"""B-44 manual record tests: submit idempotency, approve new/link, return,
exception, reverse, deduplication, idempotent replay and concurrency."""

from __future__ import annotations

from decimal import Decimal
import threading
import unittest

from bank_importer import ledger_events, manual_records, matching, subjects
from ledger_helpers import LedgerBase


class SubmitTests(LedgerBase):
    def submit(self, **overrides):
        params = dict(
            company_id=self.company_a,
            counterparty_company_id=self.company_b,
            occurred_at="2026-01-10T09:00:00",
            direction="incoming",
            amount="50.00",
            currency="CNY",
            funding_source="other",
            requested_subject="other_receivable",
            request_key="mr-key",
            actor=self.admin,
            summary="归还往来款",
        )
        params.update(overrides)
        return manual_records.submit(self.connection, **params)

    def test_submit_creates_pending_decision(self) -> None:
        payload = self.submit()
        self.assertEqual("pending", payload["state"])
        self.assertEqual(Decimal("50.00"), Decimal(payload["amount"]))
        self.assertEqual(1, payload["decision_revision"])
        self.assertEqual([], payload["candidates"])

    def test_submit_idempotent_on_request_key(self) -> None:
        first = self.submit()
        second = self.submit()
        self.assertEqual(first["id"], second["id"])
        self.assertTrue(second["idempotent_replay"])
        decisions = self.connection.execute(
            "SELECT COUNT(*) AS n FROM manual_record_decisions"
        ).fetchone()["n"]
        self.assertEqual(1, decisions)

    def test_submit_validations(self) -> None:
        cases = (
            ({"counterparty_company_id": self.company_a}, "不能与本公司相同"),
            ({"amount": "0"}, "必须大于零"),
            ({"amount": "abc"}, "十进制"),
            ({"funding_source": "credit_card"}, "资金来源"),
            ({"requested_subject": "equity"}, "科目"),
            ({"request_key": ""}, "request_key"),
            ({"counterparty_company_id": 9999}, "公司不存在"),
        )
        for overrides, expected in cases:
            with self.subTest(overrides=overrides):
                with self.assertRaises(manual_records.ManualInputError) as ctx:
                    self.submit(**overrides)
                self.assertIn(expected, str(ctx.exception))

    def test_bank_account_must_belong_to_submitting_company(self) -> None:
        with self.assertRaises(manual_records.ManualInputError):
            self.submit(
                funding_source="approved_bank_account",
                bank_account_id=self.account_b["id"],
            )

    def test_related_source_row_never_modified(self) -> None:
        row_id = self.add_row(
            self.company_a, own_account="6222000000000001",
            cp_account="6222000000000002", expense="100.00",
        )
        payload = self.submit(related_source_row_id=row_id)
        self.assertEqual(row_id, payload["related_source_row_id"])
        with self.assertRaises(Exception):
            self.connection.execute(
                "UPDATE source_rows SET expense = '0' WHERE id = ?", (row_id,)
            )
        self.connection.rollback()


