"""Tests for dynamic master data (companies, bank accounts, aliases).

Unit tests exercise normalization, masking, effective-window boundaries and
alias-match priority against an in-memory database; the HTTP integration
class drives a live server to cover the submission -> review -> disable
workflow, uniqueness under concurrency, masking boundaries and RBAC.
"""

from __future__ import annotations

from pathlib import Path
import tempfile
import threading
import unittest

from bank_importer import auth, master_data
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"


class MasterDataUnitTests(unittest.TestCase):
    def setUp(self) -> None:
        self.connection = connect(":memory:")
        migrate(self.connection)
        now = master_data.utc_now()
        cursor = self.connection.execute(
            "INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
            (now, now),
        )
        self.company_id = int(cursor.lastrowid)
        self.connection.commit()

    def tearDown(self) -> None:
        self.connection.close()

    def _active_account(self, number: str = "1234567890123") -> object:
        return master_data.submit_bank_account(
            self.connection,
            company_id=self.company_id,
            bank_name="中信银行",
            account_type="基本户",
            account_number=number,
            start_date="2026-01-01",
            actor=None,
        )

    def test_normalize_account_number(self) -> None:
        self.assertEqual("1234567890", master_data.normalize_account_number("1234 5678-90"))
        self.assertEqual("1234567890", master_data.normalize_account_number("１２３４５６７８９０"))
        for invalid in ("", "123", "abc123456", "12345678901234567890123456789012345"):
            with self.assertRaises(ValueError):
                master_data.normalize_account_number(invalid)

    def test_mask_account_number(self) -> None:
        self.assertEqual("****9012", master_data.mask_account_number("123456789012"))
        self.assertEqual("****123", master_data.mask_account_number("123"))

    def test_is_usable_window_boundaries(self) -> None:
        account = self._active_account()
        account = master_data.review_bank_account(
            self.connection, account["id"], "approve", None, self._admin(),
            effective_from="2026-02-01",
        )
        self.assertFalse(master_data.is_usable(account, "2026-01-31"))
        self.assertTrue(master_data.is_usable(account, "2026-02-01"))
        # A scheduled effective_to bounds the usable window (inclusive).
        with self.connection:
            self.connection.execute(
                "UPDATE bank_accounts SET effective_to = '2026-06-30' WHERE id = ?",
                (account["id"],),
            )
        account = master_data.get_account(self.connection, account["id"])
        self.assertTrue(master_data.is_usable(account, "2026-06-30"))
        self.assertFalse(master_data.is_usable(account, "2026-07-01"))
        # Disabled accounts never accept new uploads, even inside the window.
        account = master_data.review_bank_account(
            self.connection, account["id"], "disable", "账户销户", self._admin(),
            effective_to="2026-06-30",
        )
        self.assertFalse(master_data.is_usable(account, "2026-06-30"))
        # …but they still identify historical rows inside the window.
        self.assertTrue(master_data.is_identifiable(account, "2026-06-30"))
        self.assertFalse(master_data.is_identifiable(account, "2026-07-01"))

    def _admin(self):
        user = self.connection.execute(
            "SELECT * FROM users WHERE username = 'admin-u'"
        ).fetchone()
        if user is None:
            auth.create_user(self.connection, "admin-u", "AdminPass123", "admin")
            user = self.connection.execute(
                "SELECT * FROM users WHERE username = 'admin-u'"
            ).fetchone()
        return user

    def test_pending_and_returned_accounts_never_usable(self) -> None:
        account = master_data.submit_bank_account(
            self.connection,
            company_id=self.company_id,
            bank_name="中信银行",
            account_type="一般户",
            account_number="9988776655",
            start_date="2026-01-01",
            actor=None,
        )
        self.assertFalse(master_data.is_usable(account, "2026-06-01"))
        account = master_data.review_bank_account(
            self.connection, account["id"], "return", "资料不全", self._admin()
        )
        self.assertEqual("returned", account["status"])
        self.assertFalse(master_data.is_usable(account, "2026-06-01"))

    def test_alias_match_priority_and_window(self) -> None:
        exact_account = self._active_account("1111222233334")
        exact_account = master_data.review_bank_account(
            self.connection, exact_account["id"], "approve", None, self._admin(),
            effective_from="2026-01-01",
        )
        alias_account = master_data.submit_bank_account(
            self.connection,
            company_id=self.company_id,
            bank_name="建设银行",
            account_type="一般户",
            account_number="5555666677778",
            start_date="2026-01-01",
            actor=None,
        )
        alias_account = master_data.review_bank_account(
            self.connection, alias_account["id"], "approve", None, self._admin(),
            effective_from="2026-01-01",
        )
        master_data.add_alias(
            self.connection, alias_account["id"], "account", "9999000011112",
            priority=5, actor=None,
        )
        master_data.add_alias(
            self.connection, alias_account["id"], "name", "甲公司郑州分部",
            effective_from="2026-03-01", effective_to="2026-03-31", actor=None,
        )

        # Exact account number beats the account alias tier.
        master_data.add_alias(
            self.connection, alias_account["id"], "account", "1111222233334",
            priority=1, actor=None,
        )
        hits = master_data.match_account(
            self.connection, account_number="1111-2222 3333 4", on_date="2026-06-01"
        )
        self.assertEqual(exact_account["id"], hits[0]["bank_account_id"])
        self.assertEqual("account_exact", hits[0]["via"])
        self.assertEqual(alias_account["id"], hits[1]["bank_account_id"])
        self.assertEqual("account_alias", hits[1]["via"])

        # Name alias only matches inside its effective window.
        hits = master_data.match_account(
            self.connection, name="甲公司郑州分部 ", on_date="2026-03-15"
        )
        self.assertEqual(alias_account["id"], hits[0]["bank_account_id"])
        self.assertEqual("name_alias", hits[0]["via"])
        self.assertEqual(
            [],
            master_data.match_account(
                self.connection, name="甲公司郑州分部", on_date="2026-04-01"
            ),
        )

    def test_match_never_returns_pending_or_disabled(self) -> None:
        pending = master_data.submit_bank_account(
            self.connection,
            company_id=self.company_id,
            bank_name="郑州银行",
            account_type="一般户",
            account_number="7777888899990",
            actor=None,
        )
        self.assertEqual(
            [],
            master_data.match_account(self.connection, account_number="7777888899990"),
        )
        account = master_data.review_bank_account(
            self.connection, pending["id"], "approve", None, self._admin(),
            effective_from="2026-01-01",
        )
        self.assertTrue(
            master_data.match_account(self.connection, account_number="7777888899990")
        )
        master_data.review_bank_account(
            self.connection, account["id"], "disable", "销户", self._admin(),
            effective_to="2026-06-30",
        )
        # Disabled accounts still resolve dates inside their effective window…
        self.assertTrue(
            master_data.match_account(
                self.connection, account_number="7777888899990", on_date="2026-06-30"
            )
        )
        # …and never resolve dates after it.
        self.assertEqual(
            [],
            master_data.match_account(
                self.connection, account_number="7777888899990", on_date="2026-07-01"
            ),
        )


