"""HEL-203: 真实浏览器冒烟——原因弹窗、首屏断档、表单 reset。"""

from __future__ import annotations

import os
import re
import tempfile
import threading
import unittest
from pathlib import Path

from bank_importer import auth, calculation, matching, master_data
from bank_importer.db import connect, migrate, utc_now

import server

ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "CashierA123"

try:
    from playwright.sync_api import sync_playwright
except ImportError:  # pragma: no cover
    sync_playwright = None


def _prepare_chrome_libs() -> Path | None:
    """本机缺系统 atk 时，复用仓库旁的本地 chromium 依赖目录。"""
    candidates = [ROOT / ".chrome-libs" / "lib"]
    for lib_dir in candidates:
        if (lib_dir / "libatk-1.0.so.0").exists():
            current = os.environ.get("LD_LIBRARY_PATH", "")
            prefix = str(lib_dir)
            if prefix not in current.split(":"):
                os.environ["LD_LIBRARY_PATH"] = (
                    f"{prefix}:{current}" if current else prefix
                )
            return lib_dir
    return None


def _chromium_available() -> bool:
    if not sync_playwright:
        return False
    _prepare_chrome_libs()
    try:
        with sync_playwright() as p:
            browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
            browser.close()
        return True
    except Exception:
        return False


class Hel203SourceContractTests(unittest.TestCase):
    """不依赖浏览器：锁住 N1～N3 的源码契约。"""

    def test_open_modal_is_single_top_level(self) -> None:
        js = (WEB / "app.js").read_text(encoding="utf-8")
        defs = list(re.finditer(r"(?m)^function openModal\(", js))
        self.assertEqual(1, len(defs), "openModal 必须只有一处顶层定义")
        # 不得再出现在 initAdmin / initCompany 函数体内的局部副本
        self.assertNotRegex(
            js,
            r"function initAdmin\(\)[\s\S]*?function openModal\(",
        )
        self.assertNotRegex(
            js,
            r"function initCompany\(\)[\s\S]*?function openModal\(",
        )
        self.assertIn("function askReason(", js)
        ask_pos = js.index("function askReason(")
        open_pos = defs[0].start()
        self.assertLess(open_pos, ask_pos, "openModal 须在 askReason 之前定义")

    def test_init_company_boots_coverage_gaps(self) -> None:
        js = (WEB / "app.js").read_text(encoding="utf-8")
        company_fn = js[js.index("function initCompany(") :]
        boot = company_fn[: company_fn.index("\nif (portal ===")]
        self.assertIn("await loadCompanyWorkspace()", boot)
        self.assertIn("await loadCompanyCoverageGaps()", boot)

    def test_async_forms_capture_form_before_await(self) -> None:
        js = (WEB / "app.js").read_text(encoding="utf-8")
        self.assertNotIn("event.currentTarget.reset()", js)
        for marker in ("#companyForm", "#openingForm", "#accountForm"):
            idx = js.index(marker)
            chunk = js[idx : idx + 2500]
            self.assertIn("const form = event.currentTarget", chunk)
            self.assertIn("form.reset()", chunk)


