"""Commit — decisions -> entities -> stamped entries -> batch
(imports.md §J; 06-test-plan §2.2 T-IMP-001/002/015…029 + the
T-CLK-018 port*)."""

import uuid
from datetime import datetime
from decimal import Decimal

import pytest
from sqlalchemy import select

from app.models import Engagement, ImportBatch, Party, PendingImport, Project, TimeEntry
from app.services.imports import commit as commit_mod
from app.services.imports import staging
from app.services.imports.commit import commit_pending
from app.services.imports.errors import ImportsError
from tests.authz.conftest import actor_for
from tests.imports.conftest import csv_of, csv_row


def stage(graph, *rows, username="adi", name=None):
    actor = actor_for(graph, username)
    csv = csv_of(*(rows or (csv_row(),)))
    pending = staging.stage_upload(
        graph.session, actor, filename="weekly.csv", data=csv.encode(), batch_name=name
    )
    return actor, pending


def decide(**kw):
    base = {"operators": [], "clients": [], "projects": [], "buckets": []}
    base.update(kw)
    return base


def entries_of(graph, batch_id):
    return graph.session.scalars(
        select(TimeEntry).where(TimeEntry.import_id == batch_id)
    ).all()


# ------------------------------------------------------------ happy path


def test_imp_001_known_chain_commits_batch_plus_entries(graph):
    actor, pending = stage(graph, csv_row(), csv_row(Description="Other"))
    result = commit_pending(graph.session, actor, pending.id, decide())
    assert result["row_count"] == 2
    assert result["matched"] == 2
    assert result["unmatched"] == 0

    batch = graph.session.get(ImportBatch, uuid.UUID(result["batch_id"]))
    assert batch.row_count == 2  # T-IMP-029: parsed-entry count
    assert batch.imported_by == graph.users["adi"].id
    assert batch.source == "clockify"
    assert batch.name == "Jun 2, 2026"  # auto-named from the date range

    rows = entries_of(graph, batch.id)
    assert len(rows) == 2
    entry = rows[0]
    assert entry.project_id == graph.projects["brentwood_website"].id
    assert entry.worker_party_id == graph.parties.adi.id
    assert entry.via_engagement_id == graph.engagements["adi"].id
    assert entry.start_at == datetime(2026, 6, 2, 9, 0, 0)
    assert entry.end_at == datetime(2026, 6, 2, 10, 30, 0)
    assert entry.duration_seconds == 5400
    assert entry.billable is True
    assert entry.source == "clockify"
    # T-IMP-024: staging row consumed
    assert graph.session.get(PendingImport, pending.id) is None


def test_imp_022_stamps_use_terms_and_override_precedence(graph):
    """1.5h for adi on Brentwood:Website: cost = 1.5 × 18 = 27.00;
    billout = 1.5 × 30 (adi-specific override beats project-wide 28
    and the default 25) = 45.00 — currencies ride along."""
    actor, pending = stage(graph)
    result = commit_pending(graph.session, actor, pending.id, decide())
    entry = entries_of(graph, uuid.UUID(result["batch_id"]))[0]
    assert Decimal(entry.cost_amount) == Decimal("27.00")
    assert entry.cost_currency == "CAD"
    assert Decimal(entry.billout_amount) == Decimal("45.00")
    assert entry.billout_currency == "CAD"


def test_imp_002_clk_018_unknown_email_imports_unresolved(graph):
    actor, pending = stage(
        graph, csv_row(Email="stranger@example.com"), csv_row()
    )
    result = commit_pending(graph.session, actor, pending.id, decide())
    assert result["matched"] == 1
    assert result["unmatched"] == 1
    assert result["unknown_emails"] == ["stranger@example.com"]
    unresolved = next(
        e
        for e in entries_of(graph, uuid.UUID(result["batch_id"]))
        if e.source_user_email == "stranger@example.com"
    )
    assert unresolved.worker_party_id is None
    assert unresolved.via_engagement_id is None
    assert unresolved.cost_amount is None and unresolved.billout_amount is None
    # ...but the entity chain still resolved (project stamped)
    assert unresolved.project_id == graph.projects["brentwood_website"].id


