"""CC service semantics (cc-expenses.md B4–B54) on the fast SQLite graph
``sg``. Covers the CCE-A (workflow), CCE-B (aggregates/export selection)
and permission behaviors. The 3-color ROW STATE (CCE-V) is render-time
logic tested in the frontend (features/cc/ccState.test.ts); here we pin
the DATA that drives it (project_id / auto_assigned / null date)."""

import uuid
from datetime import UTC, datetime

import pytest

from app.services.cc import service as cc
from app.services.errors import ServiceError

VALID = "May 20, 2026\t{desc}\t${amt}\t\t$100.00"


def _batch(sg, name="April 2026 PlusROI"):
    return uuid.UUID(cc.create_batch(sg.session, sg.rian, name)["id"])


def _rule(sg, keyword, project, *, desc=None, cat=None, at=None):
    """Create an auto-rule; optionally stamp a deterministic created_at
    (SQLite CURRENT_TIMESTAMP is second-granular, so precedence-sensitive
    tests set explicit times — Postgres has microsecond ordering)."""
    res = cc.create_auto_rule(
        sg.session, sg.rian,
        {"keyword": keyword, "project_id": str(project),
         "assignment_description": desc, "category": cat},
    )
    if at is not None:
        from app.models import CcAutoRule

        sg.session.get(CcAutoRule, uuid.UUID(res["id"])).created_at = at
        sg.session.commit()
    return res


def _lines(sg, batch_id):
    return cc.batch_detail(sg.session, sg.rian, batch_id)["lines"]


def _line_by_desc(sg, batch_id, desc):
    return next(ln for ln in _lines(sg, batch_id) if ln["description"] == desc)


# -------------------------------------------------------------- batches


def test_create_batch_sets_currency_and_creator(sg):
    row = cc.create_batch(sg.session, sg.rian, "  May 2026 PlusROI  ")
    assert row["name"] == "May 2026 PlusROI"  # trimmed
    assert row["currency"] == "CAD"  # tenant default
    from app.models import CcExpenseBatch

    batch = sg.session.get(CcExpenseBatch, uuid.UUID(row["id"]))
    assert batch.created_by == sg.rian.real_user_id  # the REAL human (B10)


def test_create_batch_requires_name(sg):
    with pytest.raises(ServiceError) as exc:
        cc.create_batch(sg.session, sg.rian, "   ")
    assert exc.value.summary == "Batch name is required."


def test_cce_a_003_delete_batch_cascades_lines(sg):
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="X", amt="5.00"))
    from app.models import CcExpenseLine

    assert sg.session.query(CcExpenseLine).count() == 1
    cc.delete_batch(sg.session, sg.rian, b)
    assert sg.session.query(CcExpenseLine).count() == 0


# -------------------------------------------------------- append + rules


def test_cce_a_001_append_continues_line_index_and_order(sg):
    b = _batch(sg)
    cc.append_lines(
        sg.session, sg.rian, b,
        "\n".join(VALID.format(desc=d, amt="1.00") for d in ("A", "B", "C")),
    )
    cc.append_lines(
        sg.session, sg.rian, b,
        "\n".join(VALID.format(desc=d, amt="1.00") for d in ("D", "E")),
    )
    lines = _lines(sg, b)
    assert [ln["line_index"] for ln in lines] == [0, 1, 2, 3, 4]
    assert [ln["description"] for ln in lines] == ["A", "B", "C", "D", "E"]


def test_cce_a_004_paste_auto_assigns_matching_rule(sg):
    cc.create_auto_rule(
        sg.session, sg.rian,
        {"keyword": "FACEBK", "project_id": str(sg.proj_tuscany),
         "assignment_description": "Facebook", "category": "Ads"},
    )
    b = _batch(sg)
    res = cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="FACEBK *X", amt="7.15"))
    assert res["message"] == "Added 1 lines (1 auto-assigned)."
    line = _lines(sg, b)[0]
    assert line["project_id"] == str(sg.proj_tuscany)
    assert line["assignment_description"] == "Facebook"
    assert line["category"] == "Ads"
    assert line["auto_assigned"] is True


def test_append_no_rule_match_flash_and_unassigned(sg):
    b = _batch(sg)
    res = cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="MYSTERY", amt="9.00"))
    assert res["message"] == "Added 1 lines."
    assert _lines(sg, b)[0]["project_id"] is None


def test_cce_a_004_newest_rule_wins_on_paste(sg):
    # Two rules both match "GOOGLE …"; the NEWER (created_at DESC) wins.
    _rule(sg, "GOOGLE", sg.proj_tuscany, desc="Old", cat="C",
          at=datetime(2026, 1, 1, tzinfo=UTC))
    _rule(sg, "GOOGLE ADS", sg.proj_venetian, desc="New", cat="C",
          at=datetime(2026, 1, 2, tzinfo=UTC))
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="GOOGLE ADS 123", amt="5.00"))
    line = _lines(sg, b)[0]
    assert line["project_id"] == str(sg.proj_venetian)
    assert line["assignment_description"] == "New"


