from __future__ import annotations

import base64
import hashlib
import json
import re
import tempfile
import unittest
from datetime import date
from pathlib import Path
from typing import Any, Dict, List

from arr_web.app import PortalApplication, RuntimeHealth, SessionLedger
from arr_web.auth import LoginCredentials
from arr_web.downloads import (
    ArtifactDescriptor,
    ControlledProjectArtifactReader,
    ManagedObjectArtifactReader,
    RoutedArtifactReader,
)
from arr_web.repository import PortalDataError
from arr_storage.filesystem import FilesystemObjectBackend
from arr_storage.store import ManagedObjectStore


PROJECT_ROOT = Path(__file__).resolve().parents[1]
STATIC_ROOT = PROJECT_ROOT / "arr_web" / "static"
TEST_CREDENTIALS = LoginCredentials(
    username="arr-test-operator",
    password="arr-test-password",
)


class FakeRepository:
    def __init__(self) -> None:
        self.months = [
            {
                "month_key": "2026-07",
                "max_arrival_date": "2026-07-26",
                "updated_at": "2026-07-28T12:00:00+08:00",
                "filename": "monthly.xlsx",
                "source_monthly_sha256": "a" * 64,
                "channel_count": 2,
                "row_count": 3,
            }
        ]

    def list_jobs(
        self,
        month_key: str,
        limit: int = 50,
        offset: int = 0,
    ) -> tuple[List[Dict[str, Any]], int]:
        return [
            {"job_id": "job-1", "status": "succeeded", "filename": "ARR.XML"}
        ], 1

    def get_job_trace(self, job_id: str) -> Dict[str, Any]:
        return {
            "trace_version": "arr-job-trace-2",
            "job": {
                "job_id": job_id,
                "status": "succeeded",
                "active": False,
                "current_stage": "database",
            },
            "evidence": {"attempts": 1},
            "logs": [
                {
                    "id": "run.received",
                    "timestamp": "2026-07-29T11:00:00+00:00",
                    "stage": "upload",
                    "level": "success",
                    "code": "UPLOAD_REGISTERED",
                    "title": "上传已安全登记",
                    "message": "任务已创建",
                }
            ],
        }

    def list_monthly_runs(
        self,
        month_key: str,
        limit: int = 50,
        offset: int = 0,
    ) -> tuple[List[Dict[str, Any]], int]:
        return [
            {
                "report_id": 3,
                "version_no": 3,
                "status": "active",
                "max_arrival_date": "2026-07-26",
            }
        ], 1

    def list_history_month_counts(self) -> List[Dict[str, Any]]:
        return [
            {
                "month_key": "2026-07",
                "daily_count": 3,
                "monthly_count": 2,
            }
        ]

    def list_months(self) -> List[Dict[str, Any]]:
        return self.months

    def read_dashboard(self, month_key: str) -> Dict[str, Any]:
        if month_key == "2026-06":
            raise PortalDataError(
                "ANALYTICS_MONTH_NOT_FOUND",
                "current monthly database projection was not found",
            )
        return {
            "version": "1.2",
            "month_key": month_key,
            "updated_at": "2026-07-28T12:00:00+08:00",
            "min_arrival_date": "2026-07-01",
            "max_arrival_date": "2026-07-26",
            "source_monthly_sha256": "a" * 64,
            "overall": {
                "totals": {
                    "rooms_sold": 3,
                    "total_price": 1200,
                    "room_nights": 5,
                    "reservation_rows": 3,
                    "room_type_count": 1,
                    "channel_count": 2,
                },
                "room_types": [{"room_type": "KING", "rooms_sold": 3}],
            },
            "channels": [
                {
                    "worksheet": "COMPANY-A",
                    "totals": {"rooms_sold": 2, "total_price": 800},
                    "room_types": [{"room_type": "KING", "rooms_sold": 2}],
                },
                {
                    "worksheet": "COMPANY-B",
                    "totals": {"rooms_sold": 1, "total_price": 400},
                    "room_types": [{"room_type": "KING", "rooms_sold": 1}],
                },
            ],
        }

    def read_channel_detail(
        self,
        month_key: str,
        worksheet: str,
        limit: int,
        offset: int,
    ) -> Dict[str, Any]:
        return {"month_key": month_key, "worksheet": worksheet, "rows": []}

    def resolve_daily_download(self, job_id: str) -> ArtifactDescriptor:
        return ArtifactDescriptor(
            "daily_xlsx", "7.26.xlsx", "private/daily.xlsx", "a" * 64, 4, "application/xlsx"
        )

    def resolve_monthly_download(self, report_id: int) -> ArtifactDescriptor:
        return ArtifactDescriptor(
            "monthly_xlsx", "月报.xlsx", "private/monthly.xlsx", "b" * 64, 4, "application/xlsx"
        )


class FakeUpload:
    def __init__(self) -> None:
        self.calls: list[tuple[str, bytes]] = []

    def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]:
        self.calls.append((original_filename, payload))
        return {"job_id": "arr-job-1", "status": "queued"}


