from __future__ import annotations

import csv
import io
import json
import os
from pathlib import Path
import re
from http.cookies import SimpleCookie
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

from bank_importer import (
    auth, calculation, company_transfers, dashboard, importing, ledger_events,
    manual_records, master_data, matching, multipart, personal_transit, positions,
    settings, subjects,
)
from bank_importer.db import connect, migrate, utc_now


ROOT = Path(__file__).resolve().parent
WEB_ROOT = ROOT / "web"
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
DB_PATH = Path(os.environ.get("APP_DB_PATH", ROOT / "data" / "app.db"))
STORAGE_DIR = Path(os.environ.get("APP_STORAGE_DIR", ROOT / "data" / "files"))
SESSION_COOKIE = "cw_session"
# Local plain-HTTP deployment: the cookie intentionally carries no Secure
# flag (see docs/decisions/003-auth.md).
COOKIE_FLAGS = "HttpOnly; SameSite=Lax; Path=/"


class AppHandler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=str(WEB_ROOT), **kwargs)

    # ------------------------------------------------------------------
    # Routing
    # ------------------------------------------------------------------

    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        path = parsed.path
        query = parse_qs(parsed.query)

        if path == "/api/me":
            self._handle_me()
            return
        if path == "/api/batches":
            self._handle_batches(query)
            return
        rows_match = re.fullmatch(r"/api/batches/(\d+)/rows", path)
        if rows_match:
            self._handle_batch_rows(int(rows_match.group(1)))
            return
        sheets_match = re.fullmatch(r"/api/batches/(\d+)/sheets", path)
        if sheets_match:
            self._handle_batch_sheets(int(sheets_match.group(1)))
            return
        if path == "/api/export.csv":
            self._handle_export_csv(query)
            return
        if path == "/api/admin/companies":
            self._handle_admin_companies()
            return
        if path == "/api/admin/users":
            self._handle_admin_users()
            return
        if path == "/api/admin/accounts":
            self._handle_admin_accounts(query)
            return
        aliases_match = re.fullmatch(r"/api/admin/accounts/(\d+)/aliases", path)
        if aliases_match:
            self._handle_admin_account_aliases(int(aliases_match.group(1)))
            return
        if path == "/api/admin/master-changes":
            self._handle_admin_master_changes(query)
            return
        if path == "/api/company/accounts":
            self._handle_company_accounts()
            return
        if path == "/api/admin/settings/calculation-start":
            self._handle_admin_calculation_start_get()
            return
        if path == "/api/admin/opening-balances":
            self._handle_admin_opening_balances_get()
            return
        if path == "/api/admin/coverage-gaps":
            self._handle_admin_coverage_gaps(query)
            return
        if path == "/api/admin/calculation-changes":
            self._handle_admin_calculation_changes()
            return
        if path == "/api/admin/balances/pair":
            self._handle_admin_pair_balance(query)
            return
        if path == "/api/company/coverage-gaps":
            self._handle_company_coverage_gaps()
            return
        if path == "/api/company/balances":
            self._handle_company_balances(query)
            return
        if path == "/api/company/balances/pair":
            self._handle_company_pair_balance(query)
            return
        if path == "/api/admin/audit-log":
            self._handle_admin_audit_log(query)
            return
        if path == "/api/admin/settings":
            self._handle_admin_settings()
            return
        if path == "/api/admin/reminders":
            self._handle_admin_reminders(query)
            return
        if path == "/api/admin/reminders/pending":
            self._handle_admin_reminder_pending(query)
            return
        if path == "/api/admin/transfer-events":
            self._handle_admin_transfer_events(query)
            return
        if path == "/api/admin/match-exceptions":
            self._handle_admin_match_exceptions(query)
            return
        if path == "/api/admin/dashboard":
            self._handle_admin_dashboard(query)
            return
        company_dash = re.fullmatch(r"/api/admin/dashboard/companies/(\d+)", path)
        if company_dash:
            self._handle_admin_dashboard_company(int(company_dash.group(1)), query)
            return
        if path == "/api/admin/personal-transit-mappings":
            self._handle_admin_personal_mappings(query)
            return
        event_match = re.fullmatch(r"/api/admin/transfer-events/(\d+)", path)
        if event_match:
            self._handle_admin_transfer_event_detail(int(event_match.group(1)))
            return
        if path == "/api/company/transfer-events":
            self._handle_company_transfer_events(query)
            return
        if path == "/api/company/match-exceptions":
            self._handle_company_match_exceptions(query)
            return
        if path == "/api/company/workspace":
            self._handle_company_workspace()
            return
        company_event_match = re.fullmatch(r"/api/company/transfer-events/(\d+)", path)
        if company_event_match:
            self._handle_company_transfer_event_detail(int(company_event_match.group(1)))
            return

        # B-44 intercompany positions (admin)
        if path == "/api/admin/intercompany/balances":
            self._handle_admin_intercompany_balances(query)
            return
        admin_pair = re.fullmatch(
            r"/api/admin/intercompany/pairs/(\d+)/(\d+)", path
        )
        if admin_pair:
            self._handle_admin_intercompany_pair(
                int(admin_pair.group(1)), int(admin_pair.group(2)), query
            )
            return
        if path == "/api/admin/intercompany/events":
            self._handle_admin_intercompany_events(query)
            return
        if path == "/api/admin/subject-reviews":
            self._handle_admin_subject_reviews(query)
            return
        if path == "/api/admin/manual-records":
            self._handle_admin_manual_records(query)
            return
        admin_event_match = re.fullmatch(r"/api/admin/intercompany/events/(\d+)", path)
        if admin_event_match:
            self._handle_admin_intercompany_event_detail(int(admin_event_match.group(1)))
            return
        admin_evidence = re.fullmatch(
            r"/api/admin/intercompany/events/(\d+)/evidence", path
        )
        if admin_evidence:
            self._handle_admin_intercompany_evidence(int(admin_evidence.group(1)))
            return

        # Company transfer-summary (HEL-169/HEL-175/HEL-176)
        if path == "/api/company/intercompany/summary":
            self._handle_company_intercompany_summary(query)
            return
        if path == "/api/company/intercompany/export.csv":
            self._handle_company_intercompany_export_csv(query)
            return

        # B-44 intercompany positions (company, own-company scope)
        if path == "/api/company/intercompany/balances":
            self._handle_company_intercompany_balances(query)
            return
        company_pair = re.fullmatch(r"/api/company/intercompany/pairs/(\d+)", path)
        if company_pair:
            self._handle_company_intercompany_pair(int(company_pair.group(1)), query)
            return
        if path == "/api/company/intercompany/events":
            # HEL-176 transfer-detail list shares this path with B-44 ledger
            # events; dispatch by distinctive query params (to/direction/…).
            if company_transfers.is_transfer_summary_events_query(query):
                self._handle_company_transfer_summary_events(query)
            else:
                self._handle_company_intercompany_events(query)
            return
        if path == "/api/company/manual-records":
            self._handle_company_manual_records(query)
            return
        if path == "/api/company/companies":
            self._handle_company_companies()
            return
        company_event_match = re.fullmatch(r"/api/company/intercompany/events/(\d+)", path)
        if company_event_match:
            self._handle_company_intercompany_event_detail(
                int(company_event_match.group(1))
            )
            return
        company_evidence = re.fullmatch(
            r"/api/company/intercompany/events/(\d+)/evidence", path
        )
        if company_evidence:
            self._handle_company_intercompany_evidence(int(company_evidence.group(1)))
            return

        if path == "/admin.html" and not self._guard_page("admin"):
            return
        if path == "/company.html" and not self._guard_page("company"):
            return
        super().do_GET()

    def do_POST(self) -> None:
        path = urlparse(self.path).path

        if path == "/api/login":
            self._handle_login()
            return
        if path == "/api/logout":
            self._handle_logout()
            return
        if path == "/api/password/change":
            self._handle_password_change()
            return
        if path == "/api/parse":
            self._handle_parse()
            return
        if path == "/api/admin/companies":
            self._handle_admin_create_company()
            return
        if path == "/api/admin/users":
            self._handle_admin_create_user()
            return
        user_action = re.fullmatch(
            r"/api/admin/users/(\d+)/(disable|enable|reset-password)", path
        )
        if user_action:
            self._handle_admin_user_action(int(user_action.group(1)), user_action.group(2))
            return
        if path == "/api/company/accounts":
            self._handle_company_submit_account()
            return
        account_review = re.fullmatch(r"/api/admin/accounts/(\d+)/review", path)
        if account_review:
            self._handle_admin_review_account(int(account_review.group(1)))
            return
        alias_create = re.fullmatch(r"/api/admin/accounts/(\d+)/aliases", path)
        if alias_create:
            self._handle_admin_add_alias(int(alias_create.group(1)))
            return
        confirm_match = re.fullmatch(r"/api/batches/(\d+)/confirm", path)
        if confirm_match:
            self._handle_batch_review(int(confirm_match.group(1)), "confirm")
            return
        ignore_match = re.fullmatch(r"/api/batches/(\d+)/ignore", path)
        if ignore_match:
            self._handle_batch_review(int(ignore_match.group(1)), "ignore")
            return
        if path == "/api/admin/transfer-events/reconcile":
            self._handle_admin_reconcile()
            return
        if path == "/api/admin/personal-transit-mappings":
            self._handle_admin_create_personal_mapping()
            return
        decision_match = re.fullmatch(r"/api/admin/transfer-events/(\d+)/decisions", path)
        if decision_match:
            self._handle_admin_transfer_decision(int(decision_match.group(1)))
            return
        mapping_review = re.fullmatch(r"/api/admin/personal-transit-mappings/(\d+)/review", path)
        if mapping_review:
            self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
            return
        if path == "/api/admin/settings":
            self._handle_admin_update_settings()
            return
        if path == "/api/admin/settings/calculation-start":
            self._handle_admin_calculation_start_put()
            return
        if path == "/api/admin/opening-balances":
            self._handle_admin_opening_balances_post()
            return
        opening_confirm = re.fullmatch(r"/api/admin/opening-balances/(\d+)/confirm", path)
        if opening_confirm:
            self._handle_admin_opening_balance_confirm(int(opening_confirm.group(1)))
            return
        opening_revise = re.fullmatch(r"/api/admin/opening-balances/(\d+)/revisions", path)
        if opening_revise:
            self._handle_admin_opening_balance_revise(int(opening_revise.group(1)))
            return
        opening_void = re.fullmatch(r"/api/admin/opening-balances/(\d+)/void", path)
        if opening_void:
            self._handle_admin_opening_balance_void(int(opening_void.group(1)))
            return
        if path == "/api/admin/coverage-gaps/recalculate":
            self._handle_admin_recalculate_gaps()
            return
        attestation_review = re.fullmatch(
            r"/api/admin/no-business-attestations/(\d+)/review", path
        )
        if attestation_review:
            self._handle_admin_review_attestation(int(attestation_review.group(1)))
            return
        if path == "/api/company/no-business-attestations":
            self._handle_company_submit_attestation()
            return
        if path == "/api/admin/reminders/send":
            self._handle_admin_send_reminders()
            return

        # B-44 intercompany positions (admin writes)
        subject_decision = re.fullmatch(
            r"/api/admin/intercompany/events/(\d+)/subject-decisions", path
        )
        if subject_decision:
            self._handle_admin_subject_decision(int(subject_decision.group(1)))
            return
        adjustment = re.fullmatch(
            r"/api/admin/intercompany/events/(\d+)/adjustments", path
        )
        if adjustment:
            self._handle_admin_intercompany_adjustment(int(adjustment.group(1)))
            return
        manual_decision = re.fullmatch(r"/api/admin/manual-records/(\d+)/decisions", path)
        if manual_decision:
            self._handle_admin_manual_record_decision(int(manual_decision.group(1)))
            return

        # B-44 intercompany positions (company writes)
        if path == "/api/company/manual-records":
            self._handle_company_manual_records_submit()
            return
        company_confirm = re.fullmatch(
            r"/api/company/transfer-events/(\d+)/confirm", path
        )
        if company_confirm:
            self._handle_company_transfer_confirm(int(company_confirm.group(1)))
            return
        self._send_json(404, {"status": "error", "message": "接口不存在。"})

    def do_PUT(self) -> None:
        path = urlparse(self.path).path
        if path == "/api/admin/settings/calculation-start":
            self._handle_admin_calculation_start_put()
            return
        self._send_json(404, {"status": "error", "message": "接口不存在。"})

    # ------------------------------------------------------------------
    # Session helpers
    # ------------------------------------------------------------------

    def _session_token(self) -> str | None:
        header = self.headers.get("Cookie")
        if not header:
            return None
        cookie = SimpleCookie()
        try:
            cookie.load(header)
        except Exception:
            return None
        morsel = cookie.get(SESSION_COOKIE)
        return morsel.value if morsel is not None else None

    def _current_user(self, connection):
        token = self._session_token()
        if not token:
            return None
        return auth.resolve_session(connection, token)

    def _require_user(self, connection, *, pending_password_ok: bool = False):
        user = self._current_user(connection)
        if user is None:
            self._send_json(401, {"status": "error", "message": "请先登录。"})
            return None
        if user["must_change_password"] and not pending_password_ok:
            self._send_json(
                403,
                {"status": "error", "message": "首次登录须修改密码后才能继续操作。"},
            )
            return None
        return user

    def _require_admin(self, connection):
        user = self._require_user(connection)
        if user is None:
            return None
        if user["role"] != "admin":
            self._send_json(403, {"status": "error", "message": "该操作仅限管理员。"})
            return None
        return user

    def _guard_page(self, role: str) -> bool:
        """UX-layer static page gate; real enforcement is on the APIs."""
        connection = connect(DB_PATH)
        try:
            user = self._current_user(connection)
        finally:
            connection.close()
        if user is None or user["role"] != role:
            self.send_response(302)
            self.send_header("Location", "/")
            self.send_header("Content-Length", "0")
            self.end_headers()
            return False
        return True

    def _set_session_cookie(self, token: str) -> None:
        self.send_header("Set-Cookie", f"{SESSION_COOKIE}={token}; {COOKIE_FLAGS}")

    def _clear_session_cookie(self) -> None:
        self.send_header(
            "Set-Cookie", f"{SESSION_COOKIE}=; {COOKIE_FLAGS}; Max-Age=0"
        )

    @property
    def _client_ip(self) -> str:
        return self.client_address[0]

    # ------------------------------------------------------------------
    # Auth endpoints
    # ------------------------------------------------------------------

    def _handle_login(self) -> None:
        data = self._read_json_body()
        if data is None:
            return
        username = str(data.get("username") or "").strip()
        password = str(data.get("password") or "")
        portal = str(data.get("portal") or "")
        if not username or not password or portal not in {"admin", "company"}:
            self._send_json(400, {"status": "error", "message": "请填写账号、密码并选择工作端口。"})
            return

        connection = connect(DB_PATH)
        try:
            user, reason = auth.authenticate(connection, username, password, self._client_ip)
            if reason == "rate_limited":
                self._send_json(
                    429,
                    {"status": "error", "message": "失败次数过多，请 10 分钟后再试。"},
                )
                return
            if reason == "disabled":
                self._send_json(
                    403, {"status": "error", "message": "账号已停用，请联系管理员。"}
                )
                return
            if user is None:
                self._send_json(
                    401, {"status": "error", "message": "账号或密码不正确。"}
                )
                return
            if user["role"] != portal:
                self._send_json(
                    403, {"status": "error", "message": "账号与该工作端口不匹配。"}
                )
                return
            token = auth.create_session(connection, user["id"])
            self._send_json(
                200, self._user_payload(connection, user), session_token=token
            )
        finally:
            connection.close()

    def _handle_logout(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._current_user(connection)
            token = self._session_token()
            if token:
                auth.revoke_session(connection, token)
            if user is not None:
                auth.audit(connection, "logout", actor=user, ip=self._client_ip)
            self._send_json(200, {"status": "ok"}, clear_session=True)
        finally:
            connection.close()

    def _handle_me(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._current_user(connection)
            if user is None:
                self._send_json(401, {"status": "error", "message": "请先登录。"})
                return
            self._send_json(200, self._user_payload(connection, user))
        finally:
            connection.close()

    def _handle_password_change(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection, pending_password_ok=True)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            old_password = str(data.get("old_password") or "")
            new_password = str(data.get("new_password") or "")
            if not auth.verify_password(old_password, user["password_hash"]):
                self._send_json(401, {"status": "error", "message": "原密码不正确。"})
                return
            error = auth.change_password(
                connection, user["id"], old_password, new_password
            )
            if error is not None:
                self._send_json(400, {"status": "error", "message": error})
                return
            self._send_json(200, {"status": "ok"})
        finally:
            connection.close()

    def _user_payload(self, connection, user) -> dict[str, object]:
        company_name = None
        if user["company_id"] is not None:
            company = connection.execute(
                "SELECT name FROM companies WHERE id = ?", (user["company_id"],)
            ).fetchone()
            company_name = company["name"] if company is not None else None
        return {
            "status": "ok",
            "username": user["username"],
            "role": user["role"],
            "must_change_password": bool(user["must_change_password"]),
            "company_id": user["company_id"],
            "company_name": company_name,
        }

    # ------------------------------------------------------------------
    # Statement import (existing behavior + tenant binding)
    # ------------------------------------------------------------------

    def _handle_parse(self) -> None:
        connection = connect(DB_PATH)
        upload = None
        try:
            user = self._require_user(connection)
            if user is None:
                return
            try:
                fields, upload = self._receive_upload()
                filename = os.path.basename(upload.filename)
                suffix = Path(filename).suffix.lower()
                if suffix not in {".xls", ".xlsx"}:
                    raise ValueError("仅支持 .xls 或 .xlsx 银行流水文件。")
                if not multipart.valid_file_signature(upload.path, suffix):
                    raise ValueError(
                        "文件内容与扩展名不符，可能已损坏或不是有效的 Excel 文件。"
                    )

                if user["role"] == "company":
                    # Tenant binding comes from the session only; any
                    # company_id in the multipart body is ignored.
                    company_id = user["company_id"]
                    upload_account_id, invalid = self._resolve_upload_account(
                        connection, user, fields, company_id
                    )
                    if invalid:
                        return
                else:
                    company_id = self._parse_company_field(
                        connection, fields.get("company_id")
                    )
                    if company_id is None:
                        return
                    upload_account_id, invalid = self._resolve_upload_account(
                        connection, user, fields, company_id
                    )
                    if invalid:
                        return

                result = importing.import_statement_path(
                    connection, STORAGE_DIR, filename, upload.path,
                    company_id=company_id,
                    upload_bank_account_id=upload_account_id,
                )
                auth.audit(
                    connection,
                    "import_upload",
                    actor=user,
                    target=f"batch:{result.batch_id}",
                    detail=f"{filename} -> {result.status}",
                    ip=self._client_ip,
                )
                payload = self._import_payload(connection, result)
            except (ValueError,) as exc:
                self._send_json(422, {"status": "exception", "message": str(exc)})
                return
            except Exception:
                self._send_json(
                    500,
                    {"status": "error", "message": "文件解析失败，请检查文件是否完整。"},
                )
                return

            if result.status == "exception":
                self._send_json(422, payload)
            else:
                self._send_json(200, payload)
        finally:
            if upload is not None:
                upload.path.unlink(missing_ok=True)
            connection.close()

    def _receive_upload(self) -> tuple[dict[str, str], multipart.UploadedFile]:
        """Stream the multipart body into a validated temp file.

        The whole request is never buffered in memory; file bytes are written
        to a temp file under the storage directory as they arrive and are
        never trimmed. Only the first file part is accepted.
        """
        raw_length = self.headers.get("Content-Length")
        if raw_length is None:
            raise ValueError("上传请求缺少 Content-Length。")
        try:
            content_length = int(raw_length)
        except ValueError:
            raise ValueError("上传请求的 Content-Length 无效。")
        if content_length <= 0:
            raise ValueError("文件为空或超过 20 MB 限制。")
        if content_length > MAX_UPLOAD_BYTES + multipart.MAX_FIELD_BYTES:
            raise ValueError("文件超过 20 MB 限制。")
        try:
            fields, upload = multipart.parse_upload(
                self.rfile,
                content_length,
                self.headers.get("Content-Type", ""),
                STORAGE_DIR / ".uploads",
                max_file_bytes=MAX_UPLOAD_BYTES,
            )
        except multipart.MultipartError as exc:
            raise ValueError(str(exc)) from exc
        return fields, upload

    def _resolve_upload_account(self, connection, user, fields, company_id) -> tuple[int | None, bool]:
        """Validate and return the approved upload account for a batch.

        Returns ``(account_id, False)`` when the account is accepted or when
        none was supplied, and ``(None, True)`` after sending the rejection.
        The approved account is persisted on the batch so ownership can be
        resolved later even when source rows lack an own account.
        """
        raw = fields.get("bank_account_id")
        if not raw:
            return None, False
        try:
            account_id = int(raw)
        except ValueError:
            self._send_json(400, {"status": "error", "message": "bank_account_id 参数无效。"})
            return None, True
        account = master_data.get_account(connection, account_id)
        if account is None or account["company_id"] != company_id:
            self._send_json(404, {"status": "error", "message": "账户不存在。"})
            return None, True
        if not master_data.is_usable(account, master_data.utc_today()):
            self._send_json(
                409,
                {"status": "error", "message": "该账户未启用或已超出有效期，不能上传流水。"},
            )
            return None, True
        return account_id, False

    def _parse_company_field(self, connection, raw) -> int | None:
        try:
            company_id = int(str(raw))
        except (TypeError, ValueError):
            self._send_json(
                400, {"status": "error", "message": "管理员上传必须指定有效的 company_id。"}
            )
            return None
        company = connection.execute(
            "SELECT id FROM companies WHERE id = ?", (company_id,)
        ).fetchone()
        if company is None:
            self._send_json(
                400, {"status": "error", "message": "指定的公司不存在。"}
            )
            return None
        return company_id

    # ------------------------------------------------------------------
    # Batches, rows and export
    # ------------------------------------------------------------------

    def _handle_batches(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            conditions = []
            params: list[object] = []
            if user["role"] == "company":
                conditions.append("b.company_id = ?")
                params.append(user["company_id"])
            else:
                raw = (query.get("company_id") or [None])[0]
                if raw:
                    try:
                        company_id = int(raw)
                    except ValueError:
                        self._send_json(
                            400, {"status": "error", "message": "company_id 参数无效。"}
                        )
                        return
                    conditions.append("b.company_id = ?")
                    params.append(company_id)
            where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
            rows = connection.execute(
                f"""
                SELECT b.id, b.status, b.created_at, b.company_id,
                       f.original_filename, c.name AS company_name,
                       COALESCE((
                           SELECT SUM(s.transaction_count) FROM sheet_batches s
                           WHERE s.import_batch_id = b.id
                       ), 0) AS transactions,
                       (SELECT s.bank_name FROM sheet_batches s
                        WHERE s.import_batch_id = b.id
                        ORDER BY s.id LIMIT 1) AS bank_name,
                       (SELECT MIN(s.period_start) FROM sheet_batches s
                        WHERE s.import_batch_id = b.id) AS period_start,
                       (SELECT MAX(s.period_end) FROM sheet_batches s
                        WHERE s.import_batch_id = b.id) AS period_end,
                       (SELECT COUNT(*) FROM source_rows r
                        JOIN sheet_batches s ON s.id = r.sheet_batch_id
                        JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
                        WHERE s.import_batch_id = b.id AND rv.review_status = 'confirmed')
                           AS confirmed_transactions,
                       (SELECT COUNT(*) FROM sheet_reviews r
                        WHERE r.import_batch_id = b.id AND r.review_status = 'confirmed')
                           AS confirmed_sheets,
                       (SELECT COUNT(*) FROM sheet_reviews r
                        WHERE r.import_batch_id = b.id
                          AND r.outcome = 'parsed' AND r.review_status = 'pending')
                           AS pending_sheets,
                       (SELECT COUNT(*) FROM sheet_reviews r
                        WHERE r.import_batch_id = b.id AND r.outcome = 'exception')
                           AS exception_sheets,
                       (SELECT COUNT(*) FROM sheet_reviews r
                        WHERE r.import_batch_id = b.id
                          AND (r.outcome = 'ignored' OR r.review_status = 'ignored'))
                           AS ignored_sheets
                FROM import_batches b
                JOIN source_files f ON f.id = b.source_file_id
                LEFT JOIN companies c ON c.id = b.company_id
                {where}
                ORDER BY b.id DESC
                LIMIT 500
                """,
                params,
            ).fetchall()
            self._send_json(200, {"status": "ok", "batches": [dict(row) for row in rows]})
        finally:
            connection.close()

    def _handle_batch_sheets(self, batch_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            company_id = user["company_id"] if user["role"] == "company" else None
            batch = importing.scoped_batch(connection, batch_id, company_id)
            if batch is None:
                self._send_json(404, {"status": "error", "message": "批次不存在。"})
                return
            file_row = connection.execute(
                """
                SELECT f.original_filename FROM source_files f
                JOIN import_batches b ON b.source_file_id = f.id
                WHERE b.id = ?
                """,
                (batch_id,),
            ).fetchone()
            rows = importing.sheet_review_rows(connection, batch_id)
            self._send_json(
                200,
                {
                    "status": "ok",
                    "batch_id": batch_id,
                    "company_id": batch["company_id"],
                    "original_filename": file_row["original_filename"]
                    if file_row is not None
                    else None,
                    "sheets": [self._sheet_payload(row) for row in rows],
                },
            )
        finally:
            connection.close()

    def _handle_batch_review(self, batch_id: int, decision: str) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            sheets = data.get("sheets")
            if not isinstance(sheets, list):
                self._send_json(
                    400, {"status": "error", "message": "sheets 必须是工作表名称数组。"}
                )
                return
            company_id = user["company_id"] if user["role"] == "company" else None
            try:
                outcome = importing.review_sheets(
                    connection,
                    batch_id,
                    sheets,
                    decision,
                    user,
                    company_id,
                    reason=str(data.get("reason") or "") if decision == "ignore" else None,
                )
            except LookupError as exc:
                self._send_json(404, {"status": "error", "message": str(exc)})
                return
            except importing.SheetReviewError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            except Exception:
                # Confirmation and matching share one transaction; any failure
                # rolls both back (the sheet stays pending).
                self._send_json(
                    500,
                    {"status": "error", "message": "确认失败，本次确认与匹配已整体回滚。"},
                )
                return
            rows = importing.sheet_review_rows(connection, batch_id)
            payload: dict[str, object] = {
                "status": "ok",
                "updated": outcome["updated"],
                "already": outcome["already"],
                "sheets": [self._sheet_payload(row) for row in rows],
            }
            if outcome.get("matching") is not None:
                payload["matching"] = outcome["matching"]
            if decision == "confirm" and len(outcome.get("updated") or []) > 0:
                calculation.recalculate_coverage_gaps(connection)
            self._send_json(200, payload)
        finally:
            connection.close()

    def _handle_batch_rows(self, batch_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            batch = connection.execute(
                "SELECT id, company_id FROM import_batches WHERE id = ?", (batch_id,)
            ).fetchone()
            # 404 (not 403) when the batch belongs to another company, so a
            # company user cannot probe the existence of other tenants' data.
            if batch is None or (
                user["role"] == "company" and batch["company_id"] != user["company_id"]
            ):
                self._send_json(404, {"status": "error", "message": "批次不存在。"})
                return
            rows = connection.execute(
                """
                SELECT r.id, r.sheet_batch_id, r.source_row, r.transaction_at,
                       r.income, r.expense, r.balance, r.own_account, r.own_name,
                       r.counterparty_account, r.counterparty_name, r.counterparty_bank,
                       r.summary, r.purpose, r.reference, r.currency,
                       s.sheet_name, rv.review_status
                FROM source_rows r
                JOIN sheet_batches s ON s.id = r.sheet_batch_id
                JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
                WHERE s.import_batch_id = ?
                ORDER BY s.id, r.source_row
                """,
                (batch_id,),
            ).fetchall()
            self._send_json(200, {"status": "ok", "rows": [dict(row) for row in rows]})
        finally:
            connection.close()

    def _handle_export_csv(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            raw = (query.get("company_id") or [None])[0]
            params: list[object] = []
            if user["role"] == "company":
                if raw is not None and raw != str(user["company_id"]):
                    self._send_json(
                        403,
                        {"status": "error", "message": "只能导出本公司的数据。"},
                    )
                    return
                where = "WHERE b.company_id = ?"
                params.append(user["company_id"])
                scope = f"company:{user['company_id']}"
            elif raw is not None:
                # Admin: company_id optional; absent means all companies.
                try:
                    company_id = int(raw)
                except ValueError:
                    self._send_json(
                        400, {"status": "error", "message": "company_id 参数无效。"}
                    )
                    return
                where = "WHERE b.company_id = ?"
                params.append(company_id)
                scope = f"company:{company_id}"
            else:
                where = ""
                scope = "all"

            rows = connection.execute(
                f"""
                SELECT b.id AS batch_id, s.sheet_name, r.source_row, r.transaction_at,
                       r.income, r.expense, r.balance, r.own_account, r.own_name,
                       r.counterparty_account, r.counterparty_name, r.counterparty_bank,
                       r.summary, r.purpose, r.reference, r.currency
                FROM source_rows r
                JOIN sheet_batches s ON s.id = r.sheet_batch_id
                JOIN import_batches b ON b.id = s.import_batch_id
                -- Only worksheets confirmed by the cashier may leave the
                -- ledger: parse success is not business confirmation.
                JOIN sheet_reviews rv
                     ON rv.sheet_batch_id = s.id AND rv.review_status = 'confirmed'
                {where}
                ORDER BY b.id, s.id, r.source_row
                """,
                params,
            ).fetchall()

            buffer = io.StringIO()
            writer = csv.writer(buffer)
            writer.writerow(
                [
                    "批次", "工作表", "源行号", "交易时间", "收入", "支出", "余额",
                    "本方账号", "本方户名", "对方账号", "对方户名", "对方开户行",
                    "摘要", "用途", "流水号", "币种",
                ]
            )
            for row in rows:
                writer.writerow([row[key] for key in row.keys()])
            # UTF-8 BOM so Excel opens the CSV with the right encoding.
            content = (chr(0xFEFF) + buffer.getvalue()).encode("utf-8")

            auth.audit(
                connection,
                "export_csv",
                actor=user,
                target=scope,
                detail=f"rows:{len(rows)}",
                ip=self._client_ip,
            )
            self.send_response(200)
            self.send_header("Content-Type", "text/csv; charset=utf-8")
            self.send_header("Content-Disposition", 'attachment; filename="export.csv"')
            self.send_header("Content-Length", str(len(content)))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(content)
        finally:
            connection.close()

    # ------------------------------------------------------------------
    # Admin endpoints
    # ------------------------------------------------------------------

    def _handle_admin_companies(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            rows = connection.execute(
                """
                SELECT c.id, c.name, c.credit_code, c.cashier_name, c.status,
                       c.created_at, c.updated_at,
                       (SELECT COUNT(*) FROM bank_accounts a WHERE a.company_id = c.id)
                           AS account_count,
                       (SELECT GROUP_CONCAT(u.username, '、') FROM users u
                        WHERE u.company_id = c.id AND u.role = 'company')
                           AS usernames
                FROM companies c
                ORDER BY c.id
                """
            ).fetchall()
            self._send_json(200, {"status": "ok", "companies": [dict(row) for row in rows]})
        finally:
            connection.close()

    def _handle_admin_create_company(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            name = str(data.get("name") or "").strip()
            username = str(data.get("username") or "").strip()
            try:
                company_id = master_data.create_company(
                    connection,
                    name,
                    str(data.get("credit_code") or ""),
                    str(data.get("cashier_name") or ""),
                    user,
                )
            except master_data.ConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            auth.audit(
                connection,
                "company_create",
                actor=user,
                target=f"company:{company_id}",
                detail=name,
                ip=self._client_ip,
            )
            payload: dict[str, object] = {
                "status": "ok", "company_id": company_id, "name": name,
            }
            if username:
                # Optionally create the company login in the same request, so
                # a new company is immediately usable without code changes.
                # The initial password is a random one-time value shown only
                # in this creation response, never stored plaintext or logged;
                # must_change_password forces a change at first login.
                initial_password = auth.generate_initial_password(exclude=username)
                try:
                    user_id = auth.create_user(
                        connection, username, initial_password, "company",
                        company_id=company_id, must_change_password=True,
                    )
                except ValueError as exc:
                    self._send_json(
                        400,
                        {"status": "error",
                         "message": f"公司已创建，但公司账号创建失败：{exc}"},
                    )
                    return
                auth.audit(
                    connection, "user_create", actor=user,
                    target=f"user:{user_id}", detail=f"company:{company_id}",
                    ip=self._client_ip,
                )
                payload.update(
                    {"user_id": user_id, "username": username,
                     "initial_password": initial_password}
                )
            self._send_json(200, payload)
        finally:
            connection.close()

    # ------------------------------------------------------------------
    # Bank account master data
    # ------------------------------------------------------------------

    @staticmethod
    def _account_payload(row, *, full: bool) -> dict[str, object]:
        """Serialize an account; company users only ever see the masked form."""
        payload: dict[str, object] = {
            "id": row["id"],
            "company_id": row["company_id"],
            "bank_name": row["bank_name"],
            "account_name": row["account_name"],
            "account_type": row["account_type"],
            "status": row["status"],
            "effective_from": row["effective_from"],
            "effective_to": row["effective_to"],
            "reviewed_at": row["reviewed_at"],
            "review_reason": row["review_reason"],
            "created_at": row["created_at"],
        }
        if "company_name" in row.keys():
            payload["company_name"] = row["company_name"]
        if full:
            payload["account_number"] = row["account_number"]
        else:
            payload["account_number_masked"] = master_data.mask_account_number(
                row["account_number"]
            )
        return payload

    def _handle_admin_accounts(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            raw_company = (query.get("company_id") or [None])[0]
            raw_status = (query.get("status") or [None])[0]
            try:
                company_id = int(raw_company) if raw_company else None
            except ValueError:
                self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
                return
            try:
                rows = master_data.list_accounts(
                    connection, company_id=company_id, status=raw_status or None
                )
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(
                200,
                {"status": "ok",
                 # The admin audit view is the authorized full-number view.
                 "accounts": [self._account_payload(row, full=True) for row in rows]},
            )
        finally:
            connection.close()

    def _handle_company_accounts(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            if user["role"] != "company":
                self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
                return
            rows = master_data.list_accounts(connection, company_id=user["company_id"])
            usable_ids = {
                row["id"]
                for row in master_data.usable_accounts(connection, user["company_id"])
            }
            accounts = []
            for row in rows:
                payload = self._account_payload(row, full=False)
                payload["usable"] = row["id"] in usable_ids
                accounts.append(payload)
            self._send_json(200, {"status": "ok", "accounts": accounts})
        finally:
            connection.close()

    def _handle_company_submit_account(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            if user["role"] != "company":
                self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
                return
            data = self._read_json_body()
            if data is None:
                return
            # The company binding always comes from the session; any
            # company_id in the body is ignored.
            try:
                account = master_data.submit_bank_account(
                    connection,
                    company_id=user["company_id"],
                    bank_name=str(data.get("bank_name") or ""),
                    account_type=data.get("account_type"),
                    account_number=data.get("account_number"),
                    account_name=data.get("account_name"),
                    start_date=data.get("start_date"),
                    actor=user,
                )
            except master_data.ConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            auth.audit(
                connection,
                "account_submit",
                actor=user,
                target=f"bank_account:{account['id']}",
                detail=master_data.mask_account_number(account["account_number"]),
                ip=self._client_ip,
            )
            payload = self._account_payload(account, full=False)
            payload["usable"] = master_data.is_usable(account, master_data.utc_today())
            self._send_json(200, {"status": "ok", "account": payload})
        finally:
            connection.close()

    def _handle_admin_review_account(self, account_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                account = master_data.review_bank_account(
                    connection,
                    account_id,
                    str(data.get("decision") or ""),
                    str(data.get("reason") or ""),
                    user,
                    effective_from=data.get("effective_from"),
                    effective_to=data.get("effective_to"),
                )
            except LookupError as exc:
                self._send_json(404, {"status": "error", "message": str(exc)})
                return
            except master_data.ConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            auth.audit(
                connection,
                f"account_review_{data.get('decision')}",
                actor=user,
                target=f"bank_account:{account_id}",
                detail=master_data.mask_account_number(account["account_number"]),
                ip=self._client_ip,
            )
            self._send_json(
                200,
                {"status": "ok",
                 "account": self._account_payload(account, full=True)},
            )
        finally:
            connection.close()

    def _handle_admin_account_aliases(self, account_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            if master_data.get_account(connection, account_id) is None:
                self._send_json(404, {"status": "error", "message": "账户不存在。"})
                return
            rows = master_data.list_aliases(connection, account_id)
            self._send_json(
                200,
                {"status": "ok", "aliases": [dict(row) for row in rows]},
            )
        finally:
            connection.close()

    def _handle_admin_add_alias(self, account_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                alias_id = master_data.add_alias(
                    connection,
                    account_id,
                    str(data.get("alias_kind") or ""),
                    data.get("alias_value"),
                    priority=data.get("priority"),
                    effective_from=data.get("effective_from"),
                    effective_to=data.get("effective_to"),
                    actor=user,
                )
            except LookupError as exc:
                self._send_json(404, {"status": "error", "message": str(exc)})
                return
            except master_data.ConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            auth.audit(
                connection,
                "account_alias_create",
                actor=user,
                target=f"account_alias:{alias_id}",
                detail=f"bank_account:{account_id}",
                ip=self._client_ip,
            )
            self._send_json(200, {"status": "ok", "alias_id": alias_id})
        finally:
            connection.close()

    def _handle_admin_master_changes(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            conditions: list[str] = []
            params: list[object] = []
            entity_type = (query.get("entity_type") or [None])[0]
            entity_id = (query.get("entity_id") or [None])[0]
            if entity_type:
                conditions.append("entity_type = ?")
                params.append(entity_type)
            if entity_id:
                try:
                    params.append(int(entity_id))
                except ValueError:
                    self._send_json(400, {"status": "error", "message": "entity_id 参数无效。"})
                    return
                conditions.append("entity_id = ?")
            raw_limit = (query.get("limit") or ["100"])[0]
            try:
                limit = max(1, min(int(raw_limit), 500))
            except ValueError:
                limit = 100
            where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
            rows = connection.execute(
                f"""
                SELECT id, entity_type, entity_id, action, before_json, after_json,
                       reason, actor_user_id, actor_username, created_at
                FROM master_data_changes
                {where}
                ORDER BY id DESC
                LIMIT ?
                """,
                (*params, limit),
            ).fetchall()
            self._send_json(200, {"status": "ok", "changes": [dict(row) for row in rows]})
        finally:
            connection.close()

    def _handle_admin_users(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            rows = connection.execute(
                """
                SELECT u.id, u.username, u.role, u.company_id, c.name AS company_name,
                       u.status, u.must_change_password, u.created_at
                FROM users u
                LEFT JOIN companies c ON c.id = u.company_id
                ORDER BY u.id
                """
            ).fetchall()
            # password_hash is deliberately never selected or serialized.
            self._send_json(200, {"status": "ok", "users": [dict(row) for row in rows]})
        finally:
            connection.close()

    def _handle_admin_create_user(self) -> None:
        connection = connect(DB_PATH)
        try:
            admin = self._require_admin(connection)
            if admin is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            username = str(data.get("username") or "").strip()
            try:
                company_id = int(str(data.get("company_id")))
            except (TypeError, ValueError):
                self._send_json(400, {"status": "error", "message": "必须指定有效的 company_id。"})
                return
            # The initial password is a random one-time value shown only in
            # this creation response, never stored plaintext or logged;
            # must_change_password forces a change at first login. Password
            # reset keeps a random one-time password instead.
            initial_password = auth.generate_initial_password(exclude=username)
            try:
                user_id = auth.create_user(
                    connection,
                    username,
                    initial_password,
                    "company",
                    company_id=company_id,
                    must_change_password=True,
                )
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            # The random one-time password is never written to audit detail;
            # only the company binding is recorded.
            auth.audit(
                connection,
                "user_create",
                actor=admin,
                target=f"user:{user_id}",
                detail=f"company:{company_id}",
                ip=self._client_ip,
            )
            self._send_json(
                200,
                {
                    "status": "ok",
                    "user_id": user_id,
                    "username": username,
                    "company_id": company_id,
                    "initial_password": initial_password,
                },
            )
        finally:
            connection.close()

    def _handle_admin_user_action(self, user_id: int, action: str) -> None:
        connection = connect(DB_PATH)
        try:
            admin = self._require_admin(connection)
            if admin is None:
                return
            target = connection.execute(
                "SELECT * FROM users WHERE id = ?", (user_id,)
            ).fetchone()
            if target is None:
                self._send_json(404, {"status": "error", "message": "用户不存在。"})
                return

            if action == "disable":
                with connection:
                    connection.execute(
                        "UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?",
                        (utc_now(), user_id),
                    )
                auth.revoke_user_sessions(connection, user_id)
                auth.audit(
                    connection, "user_disable", actor=admin,
                    target=f"user:{user_id}", detail=target["username"], ip=self._client_ip,
                )
                self._send_json(200, {"status": "ok", "user_id": user_id, "user_status": "disabled"})
            elif action == "enable":
                with connection:
                    connection.execute(
                        "UPDATE users SET status = 'active', updated_at = ? WHERE id = ?",
                        (utc_now(), user_id),
                    )
                auth.audit(
                    connection, "user_enable", actor=admin,
                    target=f"user:{user_id}", detail=target["username"], ip=self._client_ip,
                )
                self._send_json(200, {"status": "ok", "user_id": user_id, "user_status": "active"})
            else:  # reset-password
                new_password = auth.generate_initial_password()
                with connection:
                    connection.execute(
                        """
                        UPDATE users
                        SET password_hash = ?, must_change_password = 1, updated_at = ?
                        WHERE id = ?
                        """,
                        (
                            auth.hash_password(new_password),
                            utc_now(),
                            user_id,
                        ),
                    )
                auth.revoke_user_sessions(connection, user_id)
                auth.audit(
                    connection, "user_reset_password", actor=admin,
                    target=f"user:{user_id}", detail=target["username"], ip=self._client_ip,
                )
                # Shown once in this response; never logged or audited.
                self._send_json(
                    200,
                    {"status": "ok", "user_id": user_id, "initial_password": new_password},
                )
        finally:
            connection.close()

    def _handle_admin_audit_log(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            raw = (query.get("limit") or ["100"])[0]
            try:
                limit = max(1, min(int(raw), 500))
            except ValueError:
                limit = 100
            rows = connection.execute(
                """
                SELECT id, actor_user_id, actor_username, action, target, detail, ip, created_at
                FROM audit_log
                ORDER BY id DESC
                LIMIT ?
                """,
                (limit,),
            ).fetchall()
            self._send_json(200, {"status": "ok", "entries": [dict(row) for row in rows]})
        finally:
            connection.close()

    # ------------------------------------------------------------------
    # System settings (admin read/write, persisted + audited)
    # ------------------------------------------------------------------

    def _handle_admin_calculation_start_get(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            payload = calculation.start_date_payload(connection)
            payload["status"] = "ok"
            payload["summary"] = calculation.opening_coverage_summary(connection)
            self._send_json(200, payload)
        finally:
            connection.close()

    def _handle_admin_calculation_start_put(self) -> None:
        connection = connect(DB_PATH)
        try:
            admin = self._require_admin(connection)
            if admin is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                result = calculation.set_calculation_start_date(
                    connection,
                    str(data.get("calculation_start_date") or ""),
                    str(data.get("reason") or ""),
                    admin,
                )
            except calculation.LockedError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **result})
        finally:
            connection.close()

    def _handle_admin_opening_balances_get(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            items = calculation.list_opening_balances(connection)
            self._send_json(
                200,
                {
                    "status": "ok",
                    "items": items,
                    "summary": calculation.opening_coverage_summary(connection),
                },
            )
        finally:
            connection.close()

    def _handle_admin_opening_balances_post(self) -> None:
        connection = connect(DB_PATH)
        try:
            admin = self._require_admin(connection)
            if admin is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                company_a = int(data.get("company_id_low") or data.get("from_company_id"))
                company_b = int(data.get("company_id_high") or data.get("to_company_id"))
            except (TypeError, ValueError):
                self._send_json(400, {"status": "error", "message": "必须指定有效的公司。"})
                return
            try:
                item = calculation.create_opening_balance(
                    connection,
                    company_a,
                    company_b,
                    data.get("amount"),
                    str(data.get("reason") or ""),
                    admin,
                    viewer_company_id=company_a,
                )
            except calculation.ConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", "item": item})
        finally:
            connection.close()

    def _handle_admin_opening_balance_confirm(self, revision_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            admin = self._require_admin(connection)
            if admin is None:
                return
            data = self._read_json_body() or {}
            try:
                item = calculation.confirm_opening_balance(
                    connection, revision_id, str(data.get("reason") or ""), admin
                )
            except calculation.ConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", "item": item})
        finally:
            connection.close()

    def _handle_admin_opening_balance_revise(self, revision_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            admin = self._require_admin(connection)
            if admin is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                item = calculation.revise_opening_balance(
                    connection,
                    revision_id,
                    data.get("amount"),
                    str(data.get("reason") or ""),
                    admin,
                )
            except calculation.ConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", "item": item})
        finally:
            connection.close()

    def _handle_admin_opening_balance_void(self, revision_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            admin = self._require_admin(connection)
            if admin is None:
                return
            data = self._read_json_body() or {}
            try:
                item = calculation.void_opening_balance(
                    connection, revision_id, str(data.get("reason") or ""), admin
                )
            except calculation.ConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", "item": item})
        finally:
            connection.close()

    def _handle_admin_coverage_gaps(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            status = (query.get("status") or [None])[0]
            items = calculation.list_coverage_gaps(connection, status=status)
            self._send_json(200, {"status": "ok", "items": items})
        finally:
            connection.close()

    def _handle_admin_recalculate_gaps(self) -> None:
        connection = connect(DB_PATH)
        try:
            admin = self._require_admin(connection)
            if admin is None:
                return
            rebuilt = calculation.recalculate_coverage_gaps(connection)
            self._send_json(200, {"status": "ok", "rebuilt": rebuilt})
        finally:
            connection.close()

    def _handle_admin_calculation_changes(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            items = calculation.list_change_log(connection)
            self._send_json(200, {"status": "ok", "items": items})
        finally:
            connection.close()

    def _resolve_company_id(
        self, connection, query: dict[str, list[str]], field: str
    ) -> int | None:
        raw = (query.get(field) or [None])[0]
        if raw is None:
            return None
        try:
            return int(raw)
        except ValueError:
            self._send_json(400, {"status": "error", "message": f"{field} 参数无效。"})
            return -1

    def _handle_admin_pair_balance(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            from_id = self._resolve_company_id(connection, query, "from_company_id")
            to_id = self._resolve_company_id(connection, query, "to_company_id")
            if from_id == -1 or to_id == -1:
                return
            if from_id is None or to_id is None:
                self._send_json(400, {"status": "error", "message": "必须指定 from_company_id 与 to_company_id。"})
                return
            cutoff = (query.get("cutoff") or [None])[0]
            try:
                balance = calculation.compute_pair_balance(
                    connection, from_id, to_id, cutoff=cutoff
                )
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", "balance": balance})
        finally:
            connection.close()

    def _handle_company_coverage_gaps(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            if user["role"] != "company" or user["company_id"] is None:
                self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
                return
            items = calculation.list_coverage_gaps(
                connection, company_id=user["company_id"]
            )
            self._send_json(200, {"status": "ok", "items": items})
        finally:
            connection.close()

    def _handle_company_balances(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            if user["role"] != "company" or user["company_id"] is None:
                self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
                return
            cutoff = (query.get("cutoff") or [None])[0]
            try:
                payload = calculation.compute_company_balances(
                    connection, user["company_id"], cutoff=cutoff
                )
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            payload["status"] = "ok"
            self._send_json(200, payload)
        finally:
            connection.close()

    def _handle_company_pair_balance(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            if user["role"] != "company" or user["company_id"] is None:
                self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
                return
            counterparty_id = self._resolve_company_id(connection, query, "counterparty_id")
            if counterparty_id == -1:
                return
            if counterparty_id is None:
                self._send_json(400, {"status": "error", "message": "必须指定 counterparty_id。"})
                return
            cutoff = (query.get("cutoff") or [None])[0]
            try:
                balance = calculation.compute_pair_balance(
                    connection, user["company_id"], counterparty_id, cutoff=cutoff
                )
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", "balance": balance})
        finally:
            connection.close()

    def _handle_company_submit_attestation(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            if user["role"] != "company" or user["company_id"] is None:
                self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                bank_account_id = int(data.get("bank_account_id"))
            except (TypeError, ValueError):
                self._send_json(400, {"status": "error", "message": "必须指定 bank_account_id。"})
                return
            try:
                item = calculation.submit_no_business_attestation(
                    connection,
                    company_id=user["company_id"],
                    bank_account_id=bank_account_id,
                    gap_start=str(data.get("gap_start") or ""),
                    gap_end=str(data.get("gap_end") or ""),
                    reason=str(data.get("reason") or ""),
                    evidence=str(data.get("evidence") or "") or None,
                    actor=user,
                )
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", "item": item})
        finally:
            connection.close()

    def _handle_admin_review_attestation(self, attestation_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            admin = self._require_admin(connection)
            if admin is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            decision = str(data.get("decision") or "")
            try:
                item = calculation.review_no_business_attestation(
                    connection,
                    attestation_id,
                    decision,
                    str(data.get("review_reason") or ""),
                    admin,
                )
            except calculation.ConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", "item": item})
        finally:
            connection.close()

    def _handle_admin_settings(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            values = settings.get_settings(connection)
            self._send_json(200, {"status": "ok", "settings": values})
        finally:
            connection.close()

    def _handle_admin_update_settings(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            before = settings.get_settings(connection)
            try:
                updated = settings.update_settings(connection, data, user)
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            changed = {
                key: updated[key]
                for key in updated
                if before.get(key) != updated[key]
            }
            auth.audit(
                connection,
                "settings_update",
                actor=user,
                target="system_settings",
                detail=";".join(
                    f"{key}:{before.get(key)}->{updated[key]}" for key in changed
                ),
                ip=self._client_ip,
            )
            self._send_json(200, {"status": "ok", "settings": updated})
        finally:
            connection.close()

    # ------------------------------------------------------------------
    # Reminder management (admin)
    # ------------------------------------------------------------------

    @staticmethod
    def _reminder_payload(row) -> dict[str, object]:
        payload = {
            "id": row["id"],
            "company_id": row["company_id"],
            "company_name": row["company_name"],
            "kind": row["kind"],
            "content": row["content"],
            "deadline": row["deadline"],
            "source": row["source"],
            "status": row["status"],
            "actor_username": row["actor_username"],
            "created_at": row["created_at"],
        }
        return payload

    def _handle_admin_reminders(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            conditions: list[str] = []
            params: list[object] = []
            raw_company = (query.get("company_id") or [None])[0]
            if raw_company:
                try:
                    params.append(int(raw_company))
                except ValueError:
                    self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
                    return
                conditions.append("r.company_id = ?")
            raw_limit = (query.get("limit") or ["200"])[0]
            try:
                limit = max(1, min(int(raw_limit), 500))
            except ValueError:
                limit = 200
            where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
            rows = connection.execute(
                f"""
                SELECT r.id, r.company_id, c.name AS company_name, r.kind,
                       r.content, r.deadline, r.source, r.status,
                       r.actor_username, r.created_at
                FROM reminders r
                JOIN companies c ON c.id = r.company_id
                {where}
                ORDER BY r.id DESC
                LIMIT ?
                """,
                (*params, limit),
            ).fetchall()
            self._send_json(
                200,
                {"status": "ok",
                 "reminders": [self._reminder_payload(row) for row in rows]},
            )
        finally:
            connection.close()

    def _handle_admin_reminder_pending(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            raw_company = (query.get("company_id") or [None])[0]
            try:
                company_id = int(raw_company)
            except (TypeError, ValueError):
                self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
                return
            company = connection.execute(
                "SELECT id, name FROM companies WHERE id = ?", (company_id,)
            ).fetchone()
            if company is None:
                self._send_json(404, {"status": "error", "message": "公司不存在。"})
                return
            items = settings.pending_items(connection, company_id)
            self._send_json(
                200,
                {
                    "status": "ok",
                    "company_id": company_id,
                    "company_name": company["name"],
                    "items": items,
                },
            )
        finally:
            connection.close()

    def _handle_admin_send_reminders(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                company_id = int(str(data.get("company_id")))
            except (TypeError, ValueError):
                self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
                return
            company = connection.execute(
                "SELECT id, name FROM companies WHERE id = ?", (company_id,)
            ).fetchone()
            if company is None:
                self._send_json(404, {"status": "error", "message": "公司不存在。"})
                return
            try:
                created, deadline = settings.send_reminders(
                    connection, company_id, user
                )
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            if not created:
                self._send_json(
                    400,
                    {"status": "error",
                     "message": "该公司当前没有待提醒事项，无需发送。"},
                )
                return
            auth.audit(
                connection,
                "reminder_send",
                actor=user,
                target=f"company:{company_id}",
                detail=f"items:{len(created)}",
                ip=self._client_ip,
            )
            self._send_json(
                200,
                {
                    "status": "ok",
                    "company_id": company_id,
                    "company_name": company["name"],
                    "deadline": deadline,
                    "reminders": created,
                },
            )
        finally:
            connection.close()

    # ------------------------------------------------------------------
    # Canonical transfer events (admin)
    # ------------------------------------------------------------------

    @staticmethod
    def _event_base_sql(company_scope: bool) -> str:
        """Shared list SELECT; ``company_scope`` filters to one company."""
        where = (
            "WHERE payer.company_id = ? OR payee.company_id = ?"
            if company_scope
            else ""
        )
        return f"""
            SELECT c.event_id, d.id AS decision_id, d.revision, d.classification,
                   d.pairing, d.amount, d.currency, d.effective_at, d.mode,
                   d.locked, d.rule_version, d.created_at,
                   payer.company_id AS payer_company_id,
                   payee.company_id AS payee_company_id,
                   payer.bank_account_id AS payer_account_id,
                   payee.bank_account_id AS payee_account_id,
                   cpayer.name AS payer_company_name,
                   cpayee.name AS payee_company_name,
                   (SELECT COUNT(*) FROM transfer_decision_observations o
                    WHERE o.decision_id = d.id) AS evidence_count
            FROM current_transfer_decisions c
            JOIN transfer_match_decisions d ON d.id = c.decision_id
            LEFT JOIN transfer_decision_participants payer
                 ON payer.decision_id = d.id AND payer.role = 'payer'
            LEFT JOIN transfer_decision_participants payee
                 ON payee.decision_id = d.id AND payee.role = 'payee'
            LEFT JOIN companies cpayer ON cpayer.id = payer.company_id
            LEFT JOIN companies cpayee ON cpayee.id = payee.company_id
            {where}
        """

    def _handle_admin_transfer_events(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            conditions: list[str] = []
            params: list[object] = []
            raw_company = (query.get("company_id") or [None])[0]
            if raw_company:
                try:
                    params.append(int(raw_company))
                    params.append(int(raw_company))
                except ValueError:
                    self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
                    return
                conditions.append("(payer.company_id = ? OR payee.company_id = ?)")
            raw_from = (query.get("from") or [None])[0]
            if raw_from:
                conditions.append("d.effective_at >= ?")
                params.append(raw_from)
            raw_to = (query.get("to") or [None])[0]
            if raw_to:
                conditions.append("d.effective_at <= ?")
                params.append(raw_to)
            raw_limit = (query.get("limit") or ["200"])[0]
            try:
                limit = max(1, min(int(raw_limit), 500))
            except ValueError:
                limit = 200
            where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
            rows = connection.execute(
                self._event_base_sql(False) + where + " ORDER BY d.id DESC LIMIT ?",
                (*params, limit),
            ).fetchall()
            items = [self._admin_event_list_item(row) for row in rows]
            status = (query.get("status") or [None])[0]
            if status:
                items = [item for item in items if item["status"] == status]
            self._send_json(200, {"status": "ok", "events": items})
        finally:
            connection.close()

    @staticmethod
    def _admin_event_list_item(row) -> dict[str, object]:
        return {
            "event_id": row["event_id"],
            "decision_id": row["decision_id"],
            "revision": row["revision"],
            "classification": row["classification"],
            "pairing": row["pairing"],
            "status": matching.exposed_status(row),
            "amount": row["amount"],
            "currency": row["currency"],
            "effective_at": row["effective_at"],
            "mode": row["mode"],
            "locked": bool(row["locked"]),
            "rule_version": row["rule_version"],
            "payer_company_id": row["payer_company_id"],
            "payer_company_name": row["payer_company_name"],
            "payee_company_id": row["payee_company_id"],
            "payee_company_name": row["payee_company_name"],
            "evidence_count": row["evidence_count"],
            "created_at": row["created_at"],
        }

    def _handle_admin_transfer_event_detail(self, event_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            payload = self._admin_event_detail(connection, event_id)
            if payload is None:
                self._send_json(404, {"status": "error", "message": "事件不存在。"})
                return
            self._send_json(200, {"status": "ok", "event": payload})
        finally:
            connection.close()

    def _admin_event_detail(self, connection, event_id: int) -> dict[str, object] | None:
        current = matching._current_decision_for_event(connection, event_id)
        if current is None:
            return None
        decisions = connection.execute(
            "SELECT * FROM transfer_match_decisions WHERE event_id = ? ORDER BY revision",
            (event_id,),
        ).fetchall()
        participants = matching._decision_participants(connection, current["id"])
        observations = matching._decision_observations(connection, current["id"])
        candidates = connection.execute(
            "SELECT * FROM transfer_match_candidates WHERE decision_id = ? ORDER BY id",
            (current["id"],),
        ).fetchall()
        observation_rows = []
        for observation in observations:
            row = connection.execute(
                """
                SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense,
                       r.own_account, r.own_name, r.counterparty_account,
                       r.counterparty_name, r.summary, r.reference, r.currency,
                       s.sheet_name, f.original_filename,
                       b.company_id AS batch_company_id
                FROM source_rows r
                JOIN sheet_batches s ON s.id = r.sheet_batch_id
                JOIN import_batches b ON b.id = s.import_batch_id
                JOIN source_files f ON f.id = b.source_file_id
                WHERE r.id = ?
                """,
                (observation["source_row_id"],),
            ).fetchone()
            observation_rows.append(
                {
                    "source_row_id": observation["source_row_id"],
                    "role": observation["role"],
                    "source_row": row["source_row"] if row else None,
                    "sheet_name": row["sheet_name"] if row else None,
                    "original_filename": row["original_filename"] if row else None,
                    "company_id": row["batch_company_id"] if row else None,
                    "transaction_at": row["transaction_at"] if row else None,
                    "income": row["income"] if row else None,
                    "expense": row["expense"] if row else None,
                    "counterparty_name": row["counterparty_name"] if row else None,
                }
            )
        history = [
            {
                "decision_id": decision["id"],
                "revision": decision["revision"],
                "classification": decision["classification"],
                "pairing": decision["pairing"],
                "status": matching.exposed_status(decision),
                "amount": decision["amount"],
                "currency": decision["currency"],
                "effective_at": decision["effective_at"],
                "mode": decision["mode"],
                "locked": bool(decision["locked"]),
                "rule_version": decision["rule_version"],
                "reason": decision["reason"],
                "actor_username": decision["actor_username"],
                "supersedes_decision_id": decision["supersedes_decision_id"],
                "created_at": decision["created_at"],
            }
            for decision in decisions
        ]
        return {
            "event_id": event_id,
            "decision_id": current["id"],
            "revision": current["revision"],
            "classification": current["classification"],
            "pairing": current["pairing"],
            "status": matching.exposed_status(current),
            "amount": current["amount"],
            "currency": current["currency"],
            "effective_at": current["effective_at"],
            "mode": current["mode"],
            "locked": bool(current["locked"]),
            "rule_version": current["rule_version"],
            "participants": [
                {
                    "role": participant["role"],
                    "company_id": participant["company_id"],
                    "bank_account_id": participant["bank_account_id"],
                    "resolve_method": participant["resolve_method"],
                    "evidence": json.loads(participant["evidence"])
                    if participant["evidence"]
                    else None,
                }
                for participant in participants
            ],
            "observations": observation_rows,
            "candidates": [
                {
                    "source_row_id": candidate["source_row_id"],
                    "rule_tier": candidate["rule_tier"],
                    "date_diff_days": candidate["date_diff_days"],
                    "account_mirror": bool(candidate["account_mirror"]),
                    "reference_match": candidate["reference_match"],
                    "summary_match": bool(candidate["summary_match"]),
                    "accepted": candidate["accepted"],
                    "reason": candidate["reason"],
                }
                for candidate in candidates
            ],
            "history": history,
        }

    def _handle_admin_match_exceptions(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            conditions = ["d.classification IN ('unresolved', 'needs_review')"]
            params: list[object] = []
            raw_company = (query.get("company_id") or [None])[0]
            if raw_company:
                try:
                    company_id = int(raw_company)
                except ValueError:
                    self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
                    return
                conditions.append("(payer.company_id = ? OR payee.company_id = ?)")
                params.extend([company_id, company_id])
            rows = connection.execute(
                self._event_base_sql(False)
                + f"WHERE {' AND '.join(conditions)} ORDER BY d.id DESC LIMIT 500",
                params,
            ).fetchall()
            items = [self._admin_event_list_item(row) for row in rows]
            self._send_json(200, {"status": "ok", "exceptions": items})
        finally:
            connection.close()

    def _handle_admin_dashboard(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            from_date = (query.get("from") or ["2026-01-01"])[0] or "2026-01-01"
            cutoff = (query.get("cutoff") or [None])[0] or None
            try:
                payload = dashboard.build_dashboard(
                    connection, from_date=from_date, cutoff=cutoff
                )
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_admin_dashboard_company(
        self, company_id: int, query: dict[str, list[str]]
    ) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            from_date = (query.get("from") or ["2026-01-01"])[0] or "2026-01-01"
            cutoff = (query.get("cutoff") or [dashboard.today_shanghai()])[0]
            try:
                payload = dashboard.company_peer_groups(
                    connection, company_id, from_date=from_date, cutoff=cutoff
                )
            except KeyError:
                self._send_json(404, {"status": "error", "message": "公司不存在。"})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_admin_reconcile(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            raw_rows = data.get("source_row_ids")
            row_ids: list[int] = []
            if raw_rows is not None:
                if not isinstance(raw_rows, list):
                    self._send_json(400, {"status": "error", "message": "source_row_ids 必须是数组。"})
                    return
                try:
                    row_ids = [int(item) for item in raw_rows]
                except (TypeError, ValueError):
                    self._send_json(400, {"status": "error", "message": "source_row_ids 必须是整数数组。"})
                    return
            raw_batch = data.get("batch_id")
            if raw_batch is not None and not row_ids:
                try:
                    batch_id = int(raw_batch)
                except (TypeError, ValueError):
                    self._send_json(400, {"status": "error", "message": "batch_id 参数无效。"})
                    return
                rows = connection.execute(
                    """
                    SELECT r.id FROM source_rows r
                    JOIN sheet_batches s ON s.id = r.sheet_batch_id
                    JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
                    WHERE s.import_batch_id = ? AND rv.review_status = 'confirmed'
                    ORDER BY r.id
                    """,
                    (batch_id,),
                ).fetchall()
                row_ids = [item["id"] for item in rows]
            if not row_ids:
                self._send_json(400, {"status": "error", "message": "必须指定 source_row_ids 或 batch_id。"})
                return
            try:
                result = matching.reconcile_rows(connection, row_ids, actor=user)
            except Exception as exc:
                self._send_json(500, {"status": "error", "message": f"重跑匹配失败：{exc}"})
                return
            try:
                ledger_events.reconcile_bank_events(connection, actor=user)
            except Exception as exc:
                self._send_json(500, {"status": "error", "message": f"同步往来事件失败：{exc}"})
                return
            auth.audit(
                connection, "transfer_reconcile", actor=user,
                target=f"rows:{len(row_ids)}",
                detail=f"created:{result['created_events']};updated:{result['updated_events']}",
                ip=self._client_ip,
            )
            self._send_json(200, {"status": "ok", "matching": result})
        finally:
            connection.close()

    def _handle_admin_transfer_decision(self, event_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            action = str(data.get("action") or "")
            try:
                expected_revision = (
                    int(data["expected_revision"])
                    if data.get("expected_revision") is not None
                    else None
                )
            except (TypeError, ValueError):
                self._send_json(400, {"status": "error", "message": "expected_revision 参数无效。"})
                return
            source_row_ids = data.get("source_row_ids")
            if source_row_ids is not None and not isinstance(source_row_ids, list):
                self._send_json(400, {"status": "error", "message": "source_row_ids 必须是数组。"})
                return
            try:
                payload = matching.apply_manual_decision(
                    connection,
                    event_id,
                    action,
                    reason=str(data.get("reason") or ""),
                    expected_revision=expected_revision,
                    request_key=str(data.get("request_key") or "") or None,
                    actor=user,
                    source_row_ids=[int(item) for item in source_row_ids]
                    if source_row_ids
                    else None,
                    participant=data.get("participant"),
                )
            except matching.MatchConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except matching.MatchInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            try:
                ledger_events.reconcile_bank_events(connection, actor=user)
            except Exception as exc:
                self._send_json(500, {"status": "error", "message": f"同步往来事件失败：{exc}"})
                return
            self._send_json(200, {"status": "ok", "decision": payload})
        finally:
            connection.close()

    # ------------------------------------------------------------------
    # Personal transit mappings (admin)
    # ------------------------------------------------------------------

    def _handle_admin_personal_mappings(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            raw_company = (query.get("company_id") or [None])[0]
            raw_status = (query.get("status") or [None])[0]
            try:
                company_id = int(raw_company) if raw_company else None
            except ValueError:
                self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
                return
            try:
                rows = personal_transit.list_mappings(
                    connection, company_id=company_id, status=raw_status or None
                )
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(
                200,
                {"status": "ok",
                 "mappings": [personal_transit.mapping_payload(row, full=True) for row in rows]},
            )
        finally:
            connection.close()

    def _handle_admin_create_personal_mapping(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                company_id = int(str(data.get("represented_company_id")))
            except (TypeError, ValueError):
                self._send_json(400, {"status": "error", "message": "represented_company_id 参数无效。"})
                return
            try:
                mapping = personal_transit.submit_mapping(
                    connection,
                    account_number=data.get("account_number"),
                    account_name=data.get("account_name"),
                    represented_company_id=company_id,
                    allowed_direction=str(data.get("allowed_direction") or ""),
                    effective_from=data.get("effective_from"),
                    actor=user,
                )
            except personal_transit.ConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            auth.audit(
                connection, "personal_transit_submit", actor=user,
                target=f"personal_transit:{mapping['id']}",
                detail=f"company:{company_id}",
                ip=self._client_ip,
            )
            self._send_json(
                200,
                {"status": "ok",
                 "mapping": personal_transit.mapping_payload(mapping, full=True)},
            )
        finally:
            connection.close()

    def _handle_admin_review_personal_mapping(self, mapping_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                mapping = personal_transit.review_mapping(
                    connection,
                    mapping_id,
                    str(data.get("decision") or ""),
                    str(data.get("reason") or ""),
                    user,
                    effective_from=data.get("effective_from"),
                    effective_to=data.get("effective_to"),
                )
            except LookupError as exc:
                self._send_json(404, {"status": "error", "message": str(exc)})
                return
            except personal_transit.ConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ValueError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            auth.audit(
                connection, "personal_transit_review", actor=user,
                target=f"personal_transit:{mapping_id}",
                detail=str(data.get("decision")),
                ip=self._client_ip,
            )
            self._send_json(
                200,
                {"status": "ok",
                 "mapping": personal_transit.mapping_payload(mapping, full=True)},
            )
        finally:
            connection.close()

    # ------------------------------------------------------------------
    # Canonical transfer events (company, own-company scope only)
    # ------------------------------------------------------------------

    def _handle_company_transfer_events(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            if user["role"] != "company":
                self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
                return
            conditions = []
            params: list[object] = [user["company_id"], user["company_id"]]
            status = (query.get("status") or [None])[0]
            raw_limit = (query.get("limit") or ["200"])[0]
            try:
                limit = max(1, min(int(raw_limit), 500))
            except ValueError:
                limit = 200
            rows = connection.execute(
                self._event_base_sql(True) + " ORDER BY d.id DESC LIMIT ?",
                (*params, limit),
            ).fetchall()
            items = [
                self._company_event_list_item(row, user["company_id"]) for row in rows
            ]
            if status:
                items = [item for item in items if item["status"] == status]
            self._send_json(200, {"status": "ok", "events": items})
        finally:
            connection.close()

    @staticmethod
    def _company_event_list_item(row, own_company_id: int) -> dict[str, object]:
        counterparty_company_id = (
            row["payee_company_id"]
            if row["payer_company_id"] == own_company_id
            else row["payer_company_id"]
        )
        counterparty_company_name = (
            row["payee_company_name"]
            if row["payer_company_id"] == own_company_id
            else row["payer_company_name"]
        )
        return {
            "event_id": row["event_id"],
            "decision_id": row["decision_id"],
            "revision": row["revision"],
            "classification": row["classification"],
            "pairing": row["pairing"],
            "status": matching.exposed_status(row),
            "amount": row["amount"],
            "currency": row["currency"],
            "effective_at": row["effective_at"],
            "mode": row["mode"],
            "rule_version": row["rule_version"],
            "own_company_id": own_company_id,
            "counterparty_company_id": counterparty_company_id,
            "counterparty_company_name": counterparty_company_name,
            "evidence_count": row["evidence_count"],
        }

    def _handle_company_workspace(self) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            if user["role"] != "company":
                self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
                return
            payload = matching.company_workspace_payload(connection, user["company_id"])
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_company_match_exceptions(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            if user["role"] != "company":
                self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
                return
            rows = matching.company_pending_unilaterals(connection, user["company_id"])
            items = [
                self._company_event_list_item(row, user["company_id"]) for row in rows
            ]
            self._send_json(200, {"status": "ok", "exceptions": items})
        finally:
            connection.close()

    def _handle_company_transfer_confirm(self, event_id: int) -> None:
        """Company confirms a pending unilateral using existing assign_participant rules."""
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            if user["role"] != "company":
                self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
                return
            company_id = int(user["company_id"])
            data = self._read_json_body()
            if data is None:
                return
            request_key = str(data.get("request_key") or "").strip() or None
            # Idempotent replay: same request_key returns current workspace without re-decrement.
            if request_key:
                existing = connection.execute(
                    """
                    SELECT id, actor_username
                    FROM transfer_match_decisions
                    WHERE idempotency_key = ? AND event_id = ?
                    """,
                    (request_key, event_id),
                ).fetchone()
                if existing is not None:
                    if existing["actor_username"] != user["username"]:
                        self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
                        return
                    workspace = matching.company_workspace_payload(connection, company_id)
                    payload = matching._decision_payload(connection, event_id, existing["id"])
                    self._send_json(
                        200,
                        {"status": "ok", "decision": payload, "workspace": workspace},
                    )
                    return
            pending = matching.company_pending_unilaterals(connection, company_id)
            target = next((row for row in pending if int(row["event_id"]) == event_id), None)
            if target is None:
                self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
                return
            try:
                expected_revision = (
                    int(data["expected_revision"])
                    if data.get("expected_revision") is not None
                    else None
                )
            except (TypeError, ValueError):
                self._send_json(400, {"status": "error", "message": "expected_revision 参数无效。"})
                return
            try:
                counterparty_company_id = int(data["counterparty_company_id"])
            except (KeyError, TypeError, ValueError):
                self._send_json(
                    400, {"status": "error", "message": "counterparty_company_id 必须是有效的公司 id。"}
                )
                return
            if counterparty_company_id == company_id:
                self._send_json(400, {"status": "error", "message": "对方公司不能是本公司。"})
                return
            # Own side is already on the decision; assign the opposite role.
            own_is_payer = target["payer_company_id"] == company_id
            role = "payee" if own_is_payer else "payer"
            if target["payer_company_id"] is None and target["payee_company_id"] is None:
                self._send_json(400, {"status": "error", "message": "单边流水缺少本方参与方，无法确认。"})
                return
            if not own_is_payer and target["payee_company_id"] != company_id:
                self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
                return
            reason = str(data.get("reason") or "").strip() or "公司端确认单边流水"
            try:
                payload = matching.apply_manual_decision(
                    connection,
                    event_id,
                    "assign_participant",
                    reason=reason,
                    expected_revision=expected_revision,
                    request_key=request_key,
                    actor=user,
                    participant={
                        "role": role,
                        "company_id": counterparty_company_id,
                    },
                )
            except matching.MatchConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except matching.MatchInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            try:
                ledger_events.reconcile_bank_events(connection, actor=user)
            except Exception as exc:
                self._send_json(500, {"status": "error", "message": f"同步往来事件失败：{exc}"})
                return
            workspace = matching.company_workspace_payload(connection, company_id)
            self._send_json(
                200,
                {"status": "ok", "decision": payload, "workspace": workspace},
            )
        finally:
            connection.close()

    def _handle_company_transfer_event_detail(self, event_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_user(connection)
            if user is None:
                return
            if user["role"] != "company":
                self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
                return
            current = matching._current_decision_for_event(connection, event_id)
            if current is None:
                self._send_json(404, {"status": "error", "message": "事件不存在。"})
                return
            participants = matching._decision_participants(connection, current["id"])
            participant_company_ids = {p["company_id"] for p in participants}
            if user["company_id"] not in participant_company_ids:
                self._send_json(404, {"status": "error", "message": "事件不存在。"})
                return
            observations = matching._decision_observations(connection, current["id"])
            own_rows = self._company_observation_rows(connection, observations, user["company_id"])
            other_side = next(
                (
                    p for p in participants
                    if p["company_id"] != user["company_id"]
                ),
                None,
            )
            counterparty = None
            if other_side is not None:
                company = connection.execute(
                    "SELECT name FROM companies WHERE id = ?", (other_side["company_id"],)
                ).fetchone()
                counterparty = {
                    "company_id": other_side["company_id"],
                    "company_name": company["name"] if company else None,
                }
                if other_side["bank_account_id"] is not None:
                    account = master_data.get_account(connection, other_side["bank_account_id"])
                    if account is not None:
                        counterparty["account_number_masked"] = master_data.mask_account_number(
                            account["account_number"]
                        )
            self._send_json(
                200,
                {
                    "status": "ok",
                    "event": {
                        "event_id": event_id,
                        "classification": current["classification"],
                        "pairing": current["pairing"],
                        "status": matching.exposed_status(current),
                        "amount": current["amount"],
                        "currency": current["currency"],
                        "effective_at": current["effective_at"],
                        "mode": current["mode"],
                        "reason": current["reason"],
                        "counterparty": counterparty,
                        "observations": own_rows,
                    },
                },
            )
        finally:
            connection.close()

    def _company_observation_rows(self, connection, observations, own_company_id: int) -> list[dict]:
        rows = []
        for observation in observations:
            row = connection.execute(
                """
                SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense,
                       r.own_account, r.own_name, r.counterparty_account,
                       r.counterparty_name, r.summary, r.reference,
                       s.sheet_name, b.id AS import_batch_id,
                       f.original_filename, b.company_id AS batch_company_id
                FROM source_rows r
                JOIN sheet_batches s ON s.id = r.sheet_batch_id
                JOIN import_batches b ON b.id = s.import_batch_id
                JOIN source_files f ON f.id = b.source_file_id
                WHERE r.id = ?
                """,
                (observation["source_row_id"],),
            ).fetchone()
            if row is None or row["batch_company_id"] != own_company_id:
                continue
            rows.append(
                {
                    "source_row_id": row["id"],
                    "role": observation["role"],
                    "source_row": row["source_row"],
                    "sheet_name": row["sheet_name"],
                    "transaction_at": row["transaction_at"],
                    "income": row["income"],
                    "expense": row["expense"],
                    "batch_company_id": row["batch_company_id"],
                    "import_batch_id": row["import_batch_id"],
                    "original_filename": row["original_filename"],
                    "own_account_masked": master_data.mask_account_number(row["own_account"])
                    if row["own_account"]
                    else None,
                    "counterparty_account_masked": master_data.mask_account_number(
                        row["counterparty_account"]
                    )
                    if row["counterparty_account"]
                    else None,
                    "counterparty_name": row["counterparty_name"],
                    "summary": row["summary"],
                    "reference": row["reference"],
                }
            )
        return rows


    # ------------------------------------------------------------------
    # B-44 intercompany positions (admin)
    # ------------------------------------------------------------------

    def _parse_window(self, query: dict[str, list[str]]) -> tuple[str, str]:
        raw_from = (query.get("from") or [None])[0]
        raw_cutoff = (query.get("cutoff") or [None])[0]
        if raw_cutoff is None:
            raw_cutoff = positions.today_shanghai()
        try:
            return positions.validate_window(raw_from, raw_cutoff)
        except positions.PositionInputError as exc:
            self._send_json(400, {"status": "error", "message": str(exc)})
            raise

    def _parse_limit(self, query: dict[str, list[str]], default: int = 50) -> int:
        raw = (query.get("limit") or [str(default)])[0]
        try:
            return max(1, min(int(raw), 200))
        except ValueError:
            return default

    def _parse_int(self, query: dict[str, list[str]], key: str, label: str) -> int | None:
        raw = (query.get(key) or [None])[0]
        if not raw:
            return None
        try:
            return int(raw)
        except ValueError:
            self._send_json(400, {"status": "error", "message": f"{label}参数无效。"})
            raise

    def _handle_admin_intercompany_balances(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            try:
                from_, cutoff = self._parse_window(query)
                company_id = self._parse_int(query, "company_id", "company_id")
                currency = (query.get("currency") or [None])[0]
                limit = self._parse_limit(query)
                cursor = (query.get("cursor") or [None])[0]
            except Exception:
                return
            try:
                payload = positions.company_balances(
                    connection, from_=from_, cutoff=cutoff, currency=currency,
                    company_id=company_id, limit=limit, cursor=cursor,
                )
            except positions.PositionInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_admin_intercompany_pair(
        self, company_a: int, company_b: int, query: dict[str, list[str]]
    ) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            if not self._companies_exist(connection, (company_a, company_b)):
                self._send_json(404, {"status": "error", "message": "公司不存在。"})
                return
            try:
                from_, cutoff = self._parse_window(query)
                currency = (query.get("currency") or [None])[0]
            except Exception:
                return
            try:
                payload = positions.pair_detail(
                    connection, company_a, company_b,
                    from_=from_, cutoff=cutoff, currency=currency,
                )
            except positions.PositionError as exc:
                self._send_json(422, {"status": "error", "message": str(exc)})
                return
            except positions.PositionInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _companies_exist(self, connection, ids: tuple[int, ...]) -> bool:
        for company_id in ids:
            row = connection.execute(
                "SELECT id FROM companies WHERE id = ?", (company_id,)
            ).fetchone()
            if row is None:
                return False
        return True

    def _handle_admin_intercompany_events(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            try:
                from_, cutoff = self._parse_window(query)
                currency = (query.get("currency") or [None])[0]
                company_id = self._parse_int(query, "company_id", "company_id")
                company_a = self._parse_int(query, "company_a", "company_a")
                company_b = self._parse_int(query, "company_b", "company_b")
                subject = (query.get("subject") or [None])[0]
                state = (query.get("state") or [None])[0]
                posting_kind = (query.get("posting_kind") or [None])[0]
                source_kind = (query.get("source_kind") or [None])[0]
                limit = self._parse_limit(query)
                cursor = (query.get("cursor") or [None])[0]
            except Exception:
                return
            try:
                payload = positions.list_events(
                    connection, from_=from_, cutoff=cutoff, currency=currency,
                    company_id=company_id, company_a=company_a, company_b=company_b,
                    subject=subject, state=state, posting_kind=posting_kind,
                    source_kind=source_kind, limit=limit, cursor=cursor,
                )
            except positions.PositionInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_admin_subject_reviews(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            try:
                from_, cutoff = self._parse_window(query)
                company_id = self._parse_int(query, "company_id", "company_id")
                limit = self._parse_limit(query)
                cursor = (query.get("cursor") or [None])[0]
            except Exception:
                return
            try:
                payload = positions.subject_review_queue(
                    connection, from_=from_, cutoff=cutoff,
                    company_id=company_id, limit=limit, cursor=cursor,
                )
            except positions.PositionInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_admin_manual_records(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            company_id = self._parse_int(query, "company_id", "company_id")
            if company_id is None and (query.get("company_id") or [None])[0]:
                return
            state = (query.get("state") or [None])[0]
            limit = self._parse_limit(query, 100)
            try:
                rows = manual_records.list_records(
                    connection, company_id=company_id, state=state, limit=limit
                )
            except manual_records.ManualInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            items = []
            for row in rows:
                payload = manual_records._row_payload(connection, row)
                items.append(payload)
            self._send_json(200, {"status": "ok", "records": items})
        finally:
            connection.close()

    def _handle_admin_intercompany_event_detail(self, event_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            payload = positions.event_detail(connection, event_id)
            if payload is None:
                self._send_json(404, {"status": "error", "message": "事件不存在。"})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_admin_intercompany_evidence(self, event_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            payload = positions.event_evidence(connection, event_id)
            if payload is None:
                self._send_json(404, {"status": "error", "message": "事件不存在。"})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_admin_subject_decision(self, event_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                perspective_company_id = int(data["perspective_company_id"])
                expected_revision = (
                    int(data["expected_revision"])
                    if data.get("expected_revision") is not None
                    else None
                )
            except (KeyError, TypeError, ValueError):
                self._send_json(
                    400, {"status": "error", "message": "perspective_company_id 或 expected_revision 无效。"}
                )
                return
            try:
                action = str(data.get("action") or "confirm")
                if action in ("return", "exception"):
                    payload = subjects.park_subject(
                        connection,
                        event_id,
                        disposition=action,
                        reason=str(data.get("reason") or ""),
                        expected_revision=expected_revision,
                        request_key=str(data.get("request_key") or "") or None,
                        actor=user,
                    )
                else:
                    payload = subjects.confirm_subject(
                        connection,
                        event_id,
                        perspective_company_id=perspective_company_id,
                        subject_code=str(data.get("subject_code") or ""),
                        reason=str(data.get("reason") or ""),
                        expected_revision=expected_revision,
                        request_key=str(data.get("request_key") or "") or None,
                        actor=user,
                    )
            except subjects.SubjectConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except subjects.SubjectInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            auth.audit(
                connection, "subject_confirm", actor=user,
                target=f"ledger_event:{event_id}",
                detail=f"revision:{payload['revision']};subject:{payload['subject_code']}",
                ip=self._client_ip,
            )
            self._send_json(200, {"status": "ok", "revision": payload})
        finally:
            connection.close()

    def _handle_admin_intercompany_adjustment(self, event_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            action = str(data.get("action") or "")
            reason = str(data.get("reason") or "")
            request_key = str(data.get("request_key") or "") or None
            if not reason:
                self._send_json(400, {"status": "error", "message": "必须填写操作原因。"})
                return
            try:
                if action == "reverse":
                    event_id, _revision_id = ledger_events.create_reversal(
                        connection, event_id,
                        source_kind="adjustment",
                        source_revision_token=None,
                        effective_at=data.get("effective_at") or None,
                        reason=reason, actor=user, idempotency_key=request_key,
                    )
                    outcome: dict[str, object] = {
                        "action": "reverse", "ledger_event_id": event_id,
                    }
                elif action == "adjust":
                    try:
                        effective_at = str(data.get("effective_at") or "")
                        amount = str(data.get("amount") or "")
                        currency = str(data.get("currency") or "")
                        payer = int(data["payer_company_id"])
                        payee = int(data["payee_company_id"])
                        perspective = int(data["perspective_company_id"])
                        subject_code = str(data.get("subject_code") or "")
                    except (KeyError, TypeError, ValueError):
                        self._send_json(
                            400, {"status": "error", "message": "adjust 参数不完整或无效。"}
                        )
                        return
                    event_id, _revision_id = ledger_events.create_adjustment(
                        connection, event_id,
                        effective_at=effective_at, amount=amount, currency=currency,
                        payer_company_id=payer, payee_company_id=payee,
                        perspective_company_id=perspective, subject_code=subject_code,
                        reason=reason, actor=user, idempotency_key=request_key,
                    )
                    outcome = {"action": "adjust", "ledger_event_id": event_id}
                elif action == "reopen":
                    event_id, _revision_id = ledger_events.reopen_subject(
                        connection, event_id, reason=reason, actor=user,
                        idempotency_key=request_key,
                    )
                    outcome = {"action": "reopen", "ledger_event_id": event_id}
                else:
                    self._send_json(
                        400,
                        {"status": "error", "message": "action 必须是 reverse、adjust 或 reopen。"},
                    )
                    return
            except ledger_events.LedgerConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except ledger_events.LedgerInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            auth.audit(
                connection, f"ledger_{action}", actor=user,
                target=f"ledger_event:{event_id}", detail=reason, ip=self._client_ip,
            )
            self._send_json(200, {"status": "ok", **outcome})
        finally:
            connection.close()

    def _handle_admin_manual_record_decision(self, record_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user = self._require_admin(connection)
            if user is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                expected_decision_id = (
                    int(data["expected_decision_id"])
                    if data.get("expected_decision_id") is not None
                    else None
                )
            except (TypeError, ValueError):
                self._send_json(400, {"status": "error", "message": "expected_decision_id 无效。"})
                return
            try:
                payload = manual_records.decide(
                    connection,
                    record_id,
                    str(data.get("action") or ""),
                    reason=str(data.get("reason") or ""),
                    expected_decision_id=expected_decision_id,
                    request_key=str(data.get("request_key") or "") or None,
                    actor=user,
                    subject_code=data.get("subject_code"),
                    target_ledger_event_id=data.get("target_ledger_event_id"),
                    effective_at=data.get("effective_at"),
                )
            except manual_records.ManualConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except manual_records.ManualInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", "decision": payload})
        finally:
            connection.close()

    # ------------------------------------------------------------------
    # B-44 intercompany positions (company, own-company scope only)
    # ------------------------------------------------------------------

    def _company_intercompany_scope(self, connection):
        """Reject company users with 403; return the session company id."""
        user = self._require_user(connection)
        if user is None:
            return None, None
        if user["role"] != "company":
            self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
            return None, None
        return user, user["company_id"]

    def _handle_company_intercompany_summary(self, query: dict[str, list[str]]) -> None:
        """Own-company transfer summary; company_id is session-only (HEL-175)."""
        connection = connect(DB_PATH)
        try:
            user, company_id = self._company_intercompany_scope(connection)
            if company_id is None:
                return
            # Front-end must never supply company_id; reject even matching values.
            if (query.get("company_id") or [None])[0] is not None:
                self._send_json(
                    400,
                    {"status": "error", "message": "不允许传入 company_id 参数。"},
                )
                return
            as_of = (query.get("as_of") or [None])[0]
            try:
                payload = company_transfers.company_intercompany_summary(
                    connection, company_id=int(company_id), as_of=as_of
                )
            except company_transfers.TransferSummaryInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _reject_forged_company_id(self, query: dict[str, list[str]]) -> bool:
        """Return True when the handler already sent a 400 for company_id."""
        if (query.get("company_id") or [None])[0] is not None:
            self._send_json(
                400,
                {"status": "error", "message": "不允许传入 company_id 参数。"},
            )
            return True
        return False

    def _handle_company_transfer_summary_events(
        self, query: dict[str, list[str]]
    ) -> None:
        """HEL-176 filtered transfer-detail list (keyset pagination)."""
        connection = connect(DB_PATH)
        try:
            user, company_id = self._company_intercompany_scope(connection)
            if company_id is None:
                return
            if self._reject_forged_company_id(query):
                return
            try:
                payload = company_transfers.company_intercompany_events(
                    connection,
                    company_id=int(company_id),
                    from_=(query.get("from") or [None])[0],
                    to=(query.get("to") or [None])[0],
                    counterparty_id=(query.get("counterparty_id") or [None])[0],
                    direction=(query.get("direction") or [None])[0],
                    state=(query.get("state") or [None])[0],
                    limit=(query.get("limit") or [None])[0],
                    cursor=(query.get("cursor") or [None])[0],
                )
            except company_transfers.TransferSummaryInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_company_intercompany_export_csv(
        self, query: dict[str, list[str]]
    ) -> None:
        """Confirmed-only CSV export; session company scope + audit (HEL-176)."""
        connection = connect(DB_PATH)
        try:
            user, company_id = self._company_intercompany_scope(connection)
            if company_id is None:
                return
            if self._reject_forged_company_id(query):
                return
            # Pending must never leave via export, even if a client sends state=.
            if (query.get("state") or [None])[0] not in (None, "", "confirmed"):
                self._send_json(
                    400,
                    {
                        "status": "error",
                        "message": "导出仅支持已确认明细，不允许导出待确认。",
                    },
                )
                return
            try:
                items, meta = company_transfers.company_intercompany_export_rows(
                    connection,
                    company_id=int(company_id),
                    from_=(query.get("from") or [None])[0],
                    to=(query.get("to") or [None])[0],
                    counterparty_id=(query.get("counterparty_id") or [None])[0],
                    direction=(query.get("direction") or [None])[0],
                )
            except company_transfers.TransferSummaryInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            content = company_transfers.render_intercompany_export_csv(items)
            detail_parts = [
                f"rows:{meta['row_count']}",
                f"from:{meta['start']}",
                f"to:{meta['end']}",
                "state:confirmed",
            ]
            if meta["counterparty_id"] is not None:
                detail_parts.append(f"counterparty_id:{meta['counterparty_id']}")
            if meta["direction"] is not None:
                detail_parts.append(f"direction:{meta['direction']}")
            auth.audit(
                connection,
                "export_intercompany_csv",
                actor=user,
                target=f"company:{company_id}",
                detail=";".join(detail_parts),
                ip=self._client_ip,
            )
            self.send_response(200)
            self.send_header("Content-Type", "text/csv; charset=utf-8")
            self.send_header(
                "Content-Disposition",
                'attachment; filename="intercompany-export.csv"',
            )
            self.send_header("Content-Length", str(len(content)))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(content)
        finally:
            connection.close()

    def _handle_company_intercompany_balances(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user, company_id = self._company_intercompany_scope(connection)
            if company_id is None:
                return
            try:
                from_, cutoff = self._parse_window(query)
                currency = (query.get("currency") or [None])[0]
                limit = self._parse_limit(query)
                cursor = (query.get("cursor") or [None])[0]
            except Exception:
                return
            try:
                payload = positions.company_balances(
                    connection, from_=from_, cutoff=cutoff, currency=currency,
                    company_id=company_id, limit=limit, cursor=cursor,
                )
                counterparties = self._company_counterparty_summary(
                    connection, company_id, from_, cutoff, currency
                )
            except positions.PositionInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **payload, "counterparties": counterparties})
        finally:
            connection.close()

    def _company_counterparty_summary(
        self, connection, company_id: int, from_: str, cutoff: str, currency: str | None
    ) -> list[dict[str, object]]:
        events = positions.load_events(
            connection, from_=from_, cutoff=cutoff, currency=currency,
            company_id=company_id,
        )
        # Buckets are keyed by ``(counterparty, currency)``: amounts never mix
        # across currencies, so one counterparty with CNY and USD yields two rows.
        buckets: dict[tuple[int, str], dict[str, object]] = {}
        for event in events:
            counterparty = (
                event["payee_company_id"]
                if int(event["payer_company_id"]) == int(company_id)
                else event["payer_company_id"]
            )
            name = (
                event["payee_company_name"]
                if int(event["payer_company_id"]) == int(company_id)
                else event["payer_company_name"]
            )
            key = (int(counterparty), event["currency"])
            bucket = buckets.setdefault(
                key,
                {
                    "counterparty_company_id": counterparty,
                    "counterparty_company_name": name,
                    "currency": event["currency"],
                    "signed": 0,
                    "event_count": 0,
                },
            )
            bucket["signed"] += positions.signed_amount(event, company_id)
            bucket["event_count"] += 1

        # Counterparties with pending-subject exposure also show up so the
        # company portal never hides unconfirmed balances.
        pending = connection.execute(
            """
            SELECT p.payer_company_id, p.payee_company_id, p.amount, p.currency,
                   cpayer.name AS payer_company_name, cpayee.name AS payee_company_name
            FROM current_ledger_event_revisions cur
            JOIN ledger_event_revisions p ON p.id = cur.revision_id
            JOIN companies cpayer ON cpayer.id = p.payer_company_id
            JOIN companies cpayee ON cpayee.id = p.payee_company_id
            WHERE p.state = 'pending_subject'
              AND p.effective_at >= ? AND p.effective_at <= ?
              AND (p.payer_company_id = ? OR p.payee_company_id = ?)
            """,
            (from_, cutoff + "T23:59:59", company_id, company_id),
        ).fetchall()
        for row in pending:
            if currency and row["currency"] != currency:
                continue
            counterparty = (
                row["payee_company_id"]
                if int(row["payer_company_id"]) == int(company_id)
                else row["payer_company_id"]
            )
            name = (
                row["payee_company_name"]
                if int(row["payer_company_id"]) == int(company_id)
                else row["payer_company_name"]
            )
            buckets.setdefault(
                (int(counterparty), row["currency"]),
                {
                    "counterparty_company_id": counterparty,
                    "counterparty_company_name": name,
                    "currency": row["currency"],
                    "signed": 0,
                    "event_count": 0,
                },
            )
        items = []
        for (counterparty, cur), bucket in sorted(buckets.items()):
            signed = bucket["signed"]
            unresolved = positions.unresolved_for_company(
                connection, company_id, cutoff, currency=cur,
                counterparty_filter=(company_id, counterparty),
            )
            direction = "receivable" if signed > 0 else ("payable" if signed < 0 else None)
            items.append(
                {
                    "counterparty_company_id": counterparty,
                    "counterparty_company_name": bucket["counterparty_company_name"],
                    "currency": cur,
                    "result": {
                        "kind": "period_net_change",
                        "signed_amount": str(signed),
                        "direction": direction,
                        "label": "期间净变动",
                    },
                    "unresolved": unresolved,
                    "event_count": bucket["event_count"],
                }
            )
        return items

    def _handle_company_intercompany_pair(
        self, counterparty_id: int, query: dict[str, list[str]]
    ) -> None:
        connection = connect(DB_PATH)
        try:
            user, company_id = self._company_intercompany_scope(connection)
            if company_id is None:
                return
            if not self._companies_exist(connection, (counterparty_id,)):
                self._send_json(404, {"status": "error", "message": "公司不存在。"})
                return
            try:
                from_, cutoff = self._parse_window(query)
                currency = (query.get("currency") or [None])[0]
            except Exception:
                return
            try:
                payload = positions.pair_detail(
                    connection, company_id, counterparty_id,
                    from_=from_, cutoff=cutoff, currency=currency,
                )
            except positions.PositionError as exc:
                self._send_json(422, {"status": "error", "message": str(exc)})
                return
            except positions.PositionInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_company_intercompany_events(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user, company_id = self._company_intercompany_scope(connection)
            if company_id is None:
                return
            try:
                from_, cutoff = self._parse_window(query)
                currency = (query.get("currency") or [None])[0]
                subject = (query.get("subject") or [None])[0]
                state = (query.get("state") or [None])[0]
                posting_kind = (query.get("posting_kind") or [None])[0]
                source_kind = (query.get("source_kind") or [None])[0]
                limit = self._parse_limit(query)
                cursor = (query.get("cursor") or [None])[0]
            except Exception:
                return
            try:
                payload = positions.list_events(
                    connection, from_=from_, cutoff=cutoff, currency=currency,
                    subject=subject, state=state, posting_kind=posting_kind,
                    source_kind=source_kind, viewer_company_id=company_id,
                    limit=limit, cursor=cursor,
                )
            except positions.PositionInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_company_intercompany_event_detail(self, event_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user, company_id = self._company_intercompany_scope(connection)
            if company_id is None:
                return
            payload = positions.event_detail(connection, event_id)
            if payload is None:
                self._send_json(404, {"status": "error", "message": "事件不存在。"})
                return
            event = payload["event"]
            if company_id not in (event["payer_company_id"], event["payee_company_id"]):
                self._send_json(404, {"status": "error", "message": "事件不存在。"})
                return
            event.update(
                positions.event_payload(
                    connection,
                    connection.execute(
                        positions._DETAIL_SELECT + " WHERE p.ledger_event_id = ?",
                        (event_id,),
                    ).fetchone(),
                    viewer_company_id=company_id,
                )
            )
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_company_intercompany_evidence(self, event_id: int) -> None:
        connection = connect(DB_PATH)
        try:
            user, company_id = self._company_intercompany_scope(connection)
            if company_id is None:
                return
            detail = positions.event_detail(connection, event_id)
            if detail is None:
                self._send_json(404, {"status": "error", "message": "事件不存在。"})
                return
            event = detail["event"]
            if company_id not in (event["payer_company_id"], event["payee_company_id"]):
                self._send_json(404, {"status": "error", "message": "事件不存在。"})
                return
            payload = positions.event_evidence(
                connection, event_id, viewer_company_id=company_id
            )
            if payload is None:
                self._send_json(404, {"status": "error", "message": "事件不存在。"})
                return
            self._send_json(200, {"status": "ok", **payload})
        finally:
            connection.close()

    def _handle_company_companies(self) -> None:
        connection = connect(DB_PATH)
        try:
            user, company_id = self._company_intercompany_scope(connection)
            if company_id is None:
                return
            rows = connection.execute(
                "SELECT id, name FROM companies ORDER BY id"
            ).fetchall()
            self._send_json(
                200,
                {"status": "ok", "companies": [dict(row) for row in rows]},
            )
        finally:
            connection.close()

    def _handle_company_manual_records(self, query: dict[str, list[str]]) -> None:
        connection = connect(DB_PATH)
        try:
            user, company_id = self._company_intercompany_scope(connection)
            if company_id is None:
                return
            state = (query.get("state") or [None])[0]
            limit = self._parse_limit(query, 100)
            try:
                rows = manual_records.list_records(
                    connection, company_id=company_id, state=state, limit=limit
                )
            except manual_records.ManualInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            # A company only ever sees its own submissions, never declarations
            # where it is merely the counterparty.
            own = [
                manual_records._row_payload(connection, row)
                for row in rows
                if row["company_id"] == company_id
            ]
            self._send_json(200, {"status": "ok", "records": own})
        finally:
            connection.close()

    def _handle_company_manual_records_submit(self) -> None:
        connection = connect(DB_PATH)
        try:
            user, company_id = self._company_intercompany_scope(connection)
            if company_id is None:
                return
            data = self._read_json_body()
            if data is None:
                return
            try:
                payload = manual_records.submit(
                    connection,
                    company_id=company_id,
                    counterparty_company_id=int(data["counterparty_company_id"]),
                    occurred_at=str(data.get("occurred_at") or ""),
                    direction=str(data.get("direction") or ""),
                    amount=str(data.get("amount") or ""),
                    currency=str(data.get("currency") or ""),
                    funding_source=str(data.get("funding_source") or ""),
                    requested_subject=str(data.get("requested_subject") or ""),
                    request_key=str(data.get("request_key") or ""),
                    actor=user,
                    bank_account_id=data.get("bank_account_id"),
                    personal_transit_mapping_id=data.get("personal_transit_mapping_id"),
                    related_source_row_id=data.get("related_source_row_id"),
                    summary=data.get("summary"),
                    reason=data.get("reason"),
                    evidence=data.get("evidence"),
                )
            except manual_records.ManualConflictError as exc:
                self._send_json(409, {"status": "error", "message": str(exc)})
                return
            except manual_records.ManualInputError as exc:
                self._send_json(400, {"status": "error", "message": str(exc)})
                return
            auth.audit(
                connection, "manual_submit", actor=user,
                target=f"manual_record:{payload['id']}",
                detail=f"amount:{payload['amount']};currency:{payload['currency']}",
                ip=self._client_ip,
            )
            self._send_json(200, {"status": "ok", "record": payload})
        finally:
            connection.close()


    # ------------------------------------------------------------------
    # Request/response plumbing
    # ------------------------------------------------------------------

    def _read_json_body(self) -> dict[str, object] | None:
        try:
            content_length = int(self.headers.get("Content-Length", "0"))
        except ValueError:
            content_length = 0
        if content_length <= 0 or content_length > 1024 * 1024:
            self._send_json(400, {"status": "error", "message": "请求体无效。"})
            return None
        try:
            data = json.loads(self.rfile.read(content_length).decode("utf-8"))
        except (UnicodeDecodeError, json.JSONDecodeError):
            self._send_json(400, {"status": "error", "message": "请求体不是有效的 JSON。"})
            return None
        if not isinstance(data, dict):
            self._send_json(400, {"status": "error", "message": "请求体必须是 JSON 对象。"})
            return None
        return data

    @staticmethod
    def _sheet_payload(row) -> dict[str, object]:
        """Serialize one persisted per-sheet review record."""
        return {
            "sheet_name": row["sheet_name"],
            "outcome": row["outcome"],
            "review_status": row["review_status"],
            "message": row["message"],
            "scanned_rows": row["scanned_rows"],
            "candidate_headers": json.loads(row["candidate_headers"])
            if row["candidate_headers"]
            else [],
            "review_reason": row["review_reason"],
            "bank": row["bank_name"],
            "template": row["template_id"],
            "header_row": row["header_row"],
            "period_start": row["period_start"],
            "period_end": row["period_end"],
            "transactions": row["transaction_count"] or 0,
            "warnings": json.loads(row["warnings"]) if row["warnings"] else [],
        }

    @staticmethod
    def _import_payload(connection, result) -> dict[str, object]:
        """Build the parse response from persisted state, not from memory.

        All worksheet results are returned (no more ``batches[0]``). A
        cross-company duplicate is opaque: only the generic status, the
        uploader's own batch id and the sha256 are returned, never the other
        company's batch id, summary or diagnostics.
        """
        payload: dict[str, object] = {
            "status": result.status,
            "batch_id": result.batch_id,
            "file_sha256": result.sha256,
        }
        if result.message:
            payload["message"] = result.message
        if result.status == "duplicate" and not result.duplicate_same_company:
            return payload

        file_row = connection.execute(
            "SELECT original_filename FROM source_files WHERE id = ?",
            (result.source_file_id,),
        ).fetchone()
        if file_row is not None:
            payload["original_filename"] = file_row["original_filename"]
        sheets = importing.sheet_review_rows(connection, result.batch_id)
        payload["sheets"] = [AppHandler._sheet_payload(row) for row in sheets]
        return payload

    def _send_json(
        self,
        status: int,
        payload: dict[str, object],
        *,
        session_token: str | None = None,
        clear_session: bool = False,
    ) -> None:
        content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(content)))
        self.send_header("Cache-Control", "no-store")
        if session_token is not None:
            self._set_session_cookie(session_token)
        if clear_session:
            self._clear_session_cookie()
        self.end_headers()
        self.wfile.write(content)


def ensure_bootstrap_admin(connection) -> str | None:
    """Create the first admin when none exists; returns the generated password."""
    existing = connection.execute(
        "SELECT 1 FROM users WHERE role = 'admin' LIMIT 1"
    ).fetchone()
    if existing is not None:
        return None
    username = os.environ.get("APP_ADMIN_USERNAME", "group-admin")
    env_password = os.environ.get("APP_BOOTSTRAP_ADMIN_PASSWORD")
    password = env_password or auth.generate_initial_password()
    user_id = auth.create_user(connection, username, password, "admin")
    auth.audit(connection, "bootstrap_admin", target=f"user:{user_id}", detail=username)
    return None if env_password else password


def main() -> None:
    host = os.environ.get("APP_HOST", "0.0.0.0")
    port = int(os.environ.get("APP_PORT", "4173"))
    connection = connect(DB_PATH)
    try:
        migrate(connection)
        initial_password = ensure_bootstrap_admin(connection)
    finally:
        connection.close()
    if initial_password is not None:
        # Printed once to stdout; never written to any log file.
        print(f"Bootstrap admin initial password (shown once): {initial_password}")
    server = ThreadingHTTPServer((host, port), AppHandler)
    print(f"Serving on http://{host}:{port}")
    server.serve_forever()


if __name__ == "__main__":
    main()