def test_imp_017_create_decisions_build_the_full_chain(graph):
    actor, pending = stage(graph, csv_row(Client="NewCo", Project="NewClient : NewProject"))
    decisions = decide(
        operators=[{"name": "NewCo", "decision": "create"}],
        clients=[{"operator": "NewCo", "name": "NewClient", "decision": "create"}],
        projects=[
            {
                "operator": "NewCo",
                "client": "NewClient",
                "name": "NewProject",
                "decision": "create",
            }
        ],
    )
    result = commit_pending(graph.session, actor, pending.id, decisions)
    entry = entries_of(graph, uuid.UUID(result["batch_id"]))[0]

    project = graph.session.get(Project, entry.project_id)
    assert project.name == "NewProject"
    operator = graph.session.get(Party, project.operator_party_id)
    client = graph.session.get(Party, project.client_party_id)
    assert operator.name == "NewCo" and operator.kind == "org"
    assert client.name == "NewClient"
    edge = graph.session.scalar(
        select(Engagement).where(
            Engagement.type == "direct_client",
            Engagement.party_a_id == operator.id,
            Engagement.party_b_id == client.id,
        )
    )
    assert edge is not None  # the chain is engagement-backed (02 §2)


def test_imp_018_typo_mapped_to_existing_keeps_raw_text(graph):
    """'Vicotria' mapped to Victoria: the FK points at the canonical
    entity; raw_client preserves the CSV spelling (02 §3 — the legacy
    text-rewrite is structurally replaced by FK display)."""
    actor, pending = stage(graph, csv_row(Project="Vicotria : Blocks"))
    decisions = decide(
        clients=[
            {
                "operator": "Bowden Works",
                "name": "Vicotria",
                "decision": "map",
                "target_id": str(graph.parties.victoria.id),
            }
        ],
        projects=[
            {
                "operator": "Bowden Works",
                "client": "Vicotria",
                "name": "Blocks",
                "decision": "map",
                "target_id": str(graph.projects["victoria_blocks"].id),
            }
        ],
    )
    result = commit_pending(graph.session, actor, pending.id, decisions)
    entry = entries_of(graph, uuid.UUID(result["batch_id"]))[0]
    assert entry.project_id == graph.projects["victoria_blocks"].id
    assert entry.raw_client == "Vicotria"  # immutable as-parsed payload
    # no duplicate 'Vicotria' party was created
    assert (
        graph.session.scalar(select(Party).where(Party.name == "Vicotria")) is None
    )


def test_imp_015_map_target_under_wrong_parent_refused(graph):
    """DaxTech belongs to PlusROI — mapping a BW-scoped client name to
    it must refuse, and nothing may be inserted."""
    actor, pending = stage(graph, csv_row(Project="Mystery : Site"))
    decisions = decide(
        clients=[
            {
                "operator": "Bowden Works",
                "name": "Mystery",
                "decision": "map",
                "target_id": str(graph.parties.daxtech.id),
            }
        ],
        projects=[
            {"operator": "Bowden Works", "client": "Mystery", "name": "Site"}
        ],
    )
    with pytest.raises(ImportsError) as err:
        commit_pending(graph.session, actor, pending.id, decisions)
    assert (
        err.value.summary
        == 'Mapped client "Mystery" is under a different operator than expected.'
    )
    assert graph.session.scalars(select(TimeEntry)).all() == []
    assert graph.session.scalars(select(ImportBatch)).all() == []
    # staging row survives for retry
    assert graph.session.get(PendingImport, pending.id) is not None


def test_imp_015b_project_map_under_wrong_client_refused(graph):
    actor, pending = stage(graph, csv_row(Project="Victoria : Mystery"))
    decisions = decide(
        projects=[
            {
                "operator": "Bowden Works",
                "client": "Victoria",
                "name": "Mystery",
                "decision": "map",
                "target_id": str(graph.projects["daxtech_website"].id),
            }
        ]
    )
    with pytest.raises(ImportsError) as err:
        commit_pending(graph.session, actor, pending.id, decisions)
    assert (
        err.value.summary
        == 'Mapped project "Mystery" is under a different client than expected.'
    )


