"""HTTP integration tests for the canonical transfer event APIs (B-43).

Covers the admin event list/detail, manual decisions and reconcile endpoints,
personal transit mapping workflow, upload-account persistence, and the
company-side read scoping with masked counterparty evidence. Uses a real
``ThreadingHTTPServer`` like ``test_server_auth``.
"""

from __future__ import annotations

import io
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest

from openpyxl import Workbook

from bank_importer.db import connect, migrate

import server
from test_server_auth import Client, as_json

BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"

CCB_HEADER = [
    "客户账号", "账户名称", "交易时间", "借方发生额（支取）", "贷方发生额（收入）",
    "余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]

ACCOUNT_A = "6222000000000001"
ACCOUNT_B = "6222000000000002"
ACCOUNT_C = "6222000000000003"


def workbook_bytes(rows) -> bytes:
    workbook = Workbook()
    sheet = workbook.active
    sheet.title = "正常流水"
    sheet.append(CCB_HEADER)
    for row in rows:
        sheet.append(row)
    buffer = io.BytesIO()
    workbook.save(buffer)
    return buffer.getvalue()


def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"):
    return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]


def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"):
    return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "收款", ""]


class MatchingApiTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls) -> None:
        cls.temp_dir = tempfile.TemporaryDirectory()
        root = Path(cls.temp_dir.name)
        cls.db_path = root / "app.db"
        cls.storage = root / "files"

        cls._old_db_path = server.DB_PATH
        cls._old_storage = server.STORAGE_DIR
        server.DB_PATH = cls.db_path
        server.STORAGE_DIR = cls.storage

        os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
        connection = connect(cls.db_path)
        migrate(connection)
        assert server.ensure_bootstrap_admin(connection) is None
        connection.close()

        class QuietHandler(server.AppHandler):
            def log_message(self, *args) -> None:
                pass

        cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
        cls.port = cls.httpd.server_address[1]
        cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
        cls.thread.start()

        cls.admin = Client("127.0.0.1", cls.port)
        status, _, data = cls.admin.post_json(
            "/api/login",
            {"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
        )
        assert status == 200, data
        status, _, data = cls.admin.post_json(
            "/api/password/change",
            {"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
        )
        assert status == 200, data

        cls.company_a = cls._create_company("甲公司", "cashier-a")
        cls.company_b = cls._create_company("乙公司", "cashier-b")
        cls.company_c = cls._create_company("丙公司", "cashier-c")
        cls.cashier_a = cls._login_company("cashier-a", cls.company_a)
        cls.cashier_b = cls._login_company("cashier-b", cls.company_b)
        cls.cashier_c = cls._login_company("cashier-c", cls.company_c)

        cls.account_a = cls._approve_account(cls.company_a, ACCOUNT_A)
        cls.account_b = cls._approve_account(cls.company_b, ACCOUNT_B)
        cls.account_c = cls._approve_account(cls.company_c, ACCOUNT_C)

        # One A<->B matched event and one B<->C matched event.
        cls._upload_and_confirm(cls.cashier_a, cls.company_a, [outgoing(ACCOUNT_A, ACCOUNT_B, "100.00")])
        cls._upload_and_confirm(cls.cashier_b, cls.company_b, [incoming(ACCOUNT_B, ACCOUNT_A, "100.00")])
        cls._upload_and_confirm(cls.cashier_b, cls.company_b, [outgoing(ACCOUNT_B, ACCOUNT_C, "200.00")])
        cls._upload_and_confirm(cls.cashier_c, cls.company_c, [incoming(ACCOUNT_C, ACCOUNT_B, "200.00")])

    @classmethod
    def _create_company(cls, name: str, username: str) -> int:
        status, _, data = cls.admin.post_json(
            "/api/admin/companies",
            {"name": name, "username": username},
        )
        assert status == 200, data
        cls.initial_passwords.setdefault(username, as_json(data)["initial_password"])
        return as_json(data)["company_id"]

    initial_passwords: dict[str, str] = {}

    @classmethod
    def _login_company(cls, username: str, company_id: int) -> Client:
        client = Client("127.0.0.1", cls.port)
        initial = cls.initial_passwords[username]
        status, _, data = client.post_json(
            "/api/login", {"username": username, "password": initial, "portal": "company"}
        )
        assert status == 200, data
        status, _, data = client.post_json(
            "/api/password/change",
            {"old_password": initial, "new_password": CASHIER_PASSWORD},
        )
        assert status == 200, data
        return client

    @classmethod
    def _approve_account(cls, company_id: int, number: str) -> int:
        # Build the account through company submission + admin review.
        company_client = {
            cls.company_a: cls.cashier_a,
            cls.company_b: cls.cashier_b,
            cls.company_c: cls.cashier_c,
        }[company_id]
        status, _, data = company_client.post_json(
            "/api/company/accounts",
            {"bank_name": "中信银行", "account_type": "基本户",
             "account_number": number, "start_date": "2026-01-01"},
        )
        assert status == 200, data
        account_id = as_json(data)["account"]["id"]
        status, _, data = cls.admin.post_json(
            f"/api/admin/accounts/{account_id}/review",
            {"decision": "approve", "reason": "测试启用",
             "effective_from": "2026-01-01"},
        )
        assert status == 200, data
        return account_id

    @classmethod
    def _upload_and_confirm(cls, client, company_id: int, rows) -> int:
        content = workbook_bytes(rows)
        status, _, data = cls.admin.post_multipart(
            "/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
        )
        assert status == 200, data
        batch_id = as_json(data)["batch_id"]
        status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
        assert status == 200, data
        names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
        status, _, data = client.post_json(
            f"/api/batches/{batch_id}/confirm", {"sheets": names}
        )
        assert status == 200, data
        return batch_id

    @classmethod
    def tearDownClass(cls) -> None:
        cls.httpd.shutdown()
        cls.httpd.server_close()
        server.DB_PATH = cls._old_db_path
        server.STORAGE_DIR = cls._old_storage
        os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
        cls.temp_dir.cleanup()

    def events(self, client) -> list[dict]:
        status, _, data = client.get("/api/admin/transfer-events")
        self.assertEqual(200, status, data)
        return as_json(data)["events"]

    def company_events(self, client) -> list[dict]:
        status, _, data = client.get("/api/company/transfer-events")
        self.assertEqual(200, status, data)
        return as_json(data)["events"]

    # ------------------------------------------------------------------
    # Admin event views
    # ------------------------------------------------------------------

    def test_admin_lists_matched_events(self) -> None:
        events = self.events(self.admin)
        matched = [e for e in events if e["status"] == "matched"]
        self.assertGreaterEqual(len(matched), 2)
        by_amount = {e["amount"]: e for e in matched}
        self.assertEqual(self.company_a, by_amount["100.00"]["payer_company_id"])
        self.assertEqual(self.company_b, by_amount["100.00"]["payee_company_id"])
        self.assertEqual("paired", by_amount["100.00"]["pairing"])
        self.assertEqual(2, by_amount["100.00"]["evidence_count"])

    def test_admin_event_detail_has_history_and_observations(self) -> None:
        events = self.events(self.admin)
        event = next(e for e in events if e["status"] == "matched")
        status, _, data = self.admin.get(f"/api/admin/transfer-events/{event['event_id']}")
        self.assertEqual(200, status, data)
        detail = as_json(data)["event"]
        self.assertEqual("matched", detail["status"])
        self.assertEqual(2, len(detail["observations"]))
        self.assertTrue(detail["history"])
        self.assertTrue(detail["candidates"])

    def test_admin_match_exceptions(self) -> None:
        status, _, data = self.admin.get("/api/admin/match-exceptions")
        self.assertEqual(200, status, data)
        self.assertIsInstance(as_json(data)["exceptions"], list)

    # ------------------------------------------------------------------
    # Admin manual decisions and reconcile via API
    # ------------------------------------------------------------------

    def test_admin_reconcile_by_batch_is_idempotent(self) -> None:
        # Re-running reconcile on an already settled batch changes nothing.
        status, _, data = self.admin.post_json(
            "/api/admin/transfer-events/reconcile", {"batch_id": 1}
        )
        self.assertEqual(200, status, data)
        result = as_json(data)["matching"]
        self.assertEqual(0, result["created_events"])
        self.assertEqual(0, result["updated_events"])

    def test_admin_confirm_single_and_reverse_via_api(self) -> None:
        # A uploads its side only -> internal_single.
        status, _, data = self.admin.post_multipart(
            "/api/parse", {"company_id": str(self.company_a)},
            "单边.xlsx", workbook_bytes([outgoing(ACCOUNT_A, ACCOUNT_B, "300.00", "2026-02-01 10:00:00")]),
        )
        self.assertEqual(200, status, data)
        batch_id = as_json(data)["batch_id"]
        status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
        names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
        status, _, data = self.cashier_a.post_json(
            f"/api/batches/{batch_id}/confirm", {"sheets": names}
        )
        self.assertEqual(200, status, data)
        events = self.events(self.admin)
        single = next(e for e in events if e["status"] == "internal_single" and e["amount"] == "300.00")
        detail = self._admin_detail(single["event_id"])
        revision = detail["revision"]

        # Company users cannot write manual decisions.
        status, _, data = self.cashier_a.post_json(
            f"/api/admin/transfer-events/{single['event_id']}/decisions",
            {"action": "reverse", "reason": "不应允许", "expected_revision": revision},
        )
        self.assertEqual(403, status, data)

        # Admin confirms the single event based on evidence.
        status, _, data = self.admin.post_json(
            f"/api/admin/transfer-events/{single['event_id']}/decisions",
            {"action": "assign_participant", "reason": "函证确认",
             "expected_revision": revision, "request_key": "confirm-300",
             "participant": {"role": "payee", "company_id": self.company_b}},
        )
        self.assertEqual(200, status, data)
        decision = as_json(data)["decision"]
        self.assertEqual("intercompany", decision["classification"])
        self.assertTrue(decision["locked"])

        # Replaying the same request key is idempotent.
        status, _, data = self.admin.post_json(
            f"/api/admin/transfer-events/{single['event_id']}/decisions",
            {"action": "assign_participant", "reason": "函证确认",
             "expected_revision": revision, "request_key": "confirm-300",
             "participant": {"role": "payee", "company_id": self.company_b}},
        )
        self.assertEqual(200, status, data)

        # Stale revision conflicts.
        status, _, data = self.admin.post_json(
            f"/api/admin/transfer-events/{single['event_id']}/decisions",
            {"action": "reverse", "reason": "撤销", "expected_revision": revision},
        )
        self.assertEqual(409, status, data)

    def _admin_detail(self, event_id: int) -> dict:
        status, _, data = self.admin.get(f"/api/admin/transfer-events/{event_id}")
        self.assertEqual(200, status, data)
        return as_json(data)["event"]

    # ------------------------------------------------------------------
    # Company scope and masking
    # ------------------------------------------------------------------

    def test_company_sees_only_events_it_participates_in(self) -> None:
        events = self.company_events(self.cashier_a)
        self.assertTrue(events)
        for event in events:
            self.assertEqual(self.company_a, event["own_company_id"])
        matched = [e for e in events if e["status"] == "matched"]
        self.assertTrue(matched)
        self.assertEqual(
            {"乙公司"},
            {e["counterparty_company_name"] for e in matched},
        )
        # B<->C event is invisible to A.
        status, _, data = self.cashier_a.get("/api/company/transfer-events")
        bc_events = [
            e for e in as_json(data)["events"]
            if e["counterparty_company_name"] == "丙公司"
        ]
        self.assertEqual([], bc_events)

    def test_company_detail_masks_counterparty_and_own_rows_only(self) -> None:
        events = self.company_events(self.cashier_a)
        event = next(e for e in events if e["status"] == "matched")
        status, _, data = self.cashier_a.get(
            f"/api/company/transfer-events/{event['event_id']}"
        )
        self.assertEqual(200, status, data)
        detail = as_json(data)["event"]
        self.assertEqual("乙公司", detail["counterparty"]["company_name"])
        self.assertTrue(detail["counterparty"].get("account_number_masked", "").startswith("****"))
        # The counterparty's full account number never leaves the server.
        self.assertNotIn(ACCOUNT_B, json.dumps(detail, ensure_ascii=False))
        # Only A's own observation rows are exposed.
        self.assertEqual(1, len(detail["observations"]))
        observation = detail["observations"][0]
        self.assertTrue(observation["own_account_masked"].startswith("****"))
        self.assertEqual(self.company_a, observation.get("batch_company_id"))

    def test_company_cannot_read_event_it_does_not_participate_in(self) -> None:
        # The B<->C event id comes from B's own list; A probing it returns 404.
        status, _, data = self.cashier_b.get("/api/company/transfer-events")
        bc_event_ids = [
            e["event_id"] for e in as_json(data)["events"]
            if e["counterparty_company_name"] == "丙公司"
        ]
        self.assertTrue(bc_event_ids)
        for event_id in bc_event_ids:
            status, _, data = self.cashier_a.get(f"/api/company/transfer-events/{event_id}")
            self.assertEqual(404, status, data)
            status, _, data = self.cashier_a.get(f"/api/admin/transfer-events/{event_id}")
            self.assertEqual(403, status, data)

    def test_company_match_exceptions_scoped_to_own_company(self) -> None:
        status, _, data = self.admin.post_multipart(
            "/api/parse", {"company_id": str(self.company_a)},
            "未决.xlsx",
            workbook_bytes([outgoing(ACCOUNT_A, "9999999999999999", "77.00", "2026-02-05 10:00:00")]),
        )
        self.assertEqual(200, status, data)
        batch_id = as_json(data)["batch_id"]
        status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
        names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
        status, _, data = self.cashier_a.post_json(
            f"/api/batches/{batch_id}/confirm", {"sheets": names}
        )
        self.assertEqual(200, status, data)

        status, _, data = self.cashier_a.get("/api/company/match-exceptions")
        self.assertEqual(200, status, data)
        mine = [e for e in as_json(data)["exceptions"] if e["amount"] == "77.00"]
        self.assertEqual(1, len(mine))
        self.assertEqual("unresolved", mine[0]["status"])

        status, _, data = self.cashier_b.get("/api/company/match-exceptions")
        self.assertEqual(200, status, data)
        self.assertEqual(
            [],
            [e for e in as_json(data)["exceptions"] if e["amount"] == "77.00"],
        )

    def test_company_forbidden_on_admin_event_endpoints(self) -> None:
        for call in (
            lambda: self.cashier_a.get("/api/admin/transfer-events"),
            lambda: self.cashier_a.get("/api/admin/match-exceptions"),
            lambda: self.cashier_a.post_json(
                "/api/admin/transfer-events/reconcile", {"batch_id": 1}
            ),
            lambda: self.cashier_a.post_json(
                "/api/admin/personal-transit-mappings", {}
            ),
            lambda: self.cashier_a.get("/api/admin/personal-transit-mappings"),
        ):
            status, _, data = call()
            self.assertEqual(403, status, data)

    # ------------------------------------------------------------------
    # Personal transit mappings
    # ------------------------------------------------------------------

    def test_personal_transit_mapping_workflow(self) -> None:
        status, _, data = self.admin.post_json(
            "/api/admin/personal-transit-mappings",
            {"account_number": "880088008800", "account_name": "张个人",
             "represented_company_id": self.company_b,
             "allowed_direction": "incoming", "effective_from": "2026-01-01"},
        )
        self.assertEqual(200, status, data)
        mapping_id = as_json(data)["mapping"]["id"]
        self.assertEqual("pending", as_json(data)["mapping"]["status"])

        status, _, data = self.admin.post_json(
            f"/api/admin/personal-transit-mappings/{mapping_id}/review",
            {"decision": "approve", "reason": "资料齐全", "effective_from": "2026-01-01"},
        )
        self.assertEqual(200, status, data)
        self.assertEqual("active", as_json(data)["mapping"]["status"])

        status, _, data = self.admin.get("/api/admin/personal-transit-mappings")
        self.assertEqual(200, status, data)
        mappings = as_json(data)["mappings"]
        self.assertTrue(any(m["id"] == mapping_id for m in mappings))

        # Duplicate account number conflicts.
        status, _, data = self.admin.post_json(
            "/api/admin/personal-transit-mappings",
            {"account_number": "880088008800", "account_name": "张个人",
             "represented_company_id": self.company_b, "allowed_direction": "both"},
        )
        self.assertEqual(409, status, data)

    # ------------------------------------------------------------------
    # Upload account persistence
    # ------------------------------------------------------------------

    def test_upload_persists_approved_bank_account(self) -> None:
        content = workbook_bytes([outgoing(ACCOUNT_A, ACCOUNT_B, "55.00", "2026-03-01 10:00:00")])
        status, _, data = self.cashier_a.post_multipart(
            "/api/parse", {"bank_account_id": str(self.account_a)},
            "带账户.xlsx", content,
        )
        self.assertEqual(200, status, data)
        batch_id = as_json(data)["batch_id"]
        connection = connect(self.db_path)
        try:
            row = connection.execute(
                "SELECT upload_bank_account_id FROM import_batches WHERE id = ?",
                (batch_id,),
            ).fetchone()
        finally:
            connection.close()
        self.assertEqual(self.account_a, row["upload_bank_account_id"])


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