def test_cce_a_011_only_blank_lines(sg):
    # Non-empty content that parses to zero rows (whitespace/blank lines).
    b = _batch(sg)
    with pytest.raises(ServiceError) as exc:
        cc.append_lines(sg.session, sg.rian, b, "\n   \n\t\n")
    assert exc.value.summary == "No usable lines found in the paste."


def test_cce_a_012_empty_paste(sg):
    b = _batch(sg)
    with pytest.raises(ServiceError) as exc:
        cc.append_lines(sg.session, sg.rian, b, "")
    assert exc.value.summary == "Paste some lines first."


# ---------------------------------------------------------- line assign


def test_cce_a_002_saving_one_row_only_changes_that_row(sg):
    b = _batch(sg)
    cc.append_lines(
        sg.session, sg.rian, b,
        "\n".join(VALID.format(desc=d, amt="1.00") for d in ("A", "B")),
    )
    a_line = _line_by_desc(sg, b, "A")
    cc.update_line(
        sg.session, sg.rian, uuid.UUID(a_line["id"]),
        {"project_id": str(sg.proj_tuscany), "assignment_description": "x", "category": "y"},
    )
    b_line = _line_by_desc(sg, b, "B")
    assert b_line["project_id"] is None  # untouched


def test_update_line_saves_green_state_and_label(sg):
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="A", amt="1.00"))
    line = _lines(sg, b)[0]
    res = cc.update_line(
        sg.session, sg.rian, uuid.UUID(line["id"]),
        {"project_id": str(sg.proj_tuscany), "assignment_description": "Google", "category": "Ads"},
    )
    row = res["row"]
    assert row["project_id"] == str(sg.proj_tuscany)
    assert row["auto_assigned"] is False  # saved == green (B28/B32)
    assert row["project_label"] == "Tuscany : Monthly Services"


def test_cce_a_010_save_flips_auto_assigned_false_even_unchanged(sg):
    cc.create_auto_rule(
        sg.session, sg.rian,
        {"keyword": "FACEBK", "project_id": str(sg.proj_tuscany),
         "assignment_description": "Facebook", "category": "Ads"},
    )
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="FACEBK *X", amt="7.15"))
    line = _lines(sg, b)[0]
    assert line["auto_assigned"] is True
    # save with the SAME values -> still confirms (orange -> green)
    cc.update_line(
        sg.session, sg.rian, uuid.UUID(line["id"]),
        {"project_id": str(sg.proj_tuscany),
         "assignment_description": "Facebook", "category": "Ads"},
    )
    assert _lines(sg, b)[0]["auto_assigned"] is False


def test_cce_a_029_clearing_project_unassigns(sg):
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="A", amt="1.00"))
    line = _lines(sg, b)[0]
    cc.update_line(sg.session, sg.rian, uuid.UUID(line["id"]),
                   {"project_id": str(sg.proj_tuscany)})
    cc.update_line(sg.session, sg.rian, uuid.UUID(line["id"]), {"project_id": None})
    assert _lines(sg, b)[0]["project_id"] is None


def test_delete_line_removes_it(sg):
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="A", amt="1.00"))
    line = _lines(sg, b)[0]
    cc.delete_line(sg.session, sg.rian, uuid.UUID(line["id"]))
    assert _lines(sg, b) == []


# --------------------------------------------------------- reapply rules


def test_cce_a_005_creating_rule_does_not_touch_existing_lines(sg):
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="FACEBK *X", amt="7.15"))
    cc.create_auto_rule(
        sg.session, sg.rian,
        {"keyword": "FACEBK", "project_id": str(sg.proj_tuscany),
         "assignment_description": "Facebook", "category": "Ads"},
    )
    assert _lines(sg, b)[0]["project_id"] is None  # untouched by create


def test_cce_a_006_reapply_assigns_unassigned(sg):
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="FACEBK *X", amt="7.15"))
    cc.create_auto_rule(
        sg.session, sg.rian,
        {"keyword": "FACEBK", "project_id": str(sg.proj_tuscany),
         "assignment_description": "Facebook", "category": "Ads"},
    )
    res = cc.reapply_rules(sg.session, sg.rian, b)
    assert res["message"] == "Re-applied rules: 1 line auto-assigned."  # singular
    line = _lines(sg, b)[0]
    assert line["project_id"] == str(sg.proj_tuscany)
    assert line["auto_assigned"] is True