def test_imp_016_missing_decision_fails_per_level(graph):
    actor, pending = stage(graph, csv_row(Client="NewCo", Project="X : Y"))
    with pytest.raises(ImportsError) as err:
        commit_pending(graph.session, actor, pending.id, decide())
    assert err.value.summary == (
        'No decision provided for unknown operator "NewCo". Please re-submit the resolver.'
    )

    actor, pending = stage(graph, csv_row(Project="NewClient : Y"))
    with pytest.raises(ImportsError) as err:
        commit_pending(graph.session, actor, pending.id, decide())
    assert 'unknown client "NewClient"' in err.value.summary

    actor, pending = stage(graph, csv_row(Project="Brentwood : NewProject"))
    with pytest.raises(ImportsError) as err:
        commit_pending(graph.session, actor, pending.id, decide())
    assert 'unknown project "NewProject"' in err.value.summary


def test_existing_match_wins_over_any_decision(graph):
    """B58: a case-insensitive existing match beats even a map decision
    pointing elsewhere."""
    actor, pending = stage(graph, csv_row(Client="bowden works"))
    decisions = decide(
        operators=[
            {
                "name": "bowden works",
                "decision": "map",
                "target_id": str(graph.parties.plusroi.id),
            }
        ]
    )
    result = commit_pending(graph.session, actor, pending.id, decisions)
    entry = entries_of(graph, uuid.UUID(result["batch_id"]))[0]
    project = graph.session.get(Project, entry.project_id)
    assert project.operator_party_id == graph.parties.bw.id


# ------------------------------------------------------ incomplete buckets


def bucket_rows():
    return csv_row(Project="Brentwood", Client="")  # no " : " -> client None, op blank


def test_imp_019_bucket_map_stamps_target_project(graph):
    actor, pending = stage(graph, bucket_rows(), bucket_rows())
    decisions = decide(
        buckets=[
            {
                "operator": None,
                "client": None,
                "project": "Brentwood",
                "decision": "map",
                "target_project_id": str(graph.projects["brentwood_website"].id),
            }
        ]
    )
    result = commit_pending(graph.session, actor, pending.id, decisions)
    rows = entries_of(graph, uuid.UUID(result["batch_id"]))
    assert all(r.project_id == graph.projects["brentwood_website"].id for r in rows)
    # raw_* keeps the parsed blanks (identity for provenance)
    assert all(r.raw_operator is None and r.raw_client is None for r in rows)
    assert all(r.raw_project == "Brentwood" for r in rows)


def test_imp_019b_bucket_create_builds_chain(graph):
    actor, pending = stage(graph, bucket_rows())
    decisions = decide(
        buckets=[
            {
                "operator": None,
                "client": None,
                "project": "Brentwood",
                "decision": "create",
                "create_operator": "Bowden Works",
                "create_client": "Fresh Client",
                "create_project": "Brentwood",
            }
        ]
    )
    result = commit_pending(graph.session, actor, pending.id, decisions)
    entry = entries_of(graph, uuid.UUID(result["batch_id"]))[0]
    project = graph.session.get(Project, entry.project_id)
    assert project.name == "Brentwood"
    client = graph.session.get(Party, project.client_party_id)
    assert client.name == "Fresh Client"
    assert project.operator_party_id == graph.parties.bw.id  # reused, not duplicated


def test_imp_019c_bucket_skip_imports_blocked(graph):
    actor, pending = stage(graph, bucket_rows())
    decisions = decide(
        buckets=[
            {"operator": None, "client": None, "project": "Brentwood", "decision": "skip"}
        ]
    )
    result = commit_pending(graph.session, actor, pending.id, decisions)
    entry = entries_of(graph, uuid.UUID(result["batch_id"]))[0]
    assert entry.project_id is None  # Blocked on /entries


def test_imp_020_bucket_map_without_selection_is_skip(graph):
    actor, pending = stage(graph, bucket_rows())
    decisions = decide(
        buckets=[
            {
                "operator": None,
                "client": None,
                "project": "Brentwood",
                "decision": "map",
                "target_project_id": None,
            }
        ]
    )
    result = commit_pending(graph.session, actor, pending.id, decisions)
    entry = entries_of(graph, uuid.UUID(result["batch_id"]))[0]
    assert entry.project_id is None  # silently a skip — no error


def test_imp_021_bucket_create_with_blank_field_refused(graph):
    actor, pending = stage(graph, bucket_rows())
    decisions = decide(
        buckets=[
            {
                "operator": None,
                "client": None,
                "project": "Brentwood",
                "decision": "create",
                "create_operator": "Bowden Works",
                "create_client": "  ",
                "create_project": "Brentwood",
            }
        ]
    )
    with pytest.raises(ImportsError) as err:
        commit_pending(graph.session, actor, pending.id, decisions)
    assert err.value.summary == (
        'Create-new requires operator + client + project for the bucket "||Brentwood".'
    )