class MasterDataApiTests(unittest.TestCase):
    """Live-server workflow tests for account registration and review."""

    @classmethod
    def setUpClass(cls) -> None:
        import os

        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)
        server.ensure_bootstrap_admin(connection)
        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

        # Company A created together with its cashier login in one call.
        status, _, data = cls.admin.post_json(
            "/api/admin/companies",
            {"name": "甲公司", "credit_code": "91410100TEST", "cashier_name": "牛女士",
             "username": "cashier-a"},
        )
        assert status == 200, data
        payload = as_json(data)
        cls.company_a = payload["company_id"]
        cls.initial_a = payload["initial_password"]
        assert cls.initial_a != "cashier-a"
        assert len(cls.initial_a) >= 12

        status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "乙公司"})
        assert status == 200, data
        cls.company_b = as_json(data)["company_id"]
        status, _, data = cls.admin.post_json(
            "/api/admin/users", {"username": "cashier-b", "company_id": cls.company_b}
        )
        assert status == 200, data
        cls.initial_b = as_json(data)["initial_password"]
        assert cls.initial_b != "cashier-b"

        cls.cashier_a = cls._login_company_user("cashier-a", cls.initial_a)
        cls.cashier_b = cls._login_company_user("cashier-b", cls.initial_b)

    @classmethod
    def _login_company_user(cls, username: str, initial: str) -> Client:
        client = Client("127.0.0.1", cls.port)
        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 tearDownClass(cls) -> None:
        import os

        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()

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def submit_account(self, client: Client, number: str, bank: str = "郑州银行"):
        return client.post_json(
            "/api/company/accounts",
            {"bank_name": bank, "account_type": "一般户",
             "account_number": number, "start_date": "2026-08-01"},
        )

    def review(self, account_id: int, decision: str, reason: str = ""):
        return self.admin.post_json(
            f"/api/admin/accounts/{account_id}/review",
            {"decision": decision, "reason": reason},
        )

    # ------------------------------------------------------------------
    # Company management
    # ------------------------------------------------------------------

    def test_new_company_appears_in_admin_list_with_user(self) -> None:
        status, _, data = self.admin.get("/api/admin/companies")
        self.assertEqual(200, status)
        companies = {c["name"]: c for c in as_json(data)["companies"]}
        self.assertIn("甲公司", companies)
        self.assertEqual("91410100TEST", companies["甲公司"]["credit_code"])
        self.assertEqual("牛女士", companies["甲公司"]["cashier_name"])
        self.assertIn("cashier-a", companies["甲公司"]["usernames"])
        self.assertEqual(0, companies["乙公司"]["account_count"])

    def test_company_user_cannot_change_own_company_binding(self) -> None:
        # A company_id in the submission body is ignored: the account is
        # always bound to the session company.
        status, _, data = self.cashier_a.post_json(
            "/api/company/accounts",
            {"bank_name": "民生银行", "account_type": "一般户",
             "account_number": "6001000100010001", "company_id": self.company_b},
        )
        self.assertEqual(200, status, data)
        self.assertEqual(self.company_a, as_json(data)["account"]["company_id"])

    # ------------------------------------------------------------------
    # Account registration -> review workflow
    # ------------------------------------------------------------------

    def test_full_registration_workflow(self) -> None:
        status, _, data = self.submit_account(self.cashier_a, "6222 0210-0100 8888")
        self.assertEqual(200, status, data)
        account = as_json(data)["account"]
        account_id = account["id"]
        self.assertEqual("pending", account["status"])
        # Company view is masked only.
        self.assertEqual("****8888", account["account_number_masked"])
        self.assertNotIn("account_number", account)
        self.assertFalse(account.get("usable", True))

        # Admin audit view shows the full normalized number.
        status, _, data = self.admin.get("/api/admin/accounts")
        self.assertEqual(200, status)
        full = [a for a in as_json(data)["accounts"] if a["id"] == account_id][0]
        self.assertEqual("62220210010088 88".replace(" ", ""), full["account_number"])
        self.assertEqual("甲公司", full["company_name"])

        # Pending account cannot upload.
        sample = Path("流水模板/中信银行账户流水.xlsx")
        status, _, data = self.cashier_a.post_multipart(
            "/api/parse", {"bank_account_id": str(account_id)},
            sample.name, sample.read_bytes(),
        )
        self.assertEqual(409, status, data)

        # Approve -> active and usable.
        status, _, data = self.review(account_id, "approve")
        self.assertEqual(200, status, data)
        self.assertEqual("active", as_json(data)["account"]["status"])
        self.assertEqual("2026-08-01", as_json(data)["account"]["effective_from"])

        status, _, data = self.cashier_a.get("/api/company/accounts")
        mine = [a for a in as_json(data)["accounts"] if a["id"] == account_id][0]
        self.assertTrue(mine["usable"])

        # Approved account accepts uploads.
        status, _, data = self.cashier_a.post_multipart(
            "/api/parse", {"bank_account_id": str(account_id)},
            sample.name, sample.read_bytes(),
        )
        self.assertEqual(200, status, data)

        # Disable -> leaves the usable window.
        status, _, data = self.review(account_id, "disable", "账户销户")
        self.assertEqual(200, status, data)
        disabled = as_json(data)["account"]
        self.assertEqual("disabled", disabled["status"])
        self.assertIsNotNone(disabled["effective_to"])

        status, _, data = self.cashier_a.get("/api/company/accounts")
        mine = [a for a in as_json(data)["accounts"] if a["id"] == account_id][0]
        self.assertFalse(mine["usable"])

    def test_return_and_resubmit_reuses_same_row(self) -> None:
        status, _, data = self.submit_account(self.cashier_a, "3100998877665")
        self.assertEqual(200, status, data)
        account_id = as_json(data)["account"]["id"]

        status, _, data = self.review(account_id, "return", "开户许可证模糊")
        self.assertEqual(200, status, data)
        self.assertEqual("returned", as_json(data)["account"]["status"])

        # Return requires a reason.
        status, _, data = self.submit_account(self.cashier_a, "3100998877666")
        other_id = as_json(data)["account"]["id"]
        status, _, _ = self.review(other_id, "return")
        self.assertEqual(400, status)

        # Resubmission reopens the same row as pending.
        status, _, data = self.submit_account(self.cashier_a, "3100 9988-7766 5")
        self.assertEqual(200, status, data)
        resubmitted = as_json(data)["account"]
        self.assertEqual(account_id, resubmitted["id"])
        self.assertEqual("pending", resubmitted["status"])

        # The whole trail is auditable with actor, time and reason.
        status, _, data = self.admin.get(
            f"/api/admin/master-changes?entity_type=bank_account&entity_id={account_id}"
        )
        self.assertEqual(200, status)
        changes = as_json(data)["changes"]
        actions = [change["action"] for change in changes]
        self.assertEqual(["resubmit", "return", "submit"], actions)
        returned = [c for c in changes if c["action"] == "return"][0]
        self.assertEqual("开户许可证模糊", returned["reason"])
        self.assertEqual("group-admin", returned["actor_username"])
        self.assertIn('"pending"', returned["before_json"])
        self.assertIn('"returned"', returned["after_json"])

    def test_duplicate_normalized_number_conflicts(self) -> None:
        status, _, _ = self.submit_account(self.cashier_a, "4501111222233")
        self.assertEqual(200, status)
        # Same company, same number with different separators.
        status, _, data = self.submit_account(self.cashier_a, "4501-1112 2223-3")
        self.assertEqual(409, status, data)
        # Another company registering the same number conflicts too.
        status, _, data = self.submit_account(self.cashier_b, "4501111222233")
        self.assertEqual(409, status, data)
        self.assertIn("其他公司", as_json(data)["message"])

    def test_concurrent_submissions_create_only_one_account(self) -> None:
        number = "8800123456789"
        results: list[int] = []

        def submit() -> None:
            client = Client("127.0.0.1", self.port)
            client.cookies.update(self.cashier_a.cookies)
            status, _, _ = client.post_json(
                "/api/company/accounts",
                {"bank_name": "中信银行", "account_type": "基本户",
                 "account_number": number},
            )
            results.append(status)

        threads = [threading.Thread(target=submit) for _ in range(2)]
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join()
        self.assertEqual(sorted(results), [200, 409])

        connection = connect(self.db_path)
        try:
            count = connection.execute(
                "SELECT COUNT(*) AS n FROM bank_accounts WHERE account_number = ?",
                (number,),
            ).fetchone()["n"]
        finally:
            connection.close()
        self.assertEqual(1, count)

    # ------------------------------------------------------------------
    # RBAC and masking
    # ------------------------------------------------------------------

    def test_company_user_forbidden_on_admin_account_endpoints(self) -> None:
        for call in (
            lambda: self.cashier_a.get("/api/admin/accounts"),
            lambda: self.cashier_a.post_json(
                "/api/admin/accounts/1/review", {"decision": "approve"}
            ),
            lambda: self.cashier_a.get("/api/admin/accounts/1/aliases"),
            lambda: self.cashier_a.post_json(
                "/api/admin/accounts/1/aliases",
                {"alias_kind": "name", "alias_value": "x"},
            ),
            lambda: self.cashier_a.get("/api/admin/master-changes"),
        ):
            status, _, data = call()
            self.assertEqual(403, status, data)

    def test_admin_cannot_use_company_account_endpoint(self) -> None:
        status, _, _ = self.admin.get("/api/company/accounts")
        self.assertEqual(403, status)
        status, _, _ = self.submit_account(self.admin, "1000200030004")
        self.assertEqual(403, status)

    def test_unauthenticated_master_data_calls_return_401(self) -> None:
        anon = Client("127.0.0.1", self.port)
        for call in (
            lambda: anon.get("/api/company/accounts"),
            lambda: anon.get("/api/admin/accounts"),
            lambda: anon.post_json("/api/company/accounts", {}),
            lambda: anon.get("/api/admin/master-changes"),
        ):
            status, _, _ = call()
            self.assertEqual(401, status)

    def test_company_account_list_scoped_and_masked(self) -> None:
        status, _, data = self.cashier_b.get("/api/company/accounts")
        self.assertEqual(200, status)
        for account in as_json(data)["accounts"]:
            self.assertEqual(self.company_b, account["company_id"])
            self.assertNotIn("account_number", account)
            self.assertTrue(account["account_number_masked"].startswith("****"))

    def test_upload_with_other_companys_account_returns_404(self) -> None:
        status, _, data = self.submit_account(self.cashier_a, "5550001112223")
        account_id = as_json(data)["account"]["id"]
        status, _, _ = self.review(account_id, "approve")
        self.assertEqual(200, status)
        sample = Path("流水模板/中信银行账户流水.xlsx")
        status, _, data = self.cashier_b.post_multipart(
            "/api/parse", {"bank_account_id": str(account_id)},
            sample.name, sample.read_bytes(),
        )
        self.assertEqual(404, status, data)

    # ------------------------------------------------------------------
    # Aliases via API
    # ------------------------------------------------------------------

    def test_alias_crud_and_audit(self) -> None:
        status, _, data = self.submit_account(self.cashier_a, "6601234509876")
        account_id = as_json(data)["account"]["id"]
        status, _, _ = self.review(account_id, "approve")
        self.assertEqual(200, status)

        status, _, data = self.admin.post_json(
            f"/api/admin/accounts/{account_id}/aliases",
            {"alias_kind": "name", "alias_value": "甲公司工会",
             "effective_from": "2026-01-01", "effective_to": "2026-12-31",
             "priority": 10},
        )
        self.assertEqual(200, status, data)
        alias_id = as_json(data)["alias_id"]

        # Duplicate alias on the same account conflicts.
        status, _, _ = self.admin.post_json(
            f"/api/admin/accounts/{account_id}/aliases",
            {"alias_kind": "name", "alias_value": "甲公司工会"},
        )
        self.assertEqual(409, status)

        status, _, data = self.admin.get(f"/api/admin/accounts/{account_id}/aliases")
        self.assertEqual(200, status)
        aliases = as_json(data)["aliases"]
        self.assertEqual(1, len(aliases))
        self.assertEqual("甲公司工会", aliases[0]["alias_value"])
        self.assertEqual(10, aliases[0]["priority"])

        status, _, data = self.admin.get(
            f"/api/admin/master-changes?entity_type=account_alias&entity_id={alias_id}"
        )
        changes = as_json(data)["changes"]
        self.assertEqual("create", changes[0]["action"])
        self.assertIn("2026-12-31", changes[0]["after_json"])

        # Invalid alias input is rejected, unknown account is 404.
        status, _, _ = self.admin.post_json(
            f"/api/admin/accounts/{account_id}/aliases",
            {"alias_kind": "account", "alias_value": "abc"},
        )
        self.assertEqual(400, status)
        status, _, _ = self.admin.post_json(
            "/api/admin/accounts/99999/aliases",
            {"alias_kind": "name", "alias_value": "x"},
        )
        self.assertEqual(404, status)


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