def test_cce_a_007_reapply_all_assigned(sg):
    _rule(sg, "ZZZ", sg.proj_tuscany)  # a rule must exist to reach this branch
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="A", amt="1.00"))
    line = _lines(sg, b)[0]  # "A" doesn't match "ZZZ" -> unassigned on paste
    cc.update_line(
        sg.session, sg.rian, uuid.UUID(line["id"]),
        {"project_id": str(sg.proj_tuscany)},
    )
    res = cc.reapply_rules(sg.session, sg.rian, b)
    assert res["message"] == "No unassigned lines to match."


def test_cce_a_008_reapply_no_rules(sg):
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="A", amt="1.00"))
    res = cc.reapply_rules(sg.session, sg.rian, b)
    assert res["message"] == "No auto-assign rules to apply."


def test_cce_a_009_reapply_skips_assigned_rows(sg):
    # Rule R1 (keyword FACEBK) auto-assigns on paste (orange). A newer,
    # DISTINCT rule R2 (keyword FACE) also substring-matches; reapply must
    # NOT re-touch the already-assigned row.
    _rule(sg, "FACEBK", sg.proj_tuscany, desc="Old", cat="C",
          at=datetime(2026, 1, 1, tzinfo=UTC))
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="FACEBK *X", amt="7.15"))
    _rule(sg, "FACE", sg.proj_venetian, desc="New", cat="C",
          at=datetime(2026, 1, 2, tzinfo=UTC))
    res = cc.reapply_rules(sg.session, sg.rian, b)
    assert res["message"] == "No unassigned lines to match."
    # still the ORIGINAL rule's assignment — untouched
    assert _lines(sg, b)[0]["assignment_description"] == "Old"


def test_reapply_no_new_matches(sg):
    cc.create_auto_rule(
        sg.session, sg.rian,
        {"keyword": "ZZZ", "project_id": str(sg.proj_tuscany),
         "assignment_description": "z", "category": "z"},
    )
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="NOMATCH", amt="1.00"))
    res = cc.reapply_rules(sg.session, sg.rian, b)
    assert res["message"] == "No new matches found."


# ------------------------------------------------------ rules & keywords


def test_cce_a_013_duplicate_keyword_case_insensitive(sg):
    cc.create_highlight_keyword(sg.session, sg.rian, "airtable")
    with pytest.raises(ServiceError) as exc:
        cc.create_highlight_keyword(sg.session, sg.rian, "AIRTABLE")
    assert exc.value.summary == '"AIRTABLE" is already a highlight keyword.'


def test_cce_a_013_duplicate_rule_case_insensitive(sg):
    cc.create_auto_rule(
        sg.session, sg.rian,
        {"keyword": "airtable", "project_id": str(sg.proj_tuscany)},
    )
    with pytest.raises(ServiceError) as exc:
        cc.create_auto_rule(
            sg.session, sg.rian,
            {"keyword": "AIRTABLE", "project_id": str(sg.proj_tuscany)},
        )
    assert exc.value.summary == 'A rule for "AIRTABLE" already exists.'


def test_cce_a_014_create_rule_rejects_cross_tenant_project(sg):
    with pytest.raises(ServiceError) as exc:
        cc.create_auto_rule(
            sg.session, sg.rian,
            {"keyword": "X", "project_id": str(sg.proj_other)},
        )
    assert exc.value.summary == "Selected project not found in this org."


def test_update_rule_revalidates_cross_tenant_project(sg):
    # Rebuild delta: the legacy update skipped this check; we DON'T.
    rule = cc.create_auto_rule(
        sg.session, sg.rian,
        {"keyword": "X", "project_id": str(sg.proj_tuscany)},
    )
    with pytest.raises(ServiceError) as exc:
        cc.update_auto_rule(
            sg.session, sg.rian, uuid.UUID(rule["id"]),
            {"keyword": "X", "project_id": str(sg.proj_other)},
        )
    assert exc.value.summary == "Selected project not found in this org."


def test_create_rule_requires_keyword_and_project(sg):
    with pytest.raises(ServiceError) as exc:
        cc.create_auto_rule(
            sg.session, sg.rian, {"keyword": "", "project_id": str(sg.proj_tuscany)}
        )
    assert exc.value.summary == "Keyword is required."
    with pytest.raises(ServiceError) as exc:
        cc.create_auto_rule(sg.session, sg.rian, {"keyword": "X", "project_id": None})
    assert exc.value.summary == "Pick a project."


