import os
from pathlib import Path
import subprocess
import sys

import pytest

from app.document_reconciliation_backfill import classify_group, select_complete_group_batch
from app.document_reconciliation_service import (
    _command_fingerprint,
    resolve_document_links,
    select_valid_primary,
)
from scripts.migrate_document_reconciliation_v2 import actions_for_only_unambiguous
from scripts.migrate_document_reconciliation_v2 import apply_actions
from scripts.preflight_document_reconciliation_v2 import run_preflight


def _doc(doc_id: str, *, role="related", primary=False, status="OPEN", external_id=None, payload=None):
    return {"id": doc_id, "opportunity_id": "00000000-0000-0000-0000-000000000001",
            "document_kind": "quotation", "role": role, "is_primary": primary,
            "status": status, "external_id": external_id or doc_id, "payload": payload or {}}


class _Rows:
    def __init__(self, rows=None, scalar=None):
        self._rows, self._scalar = rows or [], scalar
    def scalar(self): return self._scalar
    def mappings(self): return self
    def all(self): return self._rows


class _ResolverConnection:
    def __init__(self, legacy, v2=None, schema=True):
        self.legacy, self.v2, self.schema = legacy, v2 or [], schema
    def execute(self, statement, params=None):
        sql = str(statement)
        if "to_regclass" in sql:
            return _Rows(scalar=self.schema)
        if "FROM commercial_documents d" in sql and "opportunity_document_links" not in sql:
            return _Rows(self.legacy)
        if "FROM opportunity_document_links l" in sql:
            return _Rows(self.v2)
        raise AssertionError(sql)


def _legacy_row(doc_id, kind="quotation", primary=True):
    return {"id": doc_id, "opportunity_id": "o", "document_kind": kind,
            "role": "current" if primary else "related", "is_primary": primary,
            "is_active": True, "status": "OPEN", "updated_at": "1"}


def test_resolver_works_without_v2_schema_and_with_empty_schema():
    legacy = [_legacy_row("a")]
    without = resolve_document_links("o", conn=_ResolverConnection(legacy, schema=False))
    empty = resolve_document_links("o", conn=_ResolverConnection(legacy, v2=[], schema=True))
    assert without[0]["resolution_source"] == "legacy_rollout"
    assert empty[0]["resolution_source"] == "legacy_rollout"


def test_resolver_uses_legacy_for_partial_group_and_v2_after_complete_group():
    legacy = [_legacy_row("a"), _legacy_row("b", primary=False)]
    partial_v2 = [{"document_id": "a", "document_kind": "quotation", "relationship": "PRIMARY",
                   "ended_at": None, "updated_at": "2"}]
    partial = resolve_document_links("o", conn=_ResolverConnection(legacy, partial_v2))
    assert len(partial) == 2 and {row["resolution_source"] for row in partial} == {"legacy_rollout"}
    complete_v2 = partial_v2 + [{"document_id": "b", "document_kind": "quotation",
                                 "relationship": "SECONDARY", "ended_at": None, "updated_at": "2"}]
    complete = resolve_document_links("o", conn=_ResolverConnection(legacy, complete_v2))
    assert len(complete) == 2 and {row["resolution_source"] for row in complete} == {"v2"}


def test_resolver_contract_keeps_document_id_separate_from_link_id():
    legacy = [_legacy_row("document-a")]
    v2 = [{"id": "document-a", "document_id": "document-a", "link_id": "link-z",
           "document_kind": "quotation", "relationship": "PRIMARY",
           "ended_at": None, "updated_at": "2"}]
    resolved = resolve_document_links("o", conn=_ResolverConnection(legacy, v2))
    assert resolved[0]["id"] == "document-a"
    assert resolved[0]["document_id"] == "document-a"
    assert resolved[0]["link_id"] == "link-z"

    fallback = resolve_document_links("o", conn=_ResolverConnection(legacy, schema=False))
    assert fallback[0]["id"] == fallback[0]["document_id"] == "document-a"
    assert fallback[0]["link_id"] is None


def test_v2_projection_contains_ui_document_fields_and_explicit_ids():
    source = Path("app/document_reconciliation_service.py").read_text()
    projection = source[source.index('_LINK_SELECT = """'):source.index('def list_document_links')]
    assert "d.id::text AS id" in projection
    assert "d.id::text AS document_id" in projection
    assert "l.id::text AS link_id" in projection
    for field in ("customer_id", "company", "document_type", "serie", "series_number",
                  "document_number", "version_number", "external_id", "external_url", "role",
                  "is_primary", "is_active", "status", "amount", "tax_amount", "total_amount",
                  "currency", "document_date", "due_date", "payload"):
        assert f"d.{field}" in projection, field