class DecisionTests(LedgerBase):
    def setUp(self) -> None:
        super().setUp()
        self.record = manual_records.submit(
            self.connection,
            company_id=self.company_a, counterparty_company_id=self.company_b,
            occurred_at="2026-01-10T09:00:00", direction="incoming",
            amount="50.00", currency="CNY", funding_source="other",
            requested_subject="other_receivable", request_key="mr-1",
            actor=self.admin, summary="归还往来款",
        )

    def decide(self, action, **overrides):
        params = dict(
            record_id=self.record["id"],
            action=action,
            reason="管理员审核",
            expected_decision_id=self.record["decision_id"],
            request_key="dec-key",
            actor=self.admin,
        )
        params.update(overrides)
        return manual_records.decide(self.connection, **params)

    def test_approve_new_creates_confirmed_event_with_requested_subject(self) -> None:
        outcome = self.decide("approve_new")
        self.assertEqual("approved", outcome["state"])
        event_id = outcome["ledger_event_id"]
        revision = ledger_events.current_revision(self.connection, event_id)
        self.assertEqual("confirmed", revision["state"])
        # incoming 50 from A's perspective: B is the payer, A the payee.
        self.assertEqual(self.company_b, revision["payer_company_id"])
        self.assertEqual(self.company_a, revision["payee_company_id"])
        self.assertEqual(self.company_a, revision["perspective_company_id"])
        self.assertEqual("other_receivable", revision["subject_code"])
        # Exactly one position impact.
        positions = self.connection.execute(
            "SELECT * FROM eligible_position_events"
        ).fetchall()
        self.assertEqual(1, len(positions))

    def test_approve_new_replay_is_idempotent(self) -> None:
        first = self.decide("approve_new")
        second = self.decide("approve_new")
        self.assertEqual(first["decision_id"], second["decision_id"])
        events = self.connection.execute(
            "SELECT COUNT(*) AS n FROM ledger_events"
        ).fetchone()["n"]
        self.assertEqual(1, events)

    def test_return_and_exception_never_produce_balance(self) -> None:
        for action in ("return", "exception"):
            with self.subTest(action=action):
                fresh = manual_records.submit(
                    self.connection,
                    company_id=self.company_a, counterparty_company_id=self.company_b,
                    occurred_at="2026-01-11T09:00:00", direction="incoming",
                    amount="10.00", currency="CNY", funding_source="other",
                    requested_subject="receivable", request_key=f"mr-{action}",
                    actor=self.admin,
                )
                outcome = manual_records.decide(
                    self.connection, fresh["id"], action,
                    reason="材料不足" if action == "return" else "转入异常",
                    expected_decision_id=fresh["decision_id"],
                    request_key=f"dec-{action}", actor=self.admin,
                )
                expected_state = "returned" if action == "return" else "exception"
                self.assertEqual(expected_state, outcome["state"])
        self.assertEqual(0, self.connection.execute(
            "SELECT COUNT(*) AS n FROM eligible_position_events"
        ).fetchone()["n"])

    def test_reverse_of_approved_record_creates_reversal_event(self) -> None:
        approved = self.decide("approve_new")
        reversed_outcome = manual_records.decide(
            self.connection, self.record["id"], "reverse",
            reason="误录，冲销", expected_decision_id=approved["decision_id"],
            request_key="dec-rev", actor=self.admin,
        )
        self.assertEqual("reversed", reversed_outcome["state"])
        rows = self.connection.execute(
            "SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
        ).fetchall()
        self.assertEqual(2, len(rows))
        original, reversal = rows
        self.assertEqual("normal", original["posting_kind"])
        self.assertEqual("reversal", reversal["posting_kind"])
        self.assertEqual(original["payer_company_id"], reversal["payee_company_id"])
        self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
        # Net position is zero.
        signed = Decimal(original["amount"]) * (
            1 if original["payer_company_id"] == self.company_a else -1
        ) + Decimal(reversal["amount"]) * (
            1 if reversal["payer_company_id"] == self.company_a else -1
        )
        self.assertEqual(Decimal("0"), signed)

    def test_approve_link_to_bank_event_adds_evidence_not_impact(self) -> None:
        # Build an eligible bank event and confirm its subject.
        self.pair(self.company_a, self.company_b, "100.00")
        ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
        bank_ledger_id = self.ledger_events()[0]["id"]
        subjects.confirm_subject(
            self.connection, bank_ledger_id,
            perspective_company_id=self.company_a, subject_code="other_receivable",
            reason="借款", expected_revision=1, request_key="subj-1",
            actor=self.admin,
        )
        before = len(self.position_events())
        outcome = manual_records.decide(
            self.connection, self.record["id"], "approve_link",
            reason="与银行事件同源", expected_decision_id=self.record["decision_id"],
            request_key="dec-link", actor=self.admin,
            target_ledger_event_id=bank_ledger_id,
        )
        self.assertEqual("approved", outcome["state"])
        self.assertEqual(bank_ledger_id, outcome["ledger_event_id"])
        after = len(self.position_events())
        self.assertEqual(before, after)
        sources = self.connection.execute(
            "SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
            (self.record["id"],),
        ).fetchall()
        self.assertEqual(1, len(sources))
        self.assertEqual(bank_ledger_id, sources[0]["ledger_event_id"])

    def test_approve_link_requires_target(self) -> None:
        with self.assertRaises(manual_records.ManualInputError):
            self.decide("approve_link")

    def test_reverse_of_linked_record_detaches_claim_only(self) -> None:
        self.pair(self.company_a, self.company_b, "100.00")
        ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
        bank_ledger_id = self.ledger_events()[0]["id"]
        subjects.confirm_subject(
            self.connection, bank_ledger_id,
            perspective_company_id=self.company_a, subject_code="other_receivable",
            reason="借款", expected_revision=1, request_key="subj-1",
            actor=self.admin,
        )
        approved = manual_records.decide(
            self.connection, self.record["id"], "approve_link",
            reason="同源", expected_decision_id=self.record["decision_id"],
            request_key="dec-link", actor=self.admin,
            target_ledger_event_id=bank_ledger_id,
        )
        reversed_outcome = manual_records.decide(
            self.connection, self.record["id"], "reverse",
            reason="撤销关联", expected_decision_id=approved["decision_id"],
            request_key="dec-unlink", actor=self.admin,
        )
        self.assertEqual("reversed", reversed_outcome["state"])
        # No reversal event is created for a linked claim.
        reversals = self.connection.execute(
            "SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
        ).fetchone()["n"]
        self.assertEqual(0, reversals)
        # The bank impact is untouched.
        self.assertEqual(1, len(self.position_events()))

    def test_stale_expected_decision_conflicts(self) -> None:
        approved = self.decide("approve_new")
        with self.assertRaises(manual_records.ManualConflictError):
            manual_records.decide(
                self.connection, self.record["id"], "reverse",
                reason="冲销", expected_decision_id=self.record["decision_id"],
                request_key="dec-stale", actor=self.admin,
            )

    def test_returned_record_cannot_be_reversed(self) -> None:
        returned = self.decide("return", reason="材料不足")
        with self.assertRaises(manual_records.ManualConflictError):
            manual_records.decide(
                self.connection, self.record["id"], "reverse",
                reason="冲销", expected_decision_id=returned["decision_id"],
                request_key="dec-x", actor=self.admin,
            )

    def test_reverse_uses_explicit_effective_date_across_cutoff(self) -> None:
        # Reversing with an independent effective date must keep the original
        # impact for cutoffs before it and net it to zero only on/after it —
        # never rewrite the historical period retroactively.
        from bank_importer import positions
        approved = self.decide("approve_new")
        event_id = approved["ledger_event_id"]
        self.assertEqual("2026-01-10", self.current(event_id)["effective_at"][:10])

        manual_records.decide(
            self.connection, self.record["id"], "reverse",
            reason="误录冲销", expected_decision_id=approved["decision_id"],
            request_key="dec-rev-date", actor=self.admin,
            effective_at="2026-06-15",
        )
        reversal = self.connection.execute(
            "SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
        ).fetchone()
        self.assertEqual("2026-06-15", reversal["effective_at"][:10])

        before = positions.company_balances(
            self.connection, from_="2026-01-01", cutoff="2026-05-31"
        )
        after = positions.company_balances(
            self.connection, from_="2026-01-01", cutoff="2026-07-31"
        )
        for item in before["items"]:
            if item["company_id"] == self.company_a:
                self.assertEqual("-50.00", item["result"]["signed_amount"])
        for item in after["items"]:
            if item["company_id"] == self.company_a:
                self.assertEqual("0.00", item["result"]["signed_amount"])

    def test_reverse_defaults_to_approval_business_day(self) -> None:
        from datetime import datetime, timedelta, timezone
        from bank_importer import positions
        approved = self.decide("approve_new")
        manual_records.decide(
            self.connection, self.record["id"], "reverse",
            reason="误录冲销", expected_decision_id=approved["decision_id"],
            request_key="dec-rev-default", actor=self.admin,
        )
        today = datetime.now(timezone(timedelta(hours=8))).date().isoformat()
        reversal = self.connection.execute(
            "SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
        ).fetchone()
        self.assertEqual(today, reversal["effective_at"][:10])

        # The day before the approval business day keeps the original impact.
        before = positions.company_balances(
            self.connection, from_="2026-01-01",
            cutoff=(datetime.fromisoformat(today) - timedelta(days=1)).date().isoformat(),
        )
        for item in before["items"]:
            if item["company_id"] == self.company_a:
                self.assertEqual("-50.00", item["result"]["signed_amount"])

    def test_reverse_of_creation_source_with_later_linked_evidence(self) -> None:
        # approve_new created the event; a second manual record later links
        # onto it. Reversing the creation source must still create an
        # equal-amount reversal — the original economic impact must not survive
        # just because other evidence was attached later.
        approved = self.decide("approve_new")
        event_id = approved["ledger_event_id"]

        linked = manual_records.submit(
            self.connection, company_id=self.company_a,
            counterparty_company_id=self.company_b,
            occurred_at="2026-01-12T09:00:00", direction="incoming",
            amount="50.00", currency="CNY", funding_source="other",
            requested_subject="receivable", request_key="mr-link-ev",
            actor=self.admin,
        )
        manual_records.decide(
            self.connection, linked["id"], "approve_link",
            reason="同源补充证据", expected_decision_id=linked["decision_id"],
            request_key="dec-link-ev", actor=self.admin,
            target_ledger_event_id=event_id,
        )
        self.assertEqual(1, len(self.position_events()))

        reversed_outcome = manual_records.decide(
            self.connection, self.record["id"], "reverse",
            reason="误录冲销", expected_decision_id=approved["decision_id"],
            request_key="dec-rev-create", actor=self.admin,
        )
        self.assertEqual("reversed", reversed_outcome["state"])
        rows = self.connection.execute(
            "SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
        ).fetchall()
        self.assertEqual(2, len(rows))
        original, reversal = rows
        self.assertEqual("normal", original["posting_kind"])
        self.assertEqual("reversal", reversal["posting_kind"])
        self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
        # The later linked evidence stays attached to the event.
        links = self.connection.execute(
            "SELECT * FROM ledger_event_manual_sources ORDER BY manual_record_id"
        ).fetchall()
        self.assertEqual(2, len(links))

    def test_reverse_of_linked_record_after_creation_reversal_detaches_only(self) -> None:
        # M1 creates the event, M2 links; after M1's reversal created the
        # offset, reversing the linked M2 must only detach, never add a second
        # reversal event.
        approved = self.decide("approve_new")
        event_id = approved["ledger_event_id"]
        linked = manual_records.submit(
            self.connection, company_id=self.company_a,
            counterparty_company_id=self.company_b,
            occurred_at="2026-01-12T09:00:00", direction="incoming",
            amount="50.00", currency="CNY", funding_source="other",
            requested_subject="receivable", request_key="mr-link-ev2",
            actor=self.admin,
        )
        linked_approved = manual_records.decide(
            self.connection, linked["id"], "approve_link",
            reason="同源补充证据", expected_decision_id=linked["decision_id"],
            request_key="dec-link-ev2", actor=self.admin,
            target_ledger_event_id=event_id,
        )
        manual_records.decide(
            self.connection, self.record["id"], "reverse",
            reason="误录冲销", expected_decision_id=approved["decision_id"],
            request_key="dec-rev-create2", actor=self.admin,
        )
        manual_records.decide(
            self.connection, linked["id"], "reverse",
            reason="撤销关联证据", expected_decision_id=linked_approved["decision_id"],
            request_key="dec-rev-link2", actor=self.admin,
        )
        reversals = self.connection.execute(
            "SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
        ).fetchone()["n"]
        self.assertEqual(1, reversals)
        links = self.connection.execute(
            "SELECT * FROM ledger_event_manual_sources"
        ).fetchall()
        self.assertEqual(1, len(links))