@unittest.skipUnless(sync_playwright, "playwright 未安装，跳过浏览器冒烟")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动，跳过浏览器冒烟")
class Hel203BrowserSmokeTests(unittest.TestCase):
    """真实 Chromium：改起算日→期初→公司端期末；断档首屏→说明→审核。"""

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

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

        connection = connect(cls.db_path)
        migrate(connection)
        auth.create_user(
            connection,
            "group-admin",
            ADMIN_PASSWORD,
            "admin",
            must_change_password=False,
        )
        cls.company_a = master_data.create_company(
            connection, "甲公司", None, None, None
        )
        cls.company_b = master_data.create_company(
            connection, "乙公司", None, None, None
        )
        auth.create_user(
            connection,
            "cashier-a",
            CASHIER_PASSWORD,
            "company",
            cls.company_a,
            must_change_password=False,
        )
        admin = connection.execute(
            "SELECT * FROM users WHERE username = 'group-admin'"
        ).fetchone()
        account = master_data.submit_bank_account(
            connection,
            company_id=cls.company_a,
            bank_name="中信银行",
            account_type="基本户",
            account_number="6222000000000001",
            start_date="2026-06-01",
            actor=None,
        )
        cls.account_a = master_data.review_bank_account(
            connection,
            account["id"],
            "approve",
            None,
            admin,
            effective_from="2026-06-01",
        )
        account_b = master_data.submit_bank_account(
            connection,
            company_id=cls.company_b,
            bank_name="中信银行",
            account_type="基本户",
            account_number="6222000000000002",
            start_date="2026-06-01",
            actor=None,
        )
        master_data.review_bank_account(
            connection,
            account_b["id"],
            "approve",
            None,
            admin,
            effective_from="2026-06-01",
        )
        # 制造一处 mid 断档，供公司端首屏提醒
        with connection:
            cursor = connection.execute(
                """
                INSERT INTO source_files
                  (sha256, original_filename, size_bytes, storage_path, created_at)
                VALUES (?, 'gap.xlsx', 1, 'data/files/gap.xlsx', ?)
                """,
                ("sha-hel203-gap", utc_now()),
            )
            source_file_id = int(cursor.lastrowid)
            cursor = connection.execute(
                """
                INSERT INTO import_batches (
                    source_file_id, status, company_id, upload_bank_account_id,
                    created_at, updated_at
                ) VALUES (?, 'parsed', ?, ?, ?, ?)
                """,
                (
                    source_file_id,
                    cls.company_a,
                    cls.account_a["id"],
                    utc_now(),
                    utc_now(),
                ),
            )
            batch_id = int(cursor.lastrowid)
            cursor = connection.execute(
                """
                INSERT INTO sheet_batches (
                    import_batch_id, sheet_name, bank_name, template_id,
                    template_version, header_row, transaction_count, warnings,
                    created_at
                ) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
                """,
                (batch_id, utc_now()),
            )
            sheet_batch_id = int(cursor.lastrowid)
            connection.execute(
                """
                INSERT INTO sheet_reviews (
                    import_batch_id, sheet_name, outcome, sheet_batch_id,
                    review_status, created_at
                ) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
                """,
                (batch_id, sheet_batch_id, utc_now()),
            )
            connection.execute(
                """
                INSERT INTO source_rows (
                    sheet_batch_id, source_row, transaction_at, income, expense,
                    own_account, created_at
                ) VALUES (?, 1, '2026-06-21T10:00:00', '0', '0', ?, ?)
                """,
                (sheet_batch_id, "6222000000000001", utc_now()),
            )
        calculation.set_calculation_start_date(
            connection, "2026-06-01", "初始化起算", admin
        )
        calculation.recalculate_coverage_gaps(connection)

        # 种一笔已确认往来，确认期初后公司端才能进入完整期末口径
        def _add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
            with connection:
                cursor = connection.execute(
                    """
                    INSERT INTO source_files
                      (sha256, original_filename, size_bytes, storage_path, created_at)
                    VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
                    """,
                    (ref, utc_now()),
                )
                source_file_id = int(cursor.lastrowid)
                cursor = connection.execute(
                    """
                    INSERT INTO import_batches (
                        source_file_id, status, company_id, upload_bank_account_id,
                        created_at, updated_at
                    ) VALUES (?, 'parsed', ?, ?, ?, ?)
                    """,
                    (source_file_id, company_id, account_id, utc_now(), utc_now()),
                )
                batch_id = int(cursor.lastrowid)
                cursor = connection.execute(
                    """
                    INSERT INTO sheet_batches (
                        import_batch_id, sheet_name, bank_name, template_id,
                        template_version, header_row, transaction_count, warnings,
                        created_at
                    ) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
                    """,
                    (batch_id, utc_now()),
                )
                sheet_batch_id = int(cursor.lastrowid)
                connection.execute(
                    """
                    INSERT INTO sheet_reviews (
                        import_batch_id, sheet_name, outcome, sheet_batch_id,
                        review_status, created_at
                    ) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
                    """,
                    (batch_id, sheet_batch_id, utc_now()),
                )
                cursor = connection.execute(
                    """
                    INSERT INTO source_rows (
                        sheet_batch_id, source_row, transaction_at, income, expense,
                        own_account, own_name, counterparty_account, counterparty_name,
                        summary, purpose, currency, created_at
                    ) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
                    """,
                    (
                        sheet_batch_id,
                        at,
                        income,
                        expense,
                        own,
                        cp,
                        utc_now(),
                    ),
                )
            return int(cursor.lastrowid)

        row_a = _add_row(
            cls.company_a,
            cls.account_a["id"],
            "6222000000000001",
            "6222000000000002",
            income="0",
            expense="100.00",
            at="2026-06-20T10:00:00",
            ref="sha-hel203-a",
        )
        row_b = _add_row(
            cls.company_b,
            account_b["id"],
            "6222000000000002",
            "6222000000000001",
            income="100.00",
            expense="0",
            at="2026-06-20T11:00:00",
            ref="sha-hel203-b",
        )
        matching.reconcile_rows(connection, [row_a, row_b])
        connection.close()

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

        cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
        cls.port = cls.httpd.server_address[1]
        cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
        cls.thread.start()
        cls.base = f"http://127.0.0.1:{cls.port}"

    @classmethod
    def tearDownClass(cls) -> None:
        cls.httpd.shutdown()
        cls.httpd.server_close()
        server.DB_PATH = cls._old_db
        server.STORAGE_DIR = cls._old_storage
        cls.temp_dir.cleanup()

    def _new_page(self, playwright):
        browser = playwright.chromium.launch(
            headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
        )
        context = browser.new_context(viewport={"width": 1440, "height": 900})
        page = context.new_page()
        errors: list[str] = []
        page.on("pageerror", lambda err: errors.append(str(err)))
        page.on(
            "console",
            lambda msg: errors.append(f"console.{msg.type}: {msg.text}")
            if msg.type == "error"
            else None,
        )
        return browser, page, errors

    def _login(self, page, *, portal: str, username: str, password: str) -> None:
        login_path = "login-admin.html" if portal == "admin" else "login-company.html"
        page.goto(f"{self.base}/{login_path}", wait_until="domcontentloaded")
        page.fill("#account", username)
        page.fill("#password", password)
        page.click('button[type="submit"]')
        expect = "admin.html" if portal == "admin" else "company.html"
        page.wait_for_url(f"**/{expect}", timeout=15000)

    def test_02_admin_start_date_opening_company_ending(self) -> None:
        with sync_playwright() as p:
            browser, page, errors = self._new_page(p)
            try:
                self._login(
                    page,
                    portal="admin",
                    username="group-admin",
                    password=ADMIN_PASSWORD,
                )
                page.click('a[data-view="settings"]')
                page.wait_for_selector("#cs-start", state="visible")

                # 修改起算日 → 原因弹窗必须打开且发出 PUT
                page.fill("#cs-start", "2026-06-15")
                with page.expect_request(
                    lambda req: req.method == "PUT"
                    and "/api/admin/settings/calculation-start" in req.url
                ) as start_req:
                    page.click('#systemSettings button[type="submit"]')
                    page.wait_for_selector("#reasonDialog.open", timeout=5000)
                    page.fill("#reasonInput", "调整起算日供冒烟")
                    page.click("#reasonSubmit")
                self.assertTrue(start_req.value.post_data)
                page.wait_for_function(
                    "() => document.getElementById('cs-start')?.value === '2026-06-15'"
                )

                # 创建期初并确认
                page.click("#openOpeningDialog")
                page.wait_for_selector("#openingDialog.open")
                page.select_option("#ob-from", label="甲公司")
                page.select_option("#ob-to", label="乙公司")
                page.fill("#ob-amount", "200")
                page.fill("#ob-reason", "冒烟期初录入")
                with page.expect_request(
                    lambda req: req.method == "POST"
                    and req.url.endswith("/api/admin/opening-balances")
                ):
                    page.click('#openingForm button[type="submit"]')
                page.wait_for_selector(
                    '#openingRows button[data-confirm-opening]',
                    timeout=8000,
                )

                with page.expect_request(
                    lambda req: req.method == "POST"
                    and "/opening-balances/" in req.url
                    and req.url.endswith("/confirm")
                ):
                    page.click('#openingRows button[data-confirm-opening]')
                    page.wait_for_selector("#reasonDialog.open", timeout=5000)
                    page.fill("#reasonInput", "确认期初冒烟")
                    page.click("#reasonSubmit")
                page.wait_for_selector(
                    '#openingRows button[data-void-opening]',
                    timeout=8000,
                )

                # 公司端看到完整期末口径
                self._login(
                    page,
                    portal="company",
                    username="cashier-a",
                    password=CASHIER_PASSWORD,
                )
                page.click('a[data-view="transfers"]')
                page.wait_for_function(
                    """() => {
                      const data = document.getElementById('transfersData');
                      const empty = document.getElementById('transfersEmpty');
                      const ready = (data && !data.hidden) || (empty && !empty.hidden);
                      const card = document.getElementById('tfStatEndingCard');
                      const title = document.getElementById('tfStatNetTitle');
                      const emptyHtml = document.getElementById('transfersEmptyStats')?.innerHTML || '';
                      return ready && (
                        (card && !card.hidden) ||
                        (title && title.textContent.includes('期末')) ||
                        emptyHtml.includes('期末')
                      );
                    }""",
                    timeout=15000,
                )
                fatal = [e for e in errors if "openModal is not defined" in e
                         or "Cannot read properties of null" in e]
                self.assertEqual([], fatal, fatal)
            finally:
                browser.close()

    def test_01_company_gap_notice_then_admin_approve(self) -> None:
        with sync_playwright() as p:
            browser, page, errors = self._new_page(p)
            try:
                self._login(
                    page,
                    portal="company",
                    username="cashier-a",
                    password=CASHIER_PASSWORD,
                )
                # 首屏即可见断档提醒，无需手动刷新
                page.wait_for_selector(
                    "#companyCoverageNotice",
                    state="visible",
                    timeout=10000,
                )
                body = page.locator("#companyCoverageBody").inner_text()
                self.assertTrue(body.strip())
                self.assertNotIn("0002", body)

                page.click("#openAttestationFromWorkspace")
                page.wait_for_selector("#attestationDialog.open", timeout=5000)
                page.fill("#att-reason", "节假日账户无资金往来")
                with page.expect_request(
                    lambda req: req.method == "POST"
                    and req.url.endswith("/api/company/no-business-attestations")
                ) as att_req:
                    page.click('#attestationForm button[type="submit"]')
                self.assertTrue(att_req.value.post_data)
                page.wait_for_function(
                    """() => !document.getElementById('attestationDialog')?.classList.contains('open')""",
                    timeout=8000,
                )

                self._login(
                    page,
                    portal="admin",
                    username="group-admin",
                    password=ADMIN_PASSWORD,
                )
                page.click('a[data-view="audit"]')
                page.wait_for_selector(
                    'button[data-audit-action="approve-attestation"]',
                    timeout=10000,
                )
                with page.expect_request(
                    lambda req: req.method == "POST"
                    and "/no-business-attestations/" in req.url
                    and req.url.endswith("/review")
                ):
                    page.click('button[data-audit-action="approve-attestation"]')
                    page.wait_for_selector("#reasonDialog.open", timeout=5000)
                    page.fill("#reasonInput", "审核通过说明")
                    page.click("#reasonSubmit")
                page.wait_for_function(
                    """() => !document.querySelector(
                      'button[data-audit-action=\"approve-attestation\"]'
                    )""",
                    timeout=10000,
                )
                fatal = [e for e in errors if "openModal is not defined" in e]
                self.assertEqual([], fatal, fatal)
            finally:
                browser.close()


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