def test_backfill_single_primary_is_unambiguous():
    result = classify_group([_doc("a", role="current", primary=True), _doc("b")])
    assert [r["relationship"] for r in result] == ["PRIMARY", "SECONDARY"]


def test_backfill_two_primaries_requires_review():
    result = classify_group([_doc("a", role="current", primary=True), _doc("b", role="accepted", primary=True)])
    assert {r["relationship"] for r in result} == {"REVIEW_REQUIRED"}


def test_backfill_cancelled_primary_requires_review_and_never_ignored():
    result = classify_group([_doc("a", role="current", primary=True, status="cancelled")])
    assert result[0]["relationship"] == "REVIEW_REQUIRED"
    assert result[0]["cancelled"] is True


def test_detached_without_evidence_is_not_invented_removed():
    assert classify_group([_doc("a", role="detached")])[0]["relationship"] == "REVIEW_REQUIRED"
    assert classify_group([_doc("a", role="detached", payload={"removed_reason": "manual"})])[0]["relationship"] == "REMOVED"


def test_classifier_import_is_isolated_from_application_configuration():
    env = os.environ.copy()
    env.pop("DATABASE_URL", None)
    env.pop("OPENROUTER_API_KEY", None)
    result = subprocess.run(
        [
            sys.executable,
            "-c",
            "import sys; from app.document_reconciliation_backfill import classify_group; "
            "assert callable(classify_group); "
            "assert 'app.config' not in sys.modules; "
            "assert 'app.db' not in sys.modules; "
            "assert 'sqlalchemy' not in sys.modules",
        ],
        cwd=Path(__file__).resolve().parents[1],
        env=env,
        capture_output=True,
        text=True,
        check=False,
    )
    assert result.returncode == 0, result.stderr


def test_schema_has_database_guards_and_append_only_audit():
    sql = Path("migrations/007_document_reconciliation_v2.sql").read_text()
    assert "ux_document_link_primary_kind" in sql and "WHERE ended_at IS NULL AND relationship = 'PRIMARY'" in sql
    assert "ux_document_link_current" in sql
    assert "BEFORE UPDATE OR DELETE" in sql
    assert "ux_document_link_events_idempotency" in sql


def test_active_router_owns_v2_handlers_and_htmx_panel_target():
    router = Path("app/admin_ui/router.py").read_text()
    page = Path("app/admin_ui/pages/opportunities.py").read_text()
    assert "router.include_router(opportunities.router)" in router
    assert "document_reconciliation_v2_action" in page
    assert "HTMLResponse(jasmin_documents_html(opportunity_id" in page
    assert "_authenticated_actor(request)" in page


def test_sync_does_not_assign_legacy_primary_by_import_order():
    source = Path("app/reconciliation_service.py").read_text()
    function = source[source.index("def _upsert_jasmin_document_from_item"):source.index("def _", source.index("def _upsert_jasmin_document_from_item") + 10)]
    assert '"role": "related"' in function
    assert '"is_primary": False' in function
    assert "classify_sync_document" in function
    canonical = Path("app/document_reconciliation_service.py").read_text()
    assert "multiple Jasmin candidates require review" in canonical
    assert "SYNC_AMBIGUITY_DEMOTED" in canonical


def test_total_never_falls_back_to_non_primary_or_cancelled_primary():
    rows = [{"id": "secondary", "relationship": "SECONDARY", "total_amount": 999}]
    assert select_valid_primary(rows) is None
    rows.insert(0, {"id": "cancelled", "relationship": "PRIMARY", "status": "CANCELLED"})
    assert select_valid_primary(rows) is None


def test_only_unambiguous_keeps_ambiguous_group_fully_represented():
    actions = classify_group([_doc("a", role="current", primary=True),
                              _doc("b", role="accepted", primary=True)])
    selected = actions_for_only_unambiguous(actions)
    assert len(selected) == 2
    assert {row["relationship"] for row in selected} == {"REVIEW_REQUIRED"}


def test_backfill_paginates_complete_groups_with_composite_cursor():
    source = Path("scripts/migrate_document_reconciliation_v2.py").read_text()
    assert "WITH selected_groups AS" in source
    assert "GROUP BY d.opportunity_id, d.document_kind" in source
    assert "LIMIT :limit" in source
    assert "OPPORTUNITY_ID|DOCUMENT_KIND" in source
    assert "JOIN commercial_documents d ON d.opportunity_id=g.opportunity_id" in source


