"""HTTP integration tests for authentication, RBAC and tenant isolation.

Spins up a real ``ThreadingHTTPServer`` with a temp database/storage and
drives it with stdlib ``http.client`` (cookies handled by hand). The server
module reads ``APP_DB_PATH`` / ``APP_STORAGE_DIR`` from module globals at
request time, so tests patch them per class.
"""

from __future__ import annotations

from http.client import HTTPConnection
from http.cookies import SimpleCookie
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest

from bank_importer import auth
from bank_importer.db import connect, migrate, utc_now

import server


ROOT = Path(__file__).resolve().parents[1]
SAMPLES = ROOT / "流水模板"
CCB_SAMPLE = SAMPLES / "中国建设银行账户流水.xls"
CITIC_SAMPLE = SAMPLES / "中信银行账户流水.xlsx"

BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_A_PASSWORD = "CashierA123"


class Client:
    """Minimal HTTP client with a cookie jar."""

    def __init__(self, host: str, port: int) -> None:
        self.host = host
        self.port = port
        self.cookies: dict[str, str] = {}

    def request(
        self,
        method: str,
        path: str,
        body: bytes | None = None,
        headers: dict[str, str] | None = None,
    ) -> tuple[int, dict[str, str], bytes]:
        connection = HTTPConnection(self.host, self.port)
        request_headers = dict(headers or {})
        if self.cookies:
            request_headers["Cookie"] = "; ".join(
                f"{key}={value}" for key, value in self.cookies.items()
            )
        connection.request(method, path, body=body, headers=request_headers)
        response = connection.getresponse()
        data = response.read()
        response_headers = {key.lower(): value for key, value in response.getheaders()}
        set_cookie = response_headers.get("set-cookie")
        if set_cookie:
            cookie = SimpleCookie()
            cookie.load(set_cookie)
            for key, morsel in cookie.items():
                if morsel.value:
                    self.cookies[key] = morsel.value
                else:
                    self.cookies.pop(key, None)
        status = response.status
        connection.close()
        return status, response_headers, data

    def get(self, path: str) -> tuple[int, dict[str, str], bytes]:
        return self.request("GET", path)

    def post_json(self, path: str, payload: dict) -> tuple[int, dict, bytes]:
        return self.request(
            "POST",
            path,
            body=json.dumps(payload).encode("utf-8"),
            headers={"Content-Type": "application/json"},
        )

    def post_multipart(
        self, path: str, fields: dict[str, str], filename: str, content: bytes
    ) -> tuple[int, dict, bytes]:
        boundary = "----cwtestboundary7f3a9c1e"
        parts: list[bytes] = []
        for name, value in fields.items():
            parts.append(
                f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()
            )
        parts.append(
            f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\n'
            "Content-Type: application/octet-stream\r\n\r\n".encode()
            + content
            + b"\r\n"
        )
        parts.append(f"--{boundary}--\r\n".encode())
        return self.request(
            "POST",
            path,
            body=b"".join(parts),
            headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
        )


def as_json(data: bytes) -> dict:
    return json.loads(data.decode("utf-8"))