# --------------------------------------------------- guards + permissions


def test_imp_006_commit_needs_manage_imports(graph):
    actor, pending = stage(graph)
    gary = actor_for(graph, "gary")
    with pytest.raises(ImportsError) as err:
        commit_pending(graph.session, gary, pending.id, decide())
    assert err.value.code == "NOT_ALLOWED"


def test_imp_026_owner_can_commit_anyones_manager_cannot(graph):
    _, pending = stage(graph, username="rian")
    adi = actor_for(graph, "adi")
    with pytest.raises(ImportsError) as err:
        commit_pending(graph.session, adi, pending.id, decide())
    assert err.value.code == "PENDING_NOT_FOUND"  # RLS-parity: hidden, not 403

    _, adi_pending = stage(graph, username="adi")
    rian = actor_for(graph, "rian")
    result = commit_pending(graph.session, rian, adi_pending.id, decide())
    batch = graph.session.get(ImportBatch, uuid.UUID(result["batch_id"]))
    assert batch.imported_by == graph.users["rian"].id


def test_imp_027_view_as_commit_attributes_to_effective_user(graph):
    viewing = actor_for(graph, "rian", cookies={"with_view_as": "adi"})
    pending = staging.stage_upload(
        graph.session, viewing, filename="w.csv", data=csv_of(csv_row()).encode()
    )
    result = commit_pending(graph.session, viewing, pending.id, decide())
    batch = graph.session.get(ImportBatch, uuid.UUID(result["batch_id"]))
    assert batch.imported_by == graph.users["adi"].id


# ------------------------------------------------- chunking + rollback


def test_imp_023_chunked_insert_and_atomic_rollback(graph, monkeypatch):
    rows = [csv_row(Description=f"row {i}") for i in range(7)]
    actor, pending = stage(graph, *rows)

    calls: list[int] = []
    real_chunk = commit_mod._insert_chunk

    def failing_chunk(session, chunk):
        calls.append(len(chunk))
        if len(calls) == 2:
            raise RuntimeError("disk full")
        real_chunk(session, chunk)

    monkeypatch.setattr(commit_mod, "CHUNK_SIZE", 5)
    monkeypatch.setattr(commit_mod, "_insert_chunk", failing_chunk)

    with pytest.raises(ImportsError) as err:
        commit_pending(graph.session, actor, pending.id, decide())
    assert err.value.summary == "Insert failed at rows 6–7: disk full"
    assert calls == [5, 2]

    # rollback: no entries, no batch — and the staging row SURVIVES
    assert graph.session.scalars(select(TimeEntry)).all() == []
    assert graph.session.scalars(select(ImportBatch)).all() == []
    assert graph.session.get(PendingImport, pending.id) is not None

    # retry with the failure gone succeeds (the resolver-retry loop)
    monkeypatch.setattr(commit_mod, "_insert_chunk", real_chunk)
    result = commit_pending(graph.session, actor, pending.id, decide())
    assert result["row_count"] == 7


def test_rollback_discards_created_entities_too(graph, monkeypatch):
    """Rebuild-stronger-than-legacy: the transaction covers the entity
    creates as well (legacy wart #2 — debris — is gone)."""
    actor, pending = stage(graph, csv_row(Client="NewCo", Project="NewClient : NewProject"))
    decisions = decide(
        operators=[{"name": "NewCo", "decision": "create"}],
        clients=[{"operator": "NewCo", "name": "NewClient", "decision": "create"}],
        projects=[
            {
                "operator": "NewCo",
                "client": "NewClient",
                "name": "NewProject",
                "decision": "create",
            }
        ],
    )

    def boom(session, chunk):
        raise RuntimeError("boom")

    monkeypatch.setattr(commit_mod, "_insert_chunk", boom)
    with pytest.raises(ImportsError):
        commit_pending(graph.session, actor, pending.id, decisions)
    assert graph.session.scalar(select(Party).where(Party.name == "NewCo")) is None
    assert (
        graph.session.scalar(select(Project).where(Project.name == "NewProject")) is None
    )