def test_group_larger_than_batch_size_is_never_split():
    rows = [{"id": str(index), "opportunity_id": "a", "document_kind": "quotation"}
            for index in range(5)] + [{"id": "z", "opportunity_id": "b", "document_kind": "quotation"}]
    batch = select_complete_group_batch(rows, batch_size=1)
    assert len(batch) == 5 and {row["opportunity_id"] for row in batch} == {"a"}


def test_composite_resume_keeps_other_kind_on_opportunity_boundary():
    rows = [
        {"id": "1", "opportunity_id": "a", "document_kind": "invoice"},
        {"id": "2", "opportunity_id": "a", "document_kind": "quotation"},
        {"id": "3", "opportunity_id": "b", "document_kind": "quotation"},
    ]
    first = select_complete_group_batch(rows, batch_size=1)
    cursor = (first[-1]["opportunity_id"], first[-1]["document_kind"])
    second = select_complete_group_batch(rows, batch_size=1, resume_from=cursor)
    assert [(row["opportunity_id"], row["document_kind"]) for row in second] == [("a", "quotation")]


def test_idempotency_fingerprint_retry_and_conflicting_reuse_contract():
    first, payload = _command_fingerprint(opportunity_id="o", document_id="d",
        target_relationship="PRIMARY", reason="approved")
    retry, _ = _command_fingerprint(opportunity_id="o", document_id="d",
        target_relationship="PRIMARY", reason="approved")
    conflict, _ = _command_fingerprint(opportunity_id="o", document_id="d",
        target_relationship="SECONDARY", reason="approved")
    assert first == retry and first != conflict
    source = Path("app/document_reconciliation_service.py").read_text()
    function = source[source.index("def set_document_relationship"):source.index("def set_primary_document")]
    assert function.index("_claim_idempotency") < function.index("_lock_context")
    assert "already used with a different command" in source


def test_idempotency_fingerprint_covers_every_material_command_field():
    base = dict(opportunity_id="o", document_id="d", target_relationship="PRIMARY",
                reason="approved", source="api", event_type="CHANGED", metadata={"a": 1},
                is_manual=True, actor="alice", document_kind="invoice")
    fingerprint, _ = _command_fingerprint(**base)
    for field, value in (("source", "sync"), ("event_type", "BACKFILLED"),
                         ("metadata", {"a": 2}), ("is_manual", False), ("actor", "bob"),
                         ("correlation_id", "corr-1"), ("request_id", "req-1")):
        changed, _ = _command_fingerprint(**(base | {field: value}))
        assert changed != fingerprint, field
    reordered, _ = _command_fingerprint(**(base | {"metadata": {"z": 2, "a": 1}}))
    same_reordered, _ = _command_fingerprint(**(base | {"metadata": {"a": 1, "z": 2}}))
    assert reordered == same_reordered


def test_generic_document_writer_registers_quotation_and_invoice_through_canonical_service():
    source = Path("app/commercial_service.py").read_text()
    function = source[source.index("def create_commercial_document"):source.index("def add_document_lines")]
    assert "register_created_document(" in function
    assert function.index("INSERT INTO commercial_documents") < function.index("register_created_document(")
    assert "and not v2_available" in function
    assert "UPDATE opportunity_document_links" not in function
    assert "INSERT INTO opportunity_document_links" not in function
    canonical = Path("app/document_reconciliation_service.py").read_text()
    register = canonical[canonical.index("def register_created_document"):canonical.index("def record_jasmin_status_change")]
    assert "document_reconciliation_v2_available(conn)" in register
    assert "classify_sync_document(" in register


def test_new_group_member_preserves_manual_primary_and_uses_secondary_or_review():
    source = Path("app/document_reconciliation_service.py").read_text()
    classify = source[source.index("def classify_sync_document"):source.index("def register_created_document")]
    assert 'relationship = "SECONDARY" if manual_primary' in classify
    assert 'else "REVIEW_REQUIRED"' in classify
    assert "multiple Jasmin candidates require review" in classify
    assert 'and not previous["is_manual"]' in classify


def test_all_commercial_document_producers_register_v2_relationships():
    commercial = Path("app/commercial_service.py").read_text()
    reconciliation = Path("app/reconciliation_service.py").read_text()
    assert commercial.count("INSERT INTO commercial_documents") == 1
    assert "register_created_document(" in commercial
    assert reconciliation.count("INSERT INTO commercial_documents") == 1
    assert "classify_sync_document(" in reconciliation


