from __future__ import annotations

import csv
import json
import re
import zipfile
from collections import Counter
from pathlib import Path

from pypdf import PdfReader


ROOT = Path(__file__).resolve().parent
errors: list[str] = []
warnings: list[str] = []
checks = 0


def require(condition: bool, message: str) -> None:
    global checks
    checks += 1
    if not condition:
        errors.append(message)


def require_any(text: str, alternatives: list[str], label: str) -> None:
    require(
        any(phrase in text for phrase in alternatives),
        f"missing disclosure: {label}",
    )


def read_csv(path: Path) -> list[dict[str, str]]:
    with path.open("r", encoding="utf-8-sig", newline="") as handle:
        return list(csv.DictReader(handle))


def find_column(rows: list[dict[str, str]], candidates: list[str]) -> str | None:
    if not rows:
        return None
    fields = set(rows[0])
    return next((field for field in candidates if field in fields), None)


required_files = [
    "analysis-results.json",
    "analysis.py",
    "build_artifacts.py",
    "case-audit.csv",
    "paper-content.json",
    "paper-metadata.json",
    "paper.md",
    "paper.pdf",
    "source-log.csv",
    "synthetic-assumptions.json",
    "README.md",
    "RIGHTS-NOTICE.txt",
    "LICENSE.txt",
    "CITATION.cff",
]
for filename in required_files:
    path = ROOT / filename
    require(path.exists() and path.stat().st_size > 0, f"missing file: {filename}")

meta = json.loads((ROOT / "paper-metadata.json").read_text(encoding="utf-8"))
content = json.loads((ROOT / "paper-content.json").read_text(encoding="utf-8"))
results = json.loads((ROOT / "analysis-results.json").read_text(encoding="utf-8"))
assumptions = json.loads(
    (ROOT / "synthetic-assumptions.json").read_text(encoding="utf-8")
)
cases = read_csv(ROOT / "case-audit.csv")
source_log = read_csv(ROOT / "source-log.csv")

expected_doi = "10.5281/zenodo.21523281"
require(
    meta["reportNumber"] == content["reportNumber"] == "SHWRP-2026-025",
    "report number mismatch",
)
require(
    meta["doi"] == content["doi"] == expected_doi,
    "DOI mismatch",
)
require(
    meta["zenodoUrl"] == content["zenodoUrl"] == "https://zenodo.org/records/21523281",
    "Zenodo URL mismatch",
)
require(meta["version"] == content["version"] == "1.0", "version mismatch")
require(
    meta["datePublished"] == content["date"] == "2026-07-24",
    "publication date mismatch",
)
require(
    meta["dateModified"] == content["revisionDate"] == "2026-07-24",
    "revision date mismatch",
)
require(
    meta["licenseUrl"] == "https://creativecommons.org/licenses/by/4.0/",
    "license URL mismatch",
)
require(
    meta["author"]["orcid"] == "0009-0000-7018-5096",
    "ORCID mismatch",
)
require(
    meta.get("sample", {}).get("caseCount") == 60
    and meta.get("sample", {}).get("minimumOrderObserved") == 59
    and meta.get("sample", {}).get("advanceOrderObserved") == 58
    and meta.get("sample", {}).get("deliveryActorObserved") == 0,
    "metadata sample summary is stale or inconsistent",
)
require(len(content["abstractZh"]) >= 600, "Chinese abstract is too short")
require(len(content["abstractEn"]) >= 700, "English abstract is too short")
require(len(content["keywords"]) >= 6, "too few keywords")
require(len(content["sections"]) >= 9, "too few numbered paper sections")
require(len(content["references"]) >= 10, "too few references")
require(
    sum(1 for _, url in content["references"] if url.startswith("https://")) >= 10,
    "too few directly linked references",
)