class FakeDailyPriceReviews:
    case_id = "dailyreview-0123456789abcdef0123456789abcdef"

    def __init__(self) -> None:
        self.read_calls: list[tuple[str, int, int]] = []
        self.update_calls: list[tuple[str, int, str, int, str, str]] = []
        self.finalize_calls: list[tuple[str, str, int, str]] = []
        self.cancel_calls: list[tuple[str, str, int, str]] = []

    def payload(self) -> Dict[str, Any]:
        return {
            "case_id": self.case_id,
            "case_status": "open",
            "revision": 3,
            "business_date": "2026-07-27",
            "completed_items": 1,
            "total_items": 2,
            "items": [
                {
                    "item_id": 9,
                    "company_key": "QBD",
                    "rate_code": "GRPA1",
                    "effective_rate_amount": 1800,
                    "candidate_prices": [{"effective_rate_amount": 900, "real_price": 1800}],
                    "affected_records": 2,
                    "affected_rooms": 3,
                    "affected_room_nights": 3,
                    "real_price": "0",
                    "revision": 3,
                }
            ],
            "pagination": {"limit": 50, "offset": 0, "total": 2, "has_next": False},
        }

    def get_price_review(self, job_id: str, limit: int, offset: int) -> Dict[str, Any]:
        self.read_calls.append((job_id, limit, offset))
        return self.payload()

    def update_price_review_item(
        self,
        job_id: str,
        item_id: int,
        case_id: str,
        revision: int,
        real_price: str,
        actor_username: str,
    ) -> Dict[str, Any]:
        self.update_calls.append((job_id, item_id, case_id, revision, real_price, actor_username))
        return self.payload()

    def finalize_price_review(
        self, job_id: str, case_id: str, revision: int, actor_username: str
    ) -> Dict[str, Any]:
        self.finalize_calls.append((job_id, case_id, revision, actor_username))
        return {"job_id": job_id, "status": "needs_review", "review_case_id": case_id}

    def cancel_price_review(
        self, job_id: str, case_id: str, revision: int, actor_username: str
    ) -> Dict[str, Any]:
        self.cancel_calls.append((job_id, case_id, revision, actor_username))
        return {"job_id": job_id, "status": "cancelled", "review_case_id": case_id}


class FakeMonthly:
    def __init__(self) -> None:
        self.calls: list[tuple[str, date]] = []

    def generate(self, month_key: str, as_of_date: date) -> Dict[str, Any]:
        self.calls.append((month_key, as_of_date))
        return {"status": "success", "month_key": month_key}


class FakeArtifactReader:
    def read(self, descriptor: ArtifactDescriptor) -> bytes:
        return b"xlsx"


def decoded(response: Any) -> Dict[str, Any]:
    return json.loads(response.body.decode("utf-8"))


def login(
    app: PortalApplication,
) -> tuple[Any, Dict[str, str]]:
    response = app.handle(
        "POST",
        "/api/login",
        {"Content-Type": "application/json"},
        json.dumps(
            {
                "username": TEST_CREDENTIALS.username,
                "password": TEST_CREDENTIALS.password,
            }
        ).encode("utf-8"),
    )
    if response.status != 200:
        raise AssertionError(f"test login failed with HTTP {response.status}")
    payload = decoded(response)
    cookie = response.headers["Set-Cookie"].split(";", 1)[0]
    return response, {
        "Cookie": cookie,
        "X-ARR-CSRF": payload["data"]["csrf_token"],
    }