def test_backfill_applies_one_transaction_per_complete_group():
    source = Path("scripts/migrate_document_reconciliation_v2.py").read_text()
    function = source[source.index("def apply_actions"):source.index("def write_reports")]
    assert "groups.setdefault" in function
    assert function.count("with engine.begin() as conn") == 1
    assert "_conn=conn" in function


def test_backfill_second_document_failure_rolls_back_whole_group(monkeypatch):
    import app.db
    import app.document_reconciliation_service as service

    committed = {"links": [], "events": [], "legacy": []}
    class Transaction:
        def __enter__(self):
            self.pending = {key: [] for key in committed}
            return self
        def __exit__(self, exc_type, exc, tb):
            if exc_type is None:
                for key in committed:
                    committed[key].extend(self.pending[key])
            return False
    class FakeEngine:
        def begin(self): return Transaction()
    calls = 0
    def fake_set(*args, _conn=None, **kwargs):
        nonlocal calls
        calls += 1
        _conn.pending["links"].append(args[1])
        _conn.pending["events"].append(args[1])
        _conn.pending["legacy"].append(args[1])
        if calls == 2:
            raise RuntimeError("simulated second document failure")
    monkeypatch.setattr(app.db, "engine", FakeEngine())
    monkeypatch.setattr(service, "set_document_relationship", fake_set)
    actions = [dict(_doc("a"), relationship="PRIMARY", decision_reason="one"),
               dict(_doc("b"), relationship="SECONDARY", decision_reason="two")]
    with pytest.raises(RuntimeError, match="second document"):
        apply_actions(actions)
    assert committed == {"links": [], "events": [], "legacy": []}


def test_generic_line_writer_is_schema_compatible_and_keeps_ids_coherent():
    source = Path("app/commercial_service.py").read_text()
    function = source[source.index("def add_document_lines"):source.index("def list_commercial_documents")]
    assert "to_regclass('commercial_document_lines')" in function
    assert "FROM pg_attribute" in function
    assert "attname = 'commercial_document_id'" in function
    assert "table_schema='public'" not in function
    assert 'CAST(:document_id AS UUID), CAST(:document_id AS UUID)' in function


def test_startup_does_not_partially_apply_migration_007():
    source = Path("app/commercial_service.py").read_text()
    function = source[source.index("def ensure_commercial_schema"):source.index("def get_customer_by_tax_id")]
    assert "ADD COLUMN IF NOT EXISTS commercial_document_id" not in function
    assert "ADD COLUMN IF NOT EXISTS opportunity_document_link_id" not in function


def test_no_new_authoritative_v2_reader_outside_canonical_service():
    allowlist = {"document_reconciliation_service.py"}
    offenders = []
    for path in Path("app").rglob("*.py"):
        if path.name in allowlist:
            continue
        if "opportunity_document_links" in path.read_text(encoding="utf-8"):
            offenders.append(str(path))
    # Remaining consumers are deliberately tracked until migrated; additions
    # outside this explicit inventory fail the review gate.
    assert offenders == []


def test_membership_and_manual_reason_are_domain_guards():
    source = Path("app/document_reconciliation_service.py").read_text()
    assert "document does not belong to this opportunity" in source
    assert "reason is required for every manual relationship change" in source
    assert "commercial_documents WHERE id=CAST(:did AS UUID)" in source


def test_preflight_and_down_migration_cover_blockers_and_dependency_order():
    assert callable(run_preflight)
    preflight = Path("scripts/preflight_document_reconciliation_v2.py").read_text()
    assert "null_document_id" in preflight and "orphan_document_id" in preflight
    assert "multiple_legacy_primaries" in preflight and "duplicate_document_groups" in preflight
    down = Path("migrations/007_document_reconciliation_v2_down.sql").read_text()
    assert down.index("DROP COLUMN IF EXISTS opportunity_document_link_id") < down.index("DROP TABLE IF EXISTS opportunity_document_links")
    assert down.index("DROP TRIGGER") < down.index("DROP FUNCTION") < down.index("DROP TABLE IF EXISTS opportunity_document_link_events")
    assert "document_ledger_exported" in down and "v2_consumers_active" in down


def test_jasmin_items_keep_document_and_link_provenance():
    source = Path("app/reconciliation_service.py").read_text()
    assert '"commercial_document_id": origin.get("commercial_document_id")' in source
    assert '"opportunity_document_link_id": origin.get("opportunity_document_link_id")' in source