class DedupAndConcurrencyTests(LedgerBase):
    def test_duplicate_manual_record_pairing_candidate(self) -> None:
        manual_records.submit(
            self.connection, company_id=self.company_a,
            counterparty_company_id=self.company_b,
            occurred_at="2026-01-10T09:00:00", direction="outgoing",
            amount="40.00", currency="CNY", funding_source="other",
            requested_subject="other_receivable", request_key="mr-a",
            actor=self.admin,
        )
        other = manual_records.submit(
            self.connection, company_id=self.company_b,
            counterparty_company_id=self.company_a,
            occurred_at="2026-01-10T10:00:00", direction="incoming",
            amount="40.00", currency="CNY", funding_source="other",
            requested_subject="other_payable", request_key="mr-b",
            actor=self.admin,
        )
        candidates = manual_records.find_candidates(self.connection, other["id"])
        self.assertEqual(1, len(candidates))
        self.assertEqual("manual_record", candidates[0]["kind"])

    def test_duplicate_bank_event_candidate_hints_link(self) -> None:
        self.pair(self.company_a, self.company_b, "100.00")
        ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
        record = manual_records.submit(
            self.connection, company_id=self.company_a,
            counterparty_company_id=self.company_b,
            occurred_at="2026-01-05T12:00:00", direction="incoming",
            amount="100.00", currency="CNY", funding_source="other",
            requested_subject="other_receivable", request_key="mr-c",
            actor=self.admin,
        )
        candidates = manual_records.find_candidates(self.connection, record["id"])
        self.assertTrue(any(c["kind"] == "bank_event" for c in candidates))

    def test_concurrent_approve_new_counts_once(self) -> None:
        record = manual_records.submit(
            self.connection, company_id=self.company_a,
            counterparty_company_id=self.company_b,
            occurred_at="2026-01-10T09:00:00", direction="incoming",
            amount="50.00", currency="CNY", funding_source="other",
            requested_subject="receivable", request_key="mr-conc",
            actor=self.admin,
        )
        errors: list[Exception] = []

        def worker(thread_key: str) -> None:
            import sqlite3

            try:
                connection = sqlite3.connect(self.db_path, timeout=30)
                connection.row_factory = sqlite3.Row
                try:
                    manual_records.decide(
                        connection, record["id"], "approve_new",
                        reason="并发审批", expected_decision_id=record["decision_id"],
                        request_key=f"dec-{thread_key}", actor=self.admin,
                    )
                finally:
                    connection.close()
            except Exception as exc:  # pragma: no cover
                errors.append(exc)

        threads = [
            threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
        ]
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join()

        # Two connections can still double-commit, so the UNIQUE manual source
        # claim must make the second write fail or be a no-op; either way only
        # one ledger event may exist.
        events = self.connection.execute(
            "SELECT COUNT(*) AS n FROM ledger_events"
        ).fetchone()["n"]
        self.assertEqual(1, events)

    def test_concurrent_submit_same_request_key_is_idempotent(self) -> None:
        import sqlite3

        results: list[dict] = []
        errors: list[Exception] = []

        def worker(thread_key: str) -> None:
            try:
                connection = sqlite3.connect(self.db_path, timeout=30)
                connection.row_factory = sqlite3.Row
                try:
                    payload = manual_records.submit(
                        connection, company_id=self.company_a,
                        counterparty_company_id=self.company_b,
                        occurred_at="2026-01-10T09:00:00", direction="incoming",
                        amount="50.00", currency="CNY", funding_source="other",
                        requested_subject="receivable",
                        request_key="mr-conc-submit", actor=self.admin,
                    )
                    results.append(payload)
                finally:
                    connection.close()
            except Exception as exc:  # pragma: no cover
                errors.append(exc)

        threads = [
            threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
        ]
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join()

        self.assertEqual([], errors)
        rows = self.connection.execute(
            "SELECT COUNT(*) AS n FROM manual_records"
        ).fetchone()["n"]
        self.assertEqual(1, rows)
        self.assertEqual(1, len({payload["id"] for payload in results}))
        decisions = self.connection.execute(
            "SELECT COUNT(*) AS n FROM manual_record_decisions"
        ).fetchone()["n"]
        self.assertEqual(1, decisions)

    def test_concurrent_confirm_subject_same_key_creates_single_revision(self) -> None:
        import sqlite3

        self.pair(self.company_a, self.company_b, "100.00")
        from bank_importer import ledger_events
        ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
        event_id = self.ledger_events()[0]["id"]

        outcomes: list[dict] = []
        errors: list[Exception] = []

        def worker(thread_key: str) -> None:
            try:
                connection = sqlite3.connect(self.db_path, timeout=30)
                connection.row_factory = sqlite3.Row
                try:
                    payload = subjects.confirm_subject(
                        connection, event_id,
                        perspective_company_id=self.company_a,
                        subject_code="other_receivable", reason="并发确认",
                        expected_revision=1, request_key="conc-subj-key",
                        actor=self.admin,
                    )
                    outcomes.append(payload)
                finally:
                    connection.close()
            except Exception as exc:  # pragma: no cover
                errors.append(exc)

        threads = [
            threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
        ]
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join()

        self.assertEqual([], errors)
        confirmed = self.connection.execute(
            "SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE state = 'confirmed'"
        ).fetchone()["n"]
        self.assertEqual(1, confirmed)
        self.assertEqual(1, len({payload["revision_id"] for payload in outcomes}))

    def test_repeated_confirm_same_key_appends_no_noise_revision(self) -> None:
        self.pair(self.company_a, self.company_b, "100.00")
        from bank_importer import ledger_events
        ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
        event_id = self.ledger_events()[0]["id"]
        first = subjects.confirm_subject(
            self.connection, event_id,
            perspective_company_id=self.company_a, subject_code="other_receivable",
            reason="确认", expected_revision=1, request_key="noise-key",
            actor=self.admin,
        )
        second = subjects.confirm_subject(
            self.connection, event_id,
            perspective_company_id=self.company_a, subject_code="other_receivable",
            reason="重复确认", expected_revision=1, request_key="noise-key",
            actor=self.admin,
        )
        self.assertEqual(first["revision_id"], second["revision_id"])
        revisions = self.connection.execute(
            "SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE ledger_event_id = ?",
            (event_id,),
        ).fetchone()["n"]
        # One pending_subject + one confirmed; the replay appended nothing.
        self.assertEqual(2, revisions)
        confirmed = self.connection.execute(
            "SELECT COUNT(*) AS n FROM ledger_event_revisions "
            "WHERE ledger_event_id = ? AND state = 'confirmed'",
            (event_id,),
        ).fetchone()["n"]
        self.assertEqual(1, confirmed)


if __name__ == "__main__":
    unittest.main()