def test_rules_payload_orders_and_labels(sg):
    cc.create_highlight_keyword(sg.session, sg.rian, "zeta")
    cc.create_highlight_keyword(sg.session, sg.rian, "alpha")
    _rule(sg, "old", sg.proj_tuscany, cat="C1", at=datetime(2026, 1, 1, tzinfo=UTC))
    _rule(sg, "new", sg.proj_venetian, cat="C2", at=datetime(2026, 1, 2, tzinfo=UTC))
    payload = cc.rules_payload(sg.session, sg.rian)
    assert [k["keyword"] for k in payload["keywords"]] == ["alpha", "zeta"]  # ASC
    assert payload["rules"][0]["keyword"] == "new"  # created_at DESC = precedence
    assert payload["rules"][0]["project_label"] == "Venetian : Monthly Services"
    assert payload["rule_categories"] == ["C1", "C2"]
    labels = {o["label"] for o in payload["project_options"]}
    assert "Tuscany : Monthly Services" in labels


# ------------------------------------------------- aggregates & export


def test_cce_b_001_batch_list_assigned_pct_and_total(sg):
    b = _batch(sg)
    cc.append_lines(
        sg.session, sg.rian, b,
        "\n".join(VALID.format(desc=d, amt=a)
                  for d, a in (("A", "10.00"), ("B", "20.00"), ("C", "30.00"), ("D", "40.00"))),
    )
    for desc in ("A", "B"):
        ln = _line_by_desc(sg, b, desc)
        cc.update_line(
            sg.session, sg.rian, uuid.UUID(ln["id"]),
            {"project_id": str(sg.proj_tuscany)},
        )
    row = next(r for r in cc.list_batches(sg.session, sg.rian) if r["id"] == str(b))
    assert row["lines"] == 4
    assert row["assigned"] == 2
    assert row["assigned_pct"] == 50
    assert row["total"] == "100.00"  # sums ALL lines, assigned or not


def test_cce_b_002_paste_block_excludes_incomplete_lines(sg):
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, "May 1, 2026\tGOOD\t$10.00\t\t$100")   # valid
    cc.append_lines(sg.session, sg.rian, b, "???\tNODATE\t$5.00\t\t$50")           # null date
    cc.append_lines(sg.session, sg.rian, b, "May 2, 2026\tNOAMT\t\t\t$40")         # null amount
    cc.append_lines(sg.session, sg.rian, b, "May 3, 2026\tUNASSIGNED\t$3.00\t\t$1")  # no project
    for desc in ("GOOD", "NODATE", "NOAMT"):
        ln = _line_by_desc(sg, b, desc)
        cc.update_line(
            sg.session, sg.rian, uuid.UUID(ln["id"]),
            {"project_id": str(sg.proj_tuscany),
             "assignment_description": "Google", "category": "Ads"},
        )
    block = cc.paste_block(sg.session, sg.rian, b)
    assert block["line_count"] == 1  # only GOOD exports
    expected = "Tuscany : Monthly Services\tGoogle\t10.00\t2026-05-01\t\tBowden Works\tAds"
    assert block["text"] == expected


def test_cce_b_003_paste_block_empty_when_nothing_assigned(sg):
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="A", amt="1.00"))
    block = cc.paste_block(sg.session, sg.rian, b)
    assert block == {"text": "", "line_count": 0}


def test_batch_detail_carries_keywords_and_categories(sg):
    cc.create_highlight_keyword(sg.session, sg.rian, "UBER")
    b = _batch(sg)
    cc.append_lines(sg.session, sg.rian, b, VALID.format(desc="A", amt="1.00"))
    ln = _lines(sg, b)[0]
    cc.update_line(sg.session, sg.rian, uuid.UUID(ln["id"]),
                   {"project_id": str(sg.proj_tuscany), "category": "General Licenses"})
    detail = cc.batch_detail(sg.session, sg.rian, b)
    assert detail["highlight_keywords"] == ["UBER"]
    assert detail["category_options"] == ["General Licenses"]
    assert detail["totals"] == {"lines": 1, "assigned": 1, "total": "1.00"}


# --------------------------------------------------------- permissions


def test_cce_a_015_non_owner_blocked_on_reads(sg):
    with pytest.raises(ServiceError) as exc:
        cc.list_batches(sg.session, sg.gary)
    assert exc.value.summary == "Owner only."
    assert exc.value.status == 403


def test_cce_a_015_non_owner_blocked_on_writes(sg):
    with pytest.raises(ServiceError) as exc:
        cc.create_batch(sg.session, sg.gary, "sneaky")
    assert exc.value.summary == "Only an owner can create expense batches."
    b = _batch(sg)  # owner-created
    with pytest.raises(ServiceError) as exc:
        cc.append_lines(sg.session, sg.gary, b, VALID.format(desc="A", amt="1.00"))
    assert exc.value.summary == "Only an owner can add expense lines."
    with pytest.raises(ServiceError) as exc:
        cc.create_auto_rule(
            sg.session, sg.gary, {"keyword": "x", "project_id": str(sg.proj_tuscany)}
        )
    assert exc.value.summary == "Only an owner can add auto-assign rules."