class PortalApplicationTests(unittest.TestCase):
    def setUp(self) -> None:
        self.upload = FakeUpload()
        self.price_reviews = FakeDailyPriceReviews()
        self.monthly = FakeMonthly()
        self.app = PortalApplication(
            repository=FakeRepository(),
            upload=self.upload,
            price_reviews=self.price_reviews,
            monthly=self.monthly,
            health=RuntimeHealth(True, True, True),
            sessions=SessionLedger(),
            credentials=TEST_CREDENTIALS,
        )
        _, self.auth_headers = login(self.app)

    def test_daily_history_hides_internal_filename_when_provenance_is_missing(self) -> None:
        script = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
        self.assertIn('job.filename || "—"', script)

    def session_headers(self) -> Dict[str, str]:
        return dict(self.auth_headers)

    def test_session_cookie_is_secure_only_when_explicitly_enabled(self) -> None:
        default_cookie = login(self.app)[0].headers["Set-Cookie"]
        secure_app = PortalApplication(
            sessions=SessionLedger(),
            secure_cookies=True,
            credentials=TEST_CREDENTIALS,
        )
        secure_cookie = login(secure_app)[0].headers["Set-Cookie"]

        self.assertNotIn("; Secure", default_cookie)
        self.assertIn("; Secure", secure_cookie)
        self.assertIn("; HttpOnly", secure_cookie)
        self.assertIn("; SameSite=Strict", secure_cookie)

    def test_fixed_static_routes_and_required_content(self) -> None:
        desktop = self.app.handle("GET", "/", self.auth_headers)
        mobile = self.app.handle("GET", "/h5", self.auth_headers)
        self.assertEqual(desktop.status, 200)
        self.assertEqual(mobile.status, 200)
        desktop_text = desktop.body.decode("utf-8")
        mobile_text = mobile.body.decode("utf-8")
        for expected in (
            '<span class="brand-name">ARR Report</span>',
            '<h2 id="daily-history-title">Daily Report</h2>',
            "ARRIVAL DATE",
            "NO. OF ROOM",
            "月报",
            "月报处理",
            "渠道BI",
            "公司渠道明细",
            "各公司本月销售情况",
            "渠道 × 房型售出房数",
            "任务日志",
            "arr-task.log",
            "$ arr trace",
        ):
            self.assertIn(expected, desktop_text)
        for removed in (
            "标准月报",
            "VERSION HISTORY",
            "标准月报版本记录",
            "公司渠道明细按 C/O（离店日）所属期间分页",
        ):
            self.assertNotIn(removed, desktop_text)
        self.assertNotIn(
            '<table class="monthly-table">\n              <thead><tr><th>版本</th>',
            desktop_text,
        )
        for required_id in (
            'id="process-log"',
            'id="trace-live-state"',
            'id="refresh-trace"',
            'id="copy-trace"',
            'id="jobs-prev"',
            'id="jobs-next"',
            'id="monthly-prev"',
            'id="monthly-next"',
            'id="company-prev"',
            'id="company-next"',
            'id="company-excel-file"',
            'id="company-upload-button"',
            'id="company-review-panel"',
            'id="company-review-filename"',
            'id="company-review-body"',
            'id="company-review-activate"',
            'id="company-review-discard"',
            'id="company-review-delete-selected"',
            'id="company-review-select-page"',
            'id="company-review-confirm-dialog"',
            'id="daily-price-review-panel"',
            'id="daily-price-review-body"',
            'id="daily-price-review-finalize"',
            'id="daily-price-review-cancel"',
            'id="daily-price-review-confirm-dialog"',
            'id="company-report-confirm-dialog"',
            'id="company-report-confirm-cancel"',
            'id="company-report-confirm-submit"',
        ):
            self.assertIn(required_id, desktop_text)
        self.assertIn("复制全部日志", desktop_text)
        self.assertIn('<pre class="trace-log"', desktop_text)
        self.assertNotIn('id="trace-stage-filter"', desktop_text)
        self.assertNotIn('id="trace-level-filter"', desktop_text)
        self.assertNotIn('id="trace-search"', desktop_text)
        self.assertNotIn("process-orbit", desktop_text)
        self.assertNotIn('class="pipeline"', desktop_text)
        self.assertNotIn("brand-mark", desktop_text)
        self.assertNotIn("DAILY ARRIVAL", desktop_text)
        self.assertNotIn(
            "上传 Opera ARR.XML，处理结果验收通过后写入业务数据库。",
            desktop_text,
        )
        self.assertNotIn("DATABASE REPORT", desktop_text)
        self.assertNotIn(
            "按数据库中已验收的日报事实生成月报，版本与文件月内持续留痕。",
            desktop_text,
        )
        self.assertNotIn("自动发布", desktop_text)
        self.assertNotIn(
            "日报入库后自动生成；“更新至”取本月数据中的最新 ARRIVAL。"
            "发布后列表自动新增，无需手动刷新。",
            desktop_text,
        )
        for removed in (
            "COMPANY DETAIL REPORTS",
            "基于 finance 已校验数据，按 C/O（离店日）所属期间生成五家公司 Excel。",
            "FINANCE DATA",
            "选择月份和本月生成期间",
            "点击任一期间后，一次固定生成 LianTai、QBD、DY-AI-Easy-KB、FengRun、HanaTour 五份工作簿。",
            "本月生成期间（一次生成五家公司）",
            "同月重跑会生成新的正式版本并保留历史归档；浏览器只提交月份和期间，不提交公司名单、数据库参数或文件路径。",
            "ROOMS SOLD",
            "支持已开放期间重跑和历史月份补录。",
            "页面与期间时区",
            "公司已生成",
            "TASK HISTORY",
            "公司渠道明细生成记录",
            "程序读取 Tour Code 与 โรงแรม，自动拆分房型和数量；异常项目可在下方人工确认。",
            "提取草稿",
            "核对房型记录",
            "还有 12 条待人工记录；填写房型并保存，或删除不需要的记录。",
            "当前数据源",
        ):
            self.assertNotIn(removed, desktop_text)
        self.assertNotIn("ROOMS SOLD", mobile_text)
        self.assertNotIn("company-timezone", desktop_text)
        styles = (STATIC_ROOT / "styles.css").read_text(encoding="utf-8")
        self.assertIn("background: #080b10", styles)
        self.assertIn("[hidden] { display: none !important; }", styles)
        self.assertIn(".company-period-button:active:not(:disabled)", styles)
        self.assertIn(".company-period-button:disabled .company-period-cta", styles)
        self.assertIn("width: clamp(72px, 34%, 94px)", styles)
        self.assertIn(".company-report-card-row", styles)
        self.assertIn("grid-template-columns: minmax(320px, 1.35fr) repeat(3", styles)
        self.assertIn(".company-setup-heading", styles)
        self.assertIn(".company-upload-button:active:not(:disabled)", styles)
        self.assertIn("min-width: 104px", styles)
        self.assertIn("align-self: flex-end", styles)
        self.assertIn(".company-page-title", styles)
        self.assertIn(".company-fixed-companies", styles)
        self.assertIn(".company-source-action", styles)
        self.assertIn(".company-source-current", styles)
        self.assertNotIn(".company-job-source", styles)
        self.assertNotIn(".company-history-source", styles)
        self.assertIn(".company-review-file", styles)
        self.assertIn("overflow-wrap: anywhere", styles)
        self.assertIn(".company-review-row.is-pending", styles)
        self.assertIn(".company-review-input:focus-visible", styles)
        self.assertIn(".company-review-delete-selected", styles)
        self.assertIn(".company-review-confirm-dialog::backdrop", styles)
        self.assertIn(".daily-price-review-input:focus-visible", styles)
        self.assertIn(".daily-price-review-confirm-dialog::backdrop", styles)
        self.assertIn(".company-report-confirm-dialog::backdrop", styles)
        script = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
        self.assertIn('navigator.clipboard?.writeText', script)
        self.assertIn('document.execCommand("copy")', script)
        self.assertIn('$("#copy-trace").addEventListener("click", copyAllTraceLogs)', script)
        self.assertIn("运行固定处理器", script)
        self.assertIn("saveCompanyReviewItem", script)
        self.assertIn("deleteCompanyReviewItem", script)
        self.assertIn("deleteSelectedCompanyReviewItems", script)
        self.assertIn("const COMPANY_REVIEW_PAGE_SIZE = 50", script)
        self.assertIn('api("/api/company-reports/source/draft/items"', script)
        self.assertIn('if (event.key !== "Escape") return;', script)
        self.assertIn("activateCompanyReviewDraft", script)
        self.assertNotIn("window.confirm", script)
        self.assertIn("function finalizeDailyPriceReview", script)
        self.assertIn("function confirmDailyPriceReviewCancel", script)
        self.assertIn("const DAILY_PRICE_REVIEW_PAGE_SIZE = 50", script)
        self.assertIn('inputmode="numeric"', script)
        self.assertIn('placeholder="0"', script)
        self.assertIn("人工价格必须是非负整数，例如 0。", script)
        self.assertIn("return formatInteger(numeric);", script)
        self.assertNotIn('inputmode="decimal"', script)
        self.assertNotIn("人工价格必须是非负且恰好两位小数", script)
        i18n_script = (STATIC_ROOT / "i18n.js").read_text(encoding="utf-8")
        self.assertIn('DATABASE_WRITE_FAILED: "error.database_write_failed"', i18n_script)
        self.assertIn('api(`/api/jobs/${encodeURIComponent(jobId)}/review', script)
        self.assertIn("companyReportConfirmIsOpen", script)
        self.assertIn("confirmCompanyReportAction", script)
        self.assertIn("BOOKING_EXCEL_SOURCE_ALREADY_ACTIVATED", script)
        self.assertIn("requestError.code", script)
        self.assertIn("state.companyReportConfirmRequest", script)
        self.assertIn("company.publish_failed_detail", script)
        self.assertIn("company.publish_failed_summary_all", script)
        i18n = (STATIC_ROOT / "i18n.js").read_text(encoding="utf-8")
        self.assertIn("数据已处理，但正式 Excel 保存失败。请重启报表服务后再生成。", i18n)
        self.assertNotIn("重复后跳过", i18n)
        self.assertNotIn("重复后跳过", script)
        self.assertNotIn("历史任务未记录来源", script)
        self.assertNotIn("company-review-guidance", script)
        self.assertNotIn("company-review-description", script)
        self.assertNotIn("还有 ${formatInteger(pending)} 条待人工记录", script)
        self.assertNotIn("历史数据源已就绪", script)
        self.assertNotIn("Excel 已校验", script)
        self.assertNotIn('id="company-source-state"', desktop_text)
        self.assertNotIn('id="company-source-summary"', desktop_text)
        for required in (
            'id="company-source-current"',
            'id="company-source-current-filename"',
            'id="company-source-current-meta"',
            'id="company-source-status"',
        ):
            self.assertIn(required, desktop_text)
        self.assertNotIn('id="company-report-job-source"', desktop_text)
        self.assertNotIn("本次任务使用的 Excel", desktop_text)
        self.assertNotIn("任务使用的 Excel", desktop_text)
        self.assertNotIn("历史任务未记录来源", desktop_text)
        self.assertIn('String(summary.filename || "").trim()', script)
        self.assertIn('$("#company-review-filename").textContent = filename || "未记录文件名"', script)
        self.assertLess(
            desktop_text.index('id="company-review-title"'),
            desktop_text.index('id="company-review-filename"'),
        )
        self.assertNotIn("派发远端处理", script)
        self.assertNotIn("尚未开放：", script)
        self.assertNotIn("开放", desktop_text)
        self.assertNotIn("开放", script)
        self.assertIn("周期结束：", desktop_text)
        self.assertIn("周期未结束", script)
        self.assertIn("周期已结束", script)
        self.assertIn("当前已入库数据", script)
        self.assertIn("monthFuture", script)
        self.assertIn("!monthValid || monthFuture", script)
        self.assertIn(".company-period-state.is-complete", styles)
        self.assertIn(".company-period-state.is-in-progress", styles)
        self.assertNotIn("TASK FINISHED", script)
        self.assertNotIn("company-period-availability", desktop_text)
        self.assertIn('<span class="brand-name">ARR Report</span>', mobile_text)
        self.assertNotIn("<span aria-hidden=\"true\"><i></i><i></i></span>", mobile_text)
        self.assertIn("各公司本月销售情况", mobile_text)
        self.assertIn("泰国曼谷（UTC+7）", mobile_text)
        self.assertNotIn('id="generate-monthly"', desktop_text)
        self.assertNotIn('id="monthly-as-of"', desktop_text)
        self.assertNotIn('id="monthly-month"', desktop_text)
        self.assertNotIn('id="refresh-monthly"', desktop_text)
        self.assertNotIn('$("#generate-monthly")', script)
        self.assertNotIn('$("#refresh-monthly")', script)
        self.assertIn("const MONTHLY_POLL_INTERVAL = 4000", script)
        self.assertIn("const HISTORY_PAGE_SIZE = 50", script)
        self.assertIn("function renderPagination", script)
        self.assertIn("function changeHistoryPage", script)
        self.assertIn("page.total > previousTotal", script)
        self.assertIn("state.monthlyOffset = 0", script)
        self.assertNotIn("limit=100", script)
        self.assertIn("function scheduleMonthlyPoll", script)
        self.assertIn("loadMonthly(false, true)", script)
        self.assertIn("自动更新重试中", script)
        self.assertIn('api("/api/company-reports/source"', script)
        self.assertIn("handleCompanySourceUpload", script)
        self.assertNotIn("company-chip-list", desktop_text)
        self.assertIn(
            '<p class="company-fixed-companies">固定生成：LianTai、QBD、DY-AI-Easy-KB、FengRun、HanaTour</p>',
            desktop_text,
        )
        self.assertNotIn('class="company-scope"', desktop_text)
        self.assertNotIn("固定生成公司", desktop_text)
        self.assertIn('title="重新读取服务状态、Excel 来源和生成记录"', desktop_text)
        self.assertIn('title="选择 C/O 所属的生成月份"', desktop_text)
        self.assertIn('aria-label="生成月份"', desktop_text)
        self.assertIn('id="company-history-month"', desktop_text)
        self.assertNotIn("生成截至", desktop_text)
        self.assertEqual(desktop_text.count('<span>生成</span>'), 3)
        self.assertIn("生成时间", desktop_text)
        self.assertNotIn('data-label="版本"', script)
        self.assertIn("function uniqueCompanyProblems", script)
        self.assertIn("companyProblemsForResult(result)", script)
        self.assertIn("job.finished_at || job.created_at", script)
        for period_label in ("C/O:01-10", "C/O:11-20", "C/O:21-30"):
            self.assertIn(f">{period_label}</strong>", desktop_text)
        self.assertIn("function companyPeriodDisplayLabel(reportMonth, period)", script)
        self.assertIn('return monthEnd ? `C/O:21-${monthEnd}` : "C/O:21-30";', script)
        self.assertIn("companyPeriodDisplayLabel(reportMonth, period)", script)
        for removed_period_name in ("第一期", "第二期", "第三期"):
            self.assertNotIn(removed_period_name, desktop_text)
        self.assertNotIn('class="company-generator-section"', desktop_text)
        self.assertNotIn('class="company-source-section"', desktop_text)
        self.assertLess(
            desktop_text.index('id="company-report-month"'),
            desktop_text.index('id="company-excel-file"'),
        )

    def test_all_public_assets_exclude_removed_copy(self) -> None:
        content = "\n".join(
            path.read_text(encoding="utf-8")
            for path in STATIC_ROOT.iterdir()
            if path.suffix in {".html", ".css", ".js"}
        ).lower()
        for removed in (
            "codex",
            "本地报表处理",
            "渠道工作表",
            "所有渠道工作表",
            "room mix",
            "channel mix",
            "房型售卖排行",
            "下载后更新月报",
            "源记录",
            "白名单删除",
            "重复删除",
            "最终输出",
        ):
            self.assertNotIn(removed, content)
        self.assertIn("正在处理arr.xml文件", content)

    def test_read_apis_return_wrapped_database_data(self) -> None:
        jobs = decoded(
            self.app.handle("GET", "/api/jobs?month=2026-07", self.auth_headers)
        )
        trace = decoded(
            self.app.handle("GET", "/api/jobs/job-1/trace", self.auth_headers)
        )
        monthly = decoded(
            self.app.handle(
                "GET",
                "/api/monthly-runs?month=2026-07",
                self.auth_headers,
            )
        )
        history_months = decoded(
            self.app.handle("GET", "/api/history-months", self.auth_headers)
        )
        analytics = decoded(
            self.app.handle(
                "GET",
                "/api/analytics?month=2026-07",
                self.auth_headers,
            )
        )
        h5_months = decoded(
            self.app.handle("GET", "/api/h5/months", self.auth_headers)
        )
        compatible_analytics = decoded(
            self.app.handle(
                "GET",
                "/api/monthly/2026-07/analytics",
                self.auth_headers,
            )
        )
        self.assertEqual(jobs["data"][0]["job_id"], "job-1")
        self.assertEqual(
            history_months["data"],
            [
                {
                    "month_key": "2026-07",
                    "daily_count": 3,
                    "monthly_count": 2,
                    "company_count": 0,
                }
            ],
        )
        self.assertEqual(
            jobs["pagination"],
            {
                "total": 1,
                "limit": 50,
                "offset": 0,
                "has_previous": False,
                "has_next": False,
            },
        )
        self.assertEqual(trace["data"]["job"]["job_id"], "job-1")
        self.assertEqual(trace["data"]["logs"][0]["code"], "UPLOAD_REGISTERED")
        self.assertEqual(monthly["data"][0]["version_no"], 3)
        self.assertEqual(monthly["data"][0]["max_arrival_date"], "2026-07-26")
        self.assertEqual(monthly["pagination"]["total"], 1)
        self.assertEqual(monthly["pagination"]["limit"], 50)
        self.assertEqual(analytics["data"]["min_arrival_date"], "2026-07-01")
        self.assertEqual(analytics["data"]["channels"][0]["worksheet"], "COMPANY-A")
        self.assertEqual(
            h5_months,
            {
                "months": [
                    {
                        "month_key": "2026-07",
                        "updated_at": "2026-07-28T12:00:00+08:00",
                        "max_arrival_date": "2026-07-26",
                    }
                ]
            },
        )
        self.assertNotIn("ok", compatible_analytics)
        self.assertEqual(compatible_analytics["version"], "1.2")
        self.assertEqual(
            compatible_analytics["channels"][0]["worksheet"],
            "COMPANY-A",
        )

    def test_anonymous_h5_exposes_only_sanitized_read_only_dashboard(self) -> None:
        mobile = self.app.handle("GET", "/h5", {})
        mobile_css = self.app.handle("GET", "/assets/h5.css", {})
        mobile_js = self.app.handle("GET", "/assets/h5.js", {})
        months = decoded(self.app.handle("GET", "/api/public/h5/months", {}))
        analytics_response = self.app.handle(
            "GET",
            "/api/public/h5/analytics?month=2026-07",
            {},
        )
        analytics = decoded(analytics_response)
        protected_generic = self.app.handle(
            "GET",
            "/api/analytics?month=2026-07",
            {},
        )
        protected_legacy = self.app.handle("GET", "/api/h5/months", {})

        self.assertEqual(mobile.status, 200)
        self.assertEqual(mobile_css.status, 200)
        self.assertEqual(mobile_js.status, 200)
        self.assertEqual(months["data"][0]["month_key"], "2026-07")
        self.assertEqual(analytics_response.headers["Cache-Control"], "no-store")
        self.assertEqual(analytics["data"]["month_key"], "2026-07")
        self.assertIn("channels", analytics["data"])
        self.assertNotIn("source_monthly_sha256", analytics["data"])
        self.assertNotIn("filename", months["data"][0])
        self.assertEqual(protected_generic.status, 401)
        self.assertEqual(protected_legacy.status, 401)

    def test_health_exposes_only_programmatic_processing_readiness(self) -> None:
        payload = decoded(
            self.app.handle("GET", "/api/health", self.auth_headers)
        )["data"]
        self.assertTrue(payload["database_ready"])
        self.assertTrue(payload["processing_ready"])
        self.assertNotIn("agent_writeback_ready", payload)

    def test_legacy_h5_analytics_route_preserves_status_contract(self) -> None:
        invalid = self.app.handle(
            "GET",
            "/api/monthly/2026-13/analytics",
            self.auth_headers,
        )
        missing = self.app.handle(
            "GET",
            "/api/monthly/2026-06/analytics",
            self.auth_headers,
        )
        self.assertEqual(invalid.status, 422)
        self.assertEqual(decoded(invalid)["error"]["code"], "MONTH_KEY_INVALID")
        self.assertEqual(missing.status, 404)
        self.assertEqual(decoded(missing)["error"]["code"], "MONTHLY_NOT_FOUND")

    def test_upload_requires_csrf_and_accepts_raw_xml_only(self) -> None:
        encoded = base64.urlsafe_b64encode("到店明细.XML".encode("utf-8")).decode("ascii").rstrip("=")
        no_csrf = self.app.handle(
            "POST",
            "/api/jobs",
            {
                "Cookie": self.auth_headers["Cookie"],
                "X-ARR-Filename-B64": encoded,
            },
            b"<root />",
        )
        self.assertEqual(no_csrf.status, 403)
        response = self.app.handle(
            "POST",
            "/api/jobs",
            {**self.session_headers(), "X-ARR-Filename-B64": encoded},
            b"\xef\xbb\xbf  <root />",
        )
        self.assertEqual(response.status, 202)
        self.assertEqual(self.upload.calls, [("到店明细.XML", b"\xef\xbb\xbf  <root />")])

    def test_upload_rejects_non_xml_bytes_before_coordinator(self) -> None:
        encoded = base64.urlsafe_b64encode(b"report.xml").decode("ascii").rstrip("=")
        response = self.app.handle(
            "POST",
            "/api/jobs",
            {**self.session_headers(), "X-ARR-Filename-B64": encoded},
            b"not xml",
        )
        self.assertEqual(response.status, 400)
        self.assertEqual(decoded(response)["error"]["code"], "UPLOAD_CONTENT_INVALID")
        self.assertEqual(self.upload.calls, [])

    def test_price_review_routes_are_authenticated_csrf_protected_and_strict(self) -> None:
        unauthenticated = self.app.handle("GET", "/api/jobs/job-1/review?limit=5&offset=2", {})
        self.assertEqual(unauthenticated.status, 401)

        review = self.app.handle(
            "GET", "/api/jobs/job-1/review?limit=5&offset=2", self.auth_headers
        )
        self.assertEqual(review.status, 200)
        payload = decoded(review)["data"]
        self.assertEqual(payload["case_id"], FakeDailyPriceReviews.case_id)
        self.assertEqual(self.price_reviews.read_calls, [("job-1", 5, 2)])
        rendered = json.dumps(payload, ensure_ascii=False)
        for private_value in ("SYNTHETIC GUEST", "RES_COMMENT", "TRACE_TEXT"):
            self.assertNotIn(private_value, rendered)

        missing_csrf = self.app.handle(
            "PATCH",
            "/api/jobs/job-1/review/items/9",
            {"Cookie": self.auth_headers["Cookie"], "Content-Type": "application/json"},
            json.dumps({"case_id": FakeDailyPriceReviews.case_id, "revision": 3, "real_price": "0"}).encode(),
        )
        self.assertEqual(missing_csrf.status, 403)

        malformed = self.app.handle(
            "PATCH",
            "/api/jobs/job-1/review/items/9",
            {**self.session_headers(), "Content-Type": "application/json"},
            json.dumps({"case_id": FakeDailyPriceReviews.case_id, "revision": 3, "real_price": "0.00"}).encode(),
        )
        self.assertEqual(malformed.status, 400)
        self.assertEqual(decoded(malformed)["error"]["code"], "REVIEW_REQUEST_INVALID")

        updated = self.app.handle(
            "PATCH",
            "/api/jobs/job-1/review/items/9",
            {**self.session_headers(), "Content-Type": "application/json"},
            json.dumps({"case_id": FakeDailyPriceReviews.case_id, "revision": 3, "real_price": "0"}).encode(),
        )
        self.assertEqual(updated.status, 200)
        self.assertEqual(
            self.price_reviews.update_calls,
            [("job-1", 9, FakeDailyPriceReviews.case_id, 3, "0", TEST_CREDENTIALS.username)],
        )

        finalized = self.app.handle(
            "POST",
            "/api/jobs/job-1/review/finalize",
            {**self.session_headers(), "Content-Type": "application/json"},
            json.dumps({"case_id": FakeDailyPriceReviews.case_id, "revision": 3}).encode(),
        )
        cancelled = self.app.handle(
            "POST",
            "/api/jobs/job-1/review/cancel",
            {**self.session_headers(), "Content-Type": "application/json"},
            json.dumps({"case_id": FakeDailyPriceReviews.case_id, "revision": 3}).encode(),
        )
        self.assertEqual(finalized.status, 200)
        self.assertEqual(cancelled.status, 200)
        self.assertEqual(
            self.price_reviews.finalize_calls,
            [("job-1", FakeDailyPriceReviews.case_id, 3, TEST_CREDENTIALS.username)],
        )
        self.assertEqual(
            self.price_reviews.cancel_calls,
            [("job-1", FakeDailyPriceReviews.case_id, 3, TEST_CREDENTIALS.username)],
        )

    def test_monthly_generation_is_csrf_protected_and_typed(self) -> None:
        response = self.app.handle(
            "POST",
            "/api/monthly-runs",
            {**self.session_headers(), "Content-Type": "application/json"},
            b'{"month":"2026-07","as_of_date":"2026-07-26"}',
        )
        self.assertEqual(response.status, 202)
        self.assertEqual(self.monthly.calls, [("2026-07", date(2026, 7, 26))])

    def test_agent_result_callback_is_not_exposed_in_programmatic_runtime(self) -> None:
        response = self.app.handle(
            "POST",
            "/api/integrations/super-agent/results",
            {
                **self.session_headers(),
                "Content-Type": "application/json; charset=utf-8",
            },
            b"{}",
        )
        self.assertEqual(response.status, 404)
        self.assertEqual(decoded(response)["error"]["code"], "ROUTE_NOT_FOUND")

    def test_download_uses_internal_descriptor_without_exposing_storage_key(self) -> None:
        app = PortalApplication(
            repository=FakeRepository(),
            artifact_reader=FakeArtifactReader(),
            health=RuntimeHealth(True, True, True, True),
            credentials=TEST_CREDENTIALS,
        )
        _, headers = login(app)
        response = app.handle(
            "GET",
            "/api/download/monthly?report_id=3",
            headers,
        )
        self.assertEqual(response.status, 200)
        self.assertEqual(response.body, b"xlsx")
        self.assertIn("filename*=UTF-8''", response.headers["Content-Disposition"])
        self.assertEqual(response.headers["Cache-Control"], "private, no-store")
        self.assertNotIn("private/monthly.xlsx", str(response.headers))

    def test_query_validation_fails_without_repository_call(self) -> None:
        response = self.app.handle(
            "GET",
            "/api/jobs?month=2026-13",
            self.auth_headers,
        )
        self.assertEqual(response.status, 400)
        self.assertEqual(decoded(response)["error"]["code"], "MONTH_INVALID")

        invalid_job = self.app.handle(
            "GET",
            "/api/jobs/not%2Fsafe/trace",
            self.auth_headers,
        )
        self.assertEqual(invalid_job.status, 400)
        self.assertEqual(decoded(invalid_job)["error"]["code"], "JOB_ID_INVALID")

        invalid_offset = self.app.handle(
            "GET",
            "/api/monthly-runs?month=2026-07&offset=-1",
            self.auth_headers,
        )
        self.assertEqual(invalid_offset.status, 400)
        self.assertEqual(decoded(invalid_offset)["error"]["code"], "QUERY_INVALID")

    def test_history_routes_forward_limit_and_offset_with_exact_totals(self) -> None:
        class PagingRepository(FakeRepository):
            def __init__(self) -> None:
                super().__init__()
                self.job_request: tuple[str, int, int] | None = None
                self.monthly_request: tuple[str, int, int] | None = None

            def list_jobs(
                self,
                month_key: str,
                limit: int = 50,
                offset: int = 0,
            ) -> tuple[List[Dict[str, Any]], int]:
                self.job_request = (month_key, limit, offset)
                return [{"job_id": "job-51"}], 73

            def list_monthly_runs(
                self,
                month_key: str,
                limit: int = 50,
                offset: int = 0,
            ) -> tuple[List[Dict[str, Any]], int]:
                self.monthly_request = (month_key, limit, offset)
                return [{"report_id": 51}], 101

        repository = PagingRepository()
        app = PortalApplication(
            repository=repository,
            credentials=TEST_CREDENTIALS,
        )
        _, headers = login(app)
        jobs = decoded(
            app.handle(
                "GET",
                "/api/jobs?month=2026-07&limit=50&offset=50",
                headers,
            )
        )
        monthly = decoded(
            app.handle(
                "GET",
                "/api/monthly-runs?month=2026-07&limit=50&offset=50",
                headers,
            )
        )

        self.assertEqual(repository.job_request, ("2026-07", 50, 50))
        self.assertEqual(repository.monthly_request, ("2026-07", 50, 50))
        self.assertEqual(jobs["pagination"]["total"], 73)
        self.assertTrue(jobs["pagination"]["has_previous"])
        self.assertTrue(jobs["pagination"]["has_next"])
        self.assertEqual(monthly["pagination"]["total"], 101)
        self.assertTrue(monthly["pagination"]["has_previous"])
        self.assertTrue(monthly["pagination"]["has_next"])