require(
    results["report_number"] == "SHWRP-2026-025",
    "analysis report number mismatch",
)
require(results["case_count"] == len(cases) == 60, "case count mismatch")
require(
    results["unique_source_url_count"]
    == len({row["source_url"] for row in cases})
    == 4,
    "unique case-source URL count mismatch",
)
require(
    results["source_type_counts"]
    == {
        "current_government_directory": 30,
        "archival_government_directory": 28,
        "first_party_live_page": 2,
    },
    "source-stratum counts changed",
)
require(
    Counter(row["source_type"] for row in cases)
    == Counter(results["source_type_counts"]),
    "case CSV source strata do not match analysis results",
)
require(
    results["rule_observed_counts"]
    == {
        "fee_amount_observed": 0,
        "minimum_order_observed": 59,
        "distance_rule_observed": 44,
        "free_delivery_observed": 3,
        "advance_order_observed": 58,
        "group_order_observed": 58,
        "delivery_actor_observed": 0,
    },
    "rule-observability counts changed",
)
for field, expected in results["rule_observed_counts"].items():
    require(
        sum(int(row[field]) for row in cases) == expected,
        f"case CSV count mismatch: {field}",
    )
require(
    results["delivery_actor_observed_count"] == 0,
    "delivery actor must remain unobserved in this sample",
)
require(
    results["route_scenario_count"] == 36,
    "route scenario count changed",
)
require(
    results["policy_scenario_count"] == 54,
    "policy scenario count changed",
)
require(
    results["threshold_scenario_count"] == 20,
    "threshold scenario count changed",
)
require(
    results["membership_scenario_count"] == 27,
    "membership scenario count changed",
)
require(
    results["mid_route_single_box_cost_per_box"] == 85.3,
    "single-box synthetic result changed",
)
require(
    results["mid_route_ten_box_cost_per_box"] == 8.53,
    "ten-box synthetic result changed",
)
require(
    results["mid_route_grouping_reduction_percent"] == 90.0,
    "grouping arithmetic changed",
)
require(
    results["synthetic_results_are_market_estimates"] is False,
    "synthetic results must not be represented as market estimates",
)
require(
    results["second_independent_human_coder"] is False,
    "second human coder disclosure changed",
)
require(results["figures"] == 6, "figure count changed")
require(
    assumptions["scope"].startswith("Synthetic assumptions"),
    "synthetic assumption scope disclosure missing",
)

case_ids = [row["case_id"] for row in cases]
require(len(set(case_ids)) == 60, "case IDs must be unique")
require(
    case_ids == [f"DL{number:03d}" for number in range(1, 61)],
    "case IDs are not the expected DL001-DL060 sequence",
)
require(
    all(re.fullmatch(r"\d{4}-\d{2}-\d{2}", row["capture_date"]) for row in cases),
    "invalid case capture date",
)
require(
    all(row["source_url"].startswith("https://") for row in cases),
    "case source URL must use HTTPS",
)

case_columns = {column.lower() for column in cases[0]}
for forbidden in [
    "customer_name",
    "contact",
    "contact_name",
    "operator_name",
    "business_name",
    "phone",
    "telephone",
    "mobile",
    "email",
    "address",
    "precise_latitude",
    "precise_longitude",
    "latitude",
    "longitude",
]:
    require(
        forbidden not in case_columns,
        f"personal or precise-location field in public case data: {forbidden}",
    )

case_text = "\n".join(
    value for row in cases for value in row.values() if value is not None
)
require(
    re.search(r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}", case_text) is None,
    "email-like value detected in public case data",
)
require(
    re.search(
        r"(?<!\d)(?:\+?886[-\s]?)?0\d{1,2}[-\s]?\d{6,8}(?!\d)",
        case_text,
    )
    is None,
    "phone-like value detected in public case data",
)

expected_outputs = {
    "rule-observability.csv": 7,
    "source-strata.csv": 3,
    "route-cost-scenarios.csv": 36,
    "free-delivery-threshold-screening.csv": 20,
    "membership-break-even.csv": 27,
    "policy-scenarios.csv": 54,
    "cost-sensitivity.csv": 5,
}
for filename, expected_rows in expected_outputs.items():
    path = ROOT / "outputs" / filename
    require(path.exists() and path.stat().st_size > 100, f"missing output: {filename}")
    if path.exists():
        require(
            len(read_csv(path)) == expected_rows,
            f"unexpected row count: {filename}",
        )

figure_files: list[Path] = []
for number in range(1, 7):
    matches = list((ROOT / "outputs" / "figures").glob(f"figure-{number}-*.png"))
    require(
        len(matches) == 1 and matches[0].stat().st_size > 20_000,
        f"figure {number} missing or too small",
    )
    figure_files.extend(matches)
require(len(figure_files) == 6, "expected exactly six numbered figures")

source_url_field = find_column(source_log, ["source_url", "url"])
source_date_field = find_column(
    source_log,
    ["capture_date", "captured_date", "accessed_date"],
)
source_rights_field = find_column(
    source_log,
    ["license_or_terms", "rights_or_terms", "original_license_or_terms"],
)
source_redistribution_field = find_column(
    source_log,
    [
        "redistributed",
        "raw_redistributed",
        "raw_source_redistributed",
        "site_redistributes_raw",
    ],
)
require(len(source_log) >= 14, "source log should cover case, official and academic sources")
require(source_url_field is not None, "source log has no URL field")
require(source_date_field is not None, "source log has no capture/access date field")
require(source_rights_field is not None, "source log has no rights/terms field")
require(
    source_redistribution_field is not None,
    "source log has no redistribution field",
)
if source_url_field:
    require(
        all(row[source_url_field].startswith("https://") for row in source_log),
        "source-log URL must use HTTPS",
    )
if source_date_field:
    require(
        all(
            re.fullmatch(r"\d{4}-\d{2}-\d{2}", row[source_date_field])
            for row in source_log
        ),
        "invalid source-log capture/access date",
    )
if source_rights_field:
    require(
        all(row[source_rights_field].strip() for row in source_log),
        "source-log rights/terms value missing",
    )
if source_redistribution_field:
    require(
        all(
            row[source_redistribution_field].strip().lower()
            in {"no", "否", "false", "0"}
            for row in source_log
        ),
        "source log must not mark third-party raw data for redistribution",
    )

paper_text = (ROOT / "paper.md").read_text(encoding="utf-8")
require_any(
    paper_text,
    ["目的性選取", "目的抽樣"],
    "purposeful sample",
)
require_any(
    paper_text,
    [
        "不能推論為市場普遍比例",
        "不能估計市場比例",
        "不代表市場普遍",
        "不能估計臺灣市場採用率",
        "不能比較各規則在全臺市場的普及程度",
    ],
    "sample is not market prevalence",
)
require_any(
    paper_text,
    [
        "自架訂餐系統不等於自有配送車隊",
        "自架訂餐不等於自有配送",
        "若把自架網站等同自有運力",
        "自架網站等同自有運力",
    ],
    "ordering system is not own-fleet evidence",
)
require_any(
    paper_text,
    ["合成情境", "研究者設定之情境"],
    "synthetic scenarios",
)
require_any(
    paper_text,
    ["不是市場平均", "不代表市場平均"],
    "not a market average",
)
require_any(
    paper_text,
    ["不是正式報價", "不構成正式報價"],
    "not a formal quotation",
)
require_any(
    paper_text,
    [
        "不構成同業共同定價",
        "不是同業共同定價",
        "不得作為同業協議價格",
        "不是邀請同業共同採用",
    ],
    "no coordinated pricing",
)
require_any(
    paper_text,
    [
        "未使用真實交易資料",
        "沒有真實交易資料",
        "不含真實交易資料",
        "本文沒有店家 POS",
    ],
    "no transaction data",
)
require_any(
    paper_text,
    ["不作因果推論", "不能解釋因果", "不是因果效果", "不能識別因果效果"],
    "no causal inference",
)
require_any(
    paper_text,
    ["沒有第二位獨立人工編碼者", "未設置第二位獨立人工編碼者", "沒有第二位獨立人類編碼者", "不構成第二位獨立人類編碼者"],
    "no second independent human coder",
)
require_any(
    paper_text,
    ["未經外部同儕審查", "未經同儕審查"],
    "not externally peer reviewed",
)
require_any(
    paper_text,
    ["AI 不列為作者", "AI不列為作者"],
    "AI is not an author",
)
require_any(
    paper_text,
    ["CC BY 4.0僅適用於作者有權授權", "CC BY 4.0 僅適用於作者有權授權"],
    "CC BY rights boundary",
)
require_any(
    paper_text,
    ["第三方名稱、商標、網址、資料", "第三方內容仍依原權利"],
    "third-party rights",
)
require_any(
    paper_text,
    ["申請更正", "更正申請"],
    "correction channel",
)
require_any(
    paper_text,
    ["不構成法律建議", "不是法律意見", "法律或交易建議"],
    "not legal advice",
)
require_any(
    paper_text,
    ["外送員權益保障及外送平臺管理法"],
    "current delivery-platform law context",
)

pdf_path = ROOT / "paper.pdf"
pdf_page_count = 0
pdf_text = ""
embedded_image_count = 0
require(pdf_path.exists() and pdf_path.stat().st_size > 250_000, "PDF missing or too small")
if pdf_path.exists():
    reader = PdfReader(str(pdf_path))
    pdf_page_count = len(reader.pages)
    pdf_text = "\n".join((page.extract_text() or "") for page in reader.pages)
    try:
        embedded_image_count = sum(len(page.images) for page in reader.pages)
    except Exception:
        embedded_image_count = 0
        warnings.append("PDF image objects could not be counted with the installed pypdf")
    require(pdf_page_count >= 12, "PDF is unexpectedly short")
    if pdf_page_count < 14:
        warnings.append(
            f"PDF has {pdf_page_count} pages; 14 or more is preferred when content warrants it"
        )
    require(len(pdf_text) > 12_000, "PDF searchable text layer is too short")
    for phrase in [
        "SHWRP-2026-025",
        expected_doi,
        "一份便當的最後一公里值多少",
        "外送費",
        "配送密度",
        "合成情境",
        "未經外部同儕審查",
        "CC BY 4.0",
    ]:
        require(phrase in pdf_text, f"PDF missing searchable phrase: {phrase}")
    if embedded_image_count:
        require(
            embedded_image_count >= 6,
            "PDF does not contain all six figure images",
        )
    require(
        not any(
            dash in pdf_text
            for dash in ["\u2010", "\u2011", "\u2012", "\u2013", "\u2014", "\u2015", "\u2212"]
        ),
        "PDF contains a non-ASCII dash glyph",
    )

public_risk_names = [
    "full_html",
    "raw_html",
    "page_text",
    "full_description",
    "review_text",
    "customer_name",
    "phone",
    "precise_latitude",
    "precise_longitude",
    "screenshot",
    "image_binary",
]
public_risk_extensions = {".html", ".htm", ".ods", ".xls", ".xlsx"}
for path in ROOT.rglob("*"):
    if not path.is_file():
        continue
    relative = path.relative_to(ROOT)
    require(
        not any(term in path.name.lower() for term in public_risk_names),
        f"public-risk filename: {relative}",
    )
    require(
        path.suffix.lower() not in public_risk_extensions,
        f"raw or page-capture file must not be public: {relative}",
    )

zip_paths = list(ROOT.glob("SHWRP-2026-025-v*.zip"))
require(len(zip_paths) <= 1, "multiple release ZIP files found")
if zip_paths:
    with zipfile.ZipFile(zip_paths[0]) as archive:
        corrupt_member = archive.testzip()
        zip_names = archive.namelist()
    require(corrupt_member is None, f"ZIP CRC error: {corrupt_member}")
    require(
        any(name.endswith("paper.pdf") for name in zip_names),
        "ZIP is missing paper.pdf",
    )
    require(
        any(name.endswith("case-audit.csv") for name in zip_names),
        "ZIP is missing the public case-audit data",
    )
    require(
        any(name.endswith("source-log.csv") for name in zip_names),
        "ZIP is missing source log",
    )
    require(
        not any(zip_paths[0].name in name for name in zip_names),
        "ZIP recursively contains itself",
    )
    for name in zip_names:
        lowered = Path(name).name.lower()
        require(
            not any(term in lowered for term in public_risk_names),
            f"public-risk ZIP member: {name}",
        )
        require(
            Path(name).suffix.lower() not in public_risk_extensions,
            f"raw or page-capture ZIP member: {name}",
        )

report = {
    "status": "pass" if not errors else "fail",
    "errors": errors,
    "warnings": warnings,
    "checks": checks,
    "pdf_pages": pdf_page_count,
    "pdf_embedded_images": embedded_image_count,
    "cases": len(cases),
    "doi": meta["doi"],
}
(ROOT / "validation-report.json").write_text(
    json.dumps(report, ensure_ascii=False, indent=2),
    encoding="utf-8",
)
print(json.dumps(report, ensure_ascii=False))
if errors:
    raise SystemExit(1)