class ServerAuthMatrixTests(unittest.TestCase):
    """One live server; setUpClass builds the shared fixture via the API."""

    @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)
        generated = server.ensure_bootstrap_admin(connection)
        assert generated is None, "env password set, nothing should be generated"
        connection.close()

        class QuietHandler(server.AppHandler):
            def log_message(self, *args) -> None:  # silence per-request logs
                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.known_passwords = {BOOTSTRAP_PASSWORD, ADMIN_PASSWORD, CASHIER_A_PASSWORD}

        # --- Admin bootstrap: must_change_password gate, then change. ---
        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
        assert as_json(data)["must_change_password"] is True
        status, _, data = cls.admin.get("/api/batches")
        assert status == 403, "must_change_password must block API access"
        status, _, data = cls.admin.post_json(
            "/api/password/change",
            {"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
        )
        assert status == 200, data
        status, _, _ = cls.admin.get("/api/batches")
        assert status == 200

        # --- Companies A and B. ---
        status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "甲公司"})
        assert status == 200, data
        cls.company_a = as_json(data)["company_id"]
        status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "乙公司"})
        assert status == 200, data
        cls.company_b = as_json(data)["company_id"]

        # --- Company user A: random one-time initial password, forced change. ---
        status, _, data = cls.admin.post_json(
            "/api/admin/users", {"username": "cashier-a", "company_id": cls.company_a}
        )
        assert status == 200, data
        payload = as_json(data)
        # Security: the initial password is random and never equals the username.
        assert payload["initial_password"] != "cashier-a"
        assert len(payload["initial_password"]) >= 12
        cls.initial_password_a = payload["initial_password"]
        cls.known_passwords.add(cls.initial_password_a)

        cls.cashier_a = Client("127.0.0.1", cls.port)
        status, _, data = cls.cashier_a.post_json(
            "/api/login",
            {
                "username": "cashier-a",
                "password": cls.initial_password_a,
                "portal": "company",
            },
        )
        assert status == 200, data
        assert as_json(data)["must_change_password"] is True
        status, _, _ = cls.cashier_a.get("/api/batches")
        assert status == 403, "must_change_password must block company API access"
        status, _, data = cls.cashier_a.post_json(
            "/api/password/change",
            {"old_password": cls.initial_password_a, "new_password": CASHIER_A_PASSWORD},
        )
        assert status == 200, data

        # --- B gets a batch (admin upload), A gets its own batch. ---
        status, _, data = cls.admin.post_multipart(
            "/api/parse",
            {"company_id": str(cls.company_b)},
            CCB_SAMPLE.name,
            CCB_SAMPLE.read_bytes(),
        )
        assert status == 200, data
        cls.b_batch_id = as_json(data)["batch_id"]
        cls.confirm_all_sheets(cls.admin, cls.b_batch_id)

        status, _, data = cls.cashier_a.post_multipart(
            "/api/parse", {}, CITIC_SAMPLE.name, CITIC_SAMPLE.read_bytes()
        )
        assert status == 200, data
        cls.a_batch_id = as_json(data)["batch_id"]
        cls.confirm_all_sheets(cls.cashier_a, cls.a_batch_id)

    @classmethod
    def confirm_all_sheets(cls, client, batch_id: int) -> None:
        status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
        assert status == 200, data
        names = [
            sheet["sheet_name"]
            for sheet in as_json(data)["sheets"]
            if sheet["outcome"] == "parsed"
        ]
        if not names:
            return
        status, _, data = client.post_json(
            f"/api/batches/{batch_id}/confirm", {"sheets": names}
        )
        assert status == 200, data

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

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

    def fresh_client(self) -> Client:
        return Client("127.0.0.1", self.port)

    def create_company_user(self, username: str) -> tuple[Client, str, int]:
        status, _, data = self.admin.post_json(
            "/api/admin/users", {"username": username, "company_id": self.company_a}
        )
        self.assertEqual(200, status, data)
        payload = as_json(data)
        initial = payload["initial_password"]
        self.known_passwords.add(initial)
        client = self.fresh_client()
        status, _, data = client.post_json(
            "/api/login",
            {"username": username, "password": initial, "portal": "company"},
        )
        self.assertEqual(200, status, data)
        new_password = "Changed123"
        self.known_passwords.add(new_password)
        status, _, data = client.post_json(
            "/api/password/change",
            {"old_password": initial, "new_password": new_password},
        )
        self.assertEqual(200, status, data)
        return client, new_password, payload["user_id"]

    # ------------------------------------------------------------------
    # Unauthenticated matrix
    # ------------------------------------------------------------------

    def test_unauthenticated_api_calls_return_401(self) -> None:
        anon = self.fresh_client()
        for method_check in (
            lambda: anon.post_multipart("/api/parse", {}, "x.xls", b"data"),
            lambda: anon.get("/api/batches"),
            lambda: anon.get(f"/api/batches/{self.a_batch_id}/rows"),
            lambda: anon.get("/api/export.csv"),
            lambda: anon.get("/api/admin/users"),
            lambda: anon.get("/api/admin/companies"),
            lambda: anon.get("/api/admin/audit-log"),
            lambda: anon.get("/api/me"),
        ):
            status, _, data = method_check()
            self.assertEqual(401, status, data)
            self.assertEqual("error", as_json(data)["status"])

    def test_unauthenticated_portal_pages_redirect(self) -> None:
        anon = self.fresh_client()
        for page in ("/admin.html", "/company.html"):
            status, headers, _ = anon.get(page)
            self.assertEqual(302, status, page)
            self.assertEqual("/", headers.get("location"))

    def test_wrong_password_returns_generic_401(self) -> None:
        anon = self.fresh_client()
        status, _, data = anon.post_json(
            "/api/login",
            {"username": "group-admin", "password": "WrongPass1", "portal": "admin"},
        )
        self.assertEqual(401, status)
        message = as_json(data)["message"]
        self.assertNotIn("密码不正确", message.replace("账号或密码不正确", ""))

    def test_rate_limit_after_five_failures(self) -> None:
        anon = self.fresh_client()
        for _ in range(5):
            status, _, _ = anon.post_json(
                "/api/login",
                {"username": "ghost-user", "password": "WrongPass1", "portal": "admin"},
            )
            self.assertEqual(401, status)
        status, _, _ = anon.post_json(
            "/api/login",
            {"username": "ghost-user", "password": "WrongPass1", "portal": "admin"},
        )
        self.assertEqual(429, status)

    def test_portal_mismatch_returns_403(self) -> None:
        anon = self.fresh_client()
        status, _, data = anon.post_json(
            "/api/login",
            {"username": "group-admin", "password": ADMIN_PASSWORD, "portal": "company"},
        )
        self.assertEqual(403, status)
        self.assertIn("端口", as_json(data)["message"])

    def test_logout_revokes_session(self) -> None:
        client, _, _ = self.create_company_user("cashier-logout")
        status, _, _ = client.get("/api/me")
        self.assertEqual(200, status)
        status, _, _ = client.request("POST", "/api/logout")
        self.assertEqual(200, status)
        self.assertNotIn("cw_session", client.cookies)
        status, _, _ = client.get("/api/me")
        self.assertEqual(401, status)

    def test_expired_session_returns_401(self) -> None:
        client, _, user_id = self.create_company_user("cashier-expired")
        connection = connect(self.db_path)
        try:
            with connection:
                connection.execute(
                    "UPDATE sessions SET expires_at = ? WHERE user_id = ?",
                    ("2000-01-01T00:00:00+00:00", user_id),
                )
        finally:
            connection.close()
        status, _, _ = client.get("/api/me")
        self.assertEqual(401, status)

    # ------------------------------------------------------------------
    # Admin and company lifecycle
    # ------------------------------------------------------------------

    def test_duplicate_company_name_returns_409(self) -> None:
        status, _, _ = self.admin.post_json("/api/admin/companies", {"name": "甲公司"})
        self.assertEqual(409, status)

    def test_me_returns_profile_without_password_material(self) -> None:
        status, _, data = self.cashier_a.get("/api/me")
        self.assertEqual(200, status)
        payload = as_json(data)
        self.assertEqual("cashier-a", payload["username"])
        self.assertEqual("company", payload["role"])
        self.assertEqual(self.company_a, payload["company_id"])
        self.assertEqual("甲公司", payload["company_name"])
        self.assertFalse(payload["must_change_password"])

    def test_company_user_forbidden_on_all_admin_endpoints(self) -> None:
        calls = (
            lambda: self.cashier_a.get("/api/admin/companies"),
            lambda: self.cashier_a.post_json("/api/admin/companies", {"name": "丙公司"}),
            lambda: self.cashier_a.get("/api/admin/users"),
            lambda: self.cashier_a.post_json(
                "/api/admin/users", {"username": "x", "company_id": self.company_a}
            ),
            lambda: self.cashier_a.request("POST", "/api/admin/users/1/disable"),
            lambda: self.cashier_a.request("POST", "/api/admin/users/1/enable"),
            lambda: self.cashier_a.request("POST", "/api/admin/users/1/reset-password"),
            lambda: self.cashier_a.get("/api/admin/audit-log"),
        )
        for call in calls:
            status, _, data = call()
            self.assertEqual(403, status, data)

    def test_admin_upload_requires_company_id(self) -> None:
        status, _, data = self.admin.post_multipart(
            "/api/parse", {}, CCB_SAMPLE.name, CCB_SAMPLE.read_bytes()
        )
        self.assertEqual(400, status, data)

    def test_admin_batches_filter_by_company(self) -> None:
        status, _, data = self.admin.get(f"/api/batches?company_id={self.company_b}")
        self.assertEqual(200, status)
        batches = as_json(data)["batches"]
        self.assertTrue(batches)
        for batch in batches:
            self.assertEqual(self.company_b, batch["company_id"])
            self.assertEqual("乙公司", batch["company_name"])

    # ------------------------------------------------------------------
    # Tenant isolation
    # ------------------------------------------------------------------

    def test_batches_scoped_to_own_company(self) -> None:
        status, _, data = self.cashier_a.get("/api/batches")
        self.assertEqual(200, status)
        batches = as_json(data)["batches"]
        self.assertTrue(batches)
        for batch in batches:
            self.assertEqual(self.company_a, batch["company_id"])
        self.assertNotIn(self.b_batch_id, [batch["id"] for batch in batches])

    def test_idor_batch_rows_of_other_company_return_404(self) -> None:
        status, _, _ = self.cashier_a.get(f"/api/batches/{self.b_batch_id}/rows")
        self.assertEqual(404, status)
        status, _, data = self.cashier_a.get(f"/api/batches/{self.a_batch_id}/rows")
        self.assertEqual(200, status)
        self.assertTrue(as_json(data)["rows"])

    def test_export_csv_forced_to_own_company(self) -> None:
        status, _, _ = self.cashier_a.get(f"/api/export.csv?company_id={self.company_b}")
        self.assertEqual(403, status)

        status, headers, data = self.cashier_a.get("/api/export.csv")
        self.assertEqual(200, status)
        self.assertEqual("text/csv; charset=utf-8", headers.get("content-type"))
        text = data.decode("utf-8-sig")
        lines = [line for line in text.splitlines() if line]
        self.assertGreater(len(lines), 1)
        for line in lines[1:]:
            self.assertEqual(str(self.a_batch_id), line.split(",", 1)[0])

    def test_cross_company_upload_is_recorded_under_own_company(self) -> None:
        # A uploads B's file bytes while claiming company B in the form; the
        # server must bind the new (duplicate) batch to A from the session.
        status, _, data = self.cashier_a.post_multipart(
            "/api/parse",
            {"company_id": str(self.company_b)},
            CCB_SAMPLE.name,
            CCB_SAMPLE.read_bytes(),
        )
        self.assertEqual(200, status, data)
        payload = as_json(data)
        self.assertEqual("duplicate", payload["status"])
        # Cross-company duplicate: no original batch id, bank, template,
        # period, transaction count or diagnostics may be exposed.
        self.assertNotEqual(self.b_batch_id, payload.get("batch_id"))
        for leaked_key in ("bank", "template", "header_row", "period_start", "period_end", "transactions", "warnings"):
            self.assertNotIn(leaked_key, payload, leaked_key)
        connection = connect(self.db_path)
        try:
            duplicate = connection.execute(
                "SELECT company_id FROM import_batches WHERE status = 'duplicate'"
            ).fetchone()
        finally:
            connection.close()
        self.assertIsNotNone(duplicate)
        self.assertEqual(self.company_a, duplicate["company_id"])

    def test_same_company_duplicate_keeps_idempotent_summary(self) -> None:
        # A re-uploads its own file; the idempotent duplicate response keeps
        # the original batch id and the parsed summary.
        status, _, data = self.cashier_a.post_multipart(
            "/api/parse", {}, CITIC_SAMPLE.name, CITIC_SAMPLE.read_bytes()
        )
        self.assertEqual(200, status, data)
        payload = as_json(data)
        self.assertEqual("duplicate", payload["status"])
        self.assertEqual(self.a_batch_id, payload["batch_id"])
        self.assertTrue(payload.get("sheets"))
        self.assertEqual("中信银行", payload["sheets"][0].get("bank"))
        self.assertTrue(payload["sheets"][0].get("transactions", 0) > 0)
        self.assertIn("period_start", payload["sheets"][0])
        self.assertIn("warnings", payload["sheets"][0])

    # ------------------------------------------------------------------
    # Disable / reset flows
    # ------------------------------------------------------------------

    def test_disabled_user_session_and_login_rejected(self) -> None:
        client, password, user_id = self.create_company_user("cashier-disable")
        status, _, _ = client.get("/api/me")
        self.assertEqual(200, status)

        status, _, data = self.admin.request("POST", f"/api/admin/users/{user_id}/disable")
        self.assertEqual(200, status, data)

        status, _, _ = client.get("/api/me")
        self.assertEqual(401, status)

        fresh = self.fresh_client()
        status, _, data = fresh.post_json(
            "/api/login",
            {"username": "cashier-disable", "password": password, "portal": "company"},
        )
        self.assertEqual(403, status, data)

    def test_reset_password_returns_once_and_revokes_sessions(self) -> None:
        client, old_password, user_id = self.create_company_user("cashier-reset")
        status, _, data = self.admin.request(
            "POST", f"/api/admin/users/{user_id}/reset-password"
        )
        self.assertEqual(200, status, data)
        new_password = as_json(data)["initial_password"]
        self.known_passwords.add(new_password)

        status, _, _ = client.get("/api/me")
        self.assertEqual(401, status)

        fresh = self.fresh_client()
        status, _, data = fresh.post_json(
            "/api/login",
            {"username": "cashier-reset", "password": new_password, "portal": "company"},
        )
        self.assertEqual(200, status, data)
        self.assertTrue(as_json(data)["must_change_password"])

    # ------------------------------------------------------------------
    # Secrets hygiene
    # ------------------------------------------------------------------

    def test_no_response_contains_password_hash(self) -> None:
        bodies = []
        status, _, data = self.admin.get("/api/admin/users")
        self.assertEqual(200, status)
        bodies.append(data)
        status, _, data = self.admin.get("/api/admin/audit-log?limit=100")
        self.assertEqual(200, status)
        bodies.append(data)
        status, _, data = self.cashier_a.get("/api/me")
        bodies.append(data)
        status, _, data = self.cashier_a.get("/api/batches")
        bodies.append(data)
        for body in bodies:
            self.assertNotIn("password_hash", body.decode("utf-8"))

    def test_audit_log_contains_no_plaintext_passwords(self) -> None:
        connection = connect(self.db_path)
        try:
            rows = connection.execute(
                "SELECT detail, target FROM audit_log"
            ).fetchall()
        finally:
            connection.close()
        for password in self.known_passwords:
            for row in rows:
                self.assertNotIn(password, row["detail"] or "")
                self.assertNotIn(password, row["target"] or "")


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