class UnavailableDataTests(unittest.TestCase):
    def test_repository_failures_are_privacy_safe_503(self) -> None:
        class Broken(FakeRepository):
            def list_jobs(
                self,
                month_key: str,
                limit: int = 50,
                offset: int = 0,
            ) -> tuple[List[Dict[str, Any]], int]:
                raise PortalDataError("DATABASE_UNAVAILABLE", "数据库连接失败")

        app = PortalApplication(
            repository=Broken(),
            credentials=TEST_CREDENTIALS,
        )
        _, headers = login(app)
        response = app.handle(
            "GET",
            "/api/jobs?month=2026-07",
            headers,
        )
        self.assertEqual(response.status, 503)
        payload = decoded(response)
        self.assertEqual(payload["error"]["message"], "数据库连接失败")
        self.assertNotIn("password", response.body.decode("utf-8").lower())


class ControlledDownloadTests(unittest.TestCase):
    def test_reader_rehashes_controlled_file(self) -> None:
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            path = root / "outputs" / "report.xlsx"
            path.parent.mkdir()
            path.write_bytes(b"trusted-xlsx")
            payload = path.read_bytes()
            descriptor = ArtifactDescriptor(
                "monthly_xlsx",
                "月报.xlsx",
                "outputs/report.xlsx",
                hashlib.sha256(payload).hexdigest(),
                len(payload),
                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            )
            self.assertEqual(ControlledProjectArtifactReader(root).read(descriptor), payload)

    def test_reader_rejects_hash_mismatch_and_parent_escape(self) -> None:
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            path = root / "report.xlsx"
            path.write_bytes(b"changed")
            bad_hash = ArtifactDescriptor(
                "daily_xlsx",
                "daily.xlsx",
                "report.xlsx",
                "0" * 64,
                len(b"changed"),
                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            )
            with self.assertRaisesRegex(Exception, "完整性"):
                ControlledProjectArtifactReader(root).read(bad_hash)
            escaped = ArtifactDescriptor(
                "daily_xlsx",
                "daily.xlsx",
                "../report.xlsx",
                "0" * 64,
                1,
                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            )
            with self.assertRaisesRegex(Exception, "文件身份"):
                ControlledProjectArtifactReader(root).read(escaped)

    def test_daily_download_reads_and_reverifies_committed_object(self) -> None:
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            source = root / "7.27.xlsx"
            source.write_bytes(b"trusted-daily-xlsx")
            store = ManagedObjectStore(
                FilesystemObjectBackend(root / "objects", create=True)
            )
            stored = store.upload_committed(
                job_id="arrjob-download-001",
                attempt_no=1,
                role="daily_report",
                source=source,
                original_filename=source.name,
            )
            descriptor = ArtifactDescriptor(
                "daily_xlsx",
                source.name,
                stored.object_key,
                stored.sha256,
                stored.byte_size,
                stored.mime_type,
            )

            reader = RoutedArtifactReader(
                daily_reader=ManagedObjectArtifactReader(store),
                local_reader=ControlledProjectArtifactReader(root),
            )
            self.assertEqual(reader.read(descriptor), source.read_bytes())

            bad = ArtifactDescriptor(
                descriptor.file_kind,
                descriptor.original_filename,
                descriptor.storage_key,
                "0" * 64,
                descriptor.byte_size,
                descriptor.mime_type,
            )
            with self.assertRaisesRegex(Exception, "完整性"):
                reader.read(bad)


class BookingHeaderI18nContractTests(unittest.TestCase):
    def test_booking_header_errors_have_three_locale_messages(self) -> None:
        script = (STATIC_ROOT / "i18n.js").read_text(encoding="utf-8")
        expected = {
            "BOOKING_EXCEL_HEADERS_MISSING": "error.booking_headers_missing",
            "BOOKING_EXCEL_HEADERS_AMBIGUOUS": "error.booking_headers_ambiguous",
        }
        for code, key in expected.items():
            self.assertIn(f'{code}: "{key}"', script)
            match = re.search(
                rf'^\s*"{re.escape(key)}": (\[.*\]),$',
                script,
                re.MULTILINE,
            )
            self.assertIsNotNone(match, key)
            values = json.loads(match.group(1))
            self.assertEqual(len(values), 3, key)
            self.assertTrue(all(values), key)
            self.assertRegex(values[2], r"[\u0e00-\u0e7f]", key)


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