from __future__ import annotations

import csv
import hashlib
import json
import re
import sys
from datetime import date, datetime
from pathlib import Path
from zoneinfo import ZoneInfo

import jsonschema
import yaml
from pypdf import PdfReader


ROOT = Path(__file__).resolve().parent
REPORT = ROOT / "validation-report.json"
TAIPEI = ZoneInfo("Asia/Taipei")
FONT_SHA256 = "40452f54ea36b5c1f1ec8bd92dc18d5cd19fafb73b8abf9c48500a00d09ec762"
FONT_LICENSE_SHA256 = "1c05c68c34f9708415aada51f17e1b0092d2cea709bf4a94cd38114f9e73d7d9"
CFF_SCHEMA_SHA256 = "0b8d22140da702d766df318dcff3a91af2f39521298dcf36d76315fd99cc169b"
ZENODO_RE = re.compile(r"10\.5281/zenodo\.\d+")
BAD_DASHES = "\u2010\u2011\u2012\u2013\u2014\u2015\u2212"

REQUIRED = [
    "README.md", "paper.md", "paper.pdf", "paper-content.json", "paper-metadata.json",
    "CITATION.cff", "LICENSE.txt", "RIGHTS-NOTICE.txt", "source-registry.csv",
    "source-log.csv", "source-evidence.json", "codebook.csv", "cleaning-rules.md",
    "analysis-plan.md", "protocol.md", "flight-checklist.md", "aircraft-comparison.csv",
    "mvp-protocol.csv", "decision-thresholds.csv", "cost-model-template.csv",
    "data-dictionary.csv", "field-form.csv", "requirements.txt", "analysis.py",
    "build_artifacts.py", "validate.py", "verify_reproducibility.py", "package_release.py",
    "validate_package.py", "pdf-visual-qa.json", "reproducibility-check.json",
    "assets/fonts/NotoSansTC-Regular.ttf", "assets/fonts/OFL.txt",
    "assets/schemas/cff-1.2.0-schema.json", "outputs/analysis-results.json",
    "outputs/protocol-workload.csv", "outputs/model-capability-matrix.csv",
    "outputs/normalized-cost-sensitivity.csv",
    "outputs/figures/figure-1-mvp-architecture.png",
    "outputs/figures/figure-2-aircraft-fit-matrix.png",
    "outputs/figures/figure-3-sampling-design.png",
    "outputs/figures/figure-4-evidence-ladder.png",
    "outputs/figures/figure-5-protocol-workload.png",
    "outputs/figures/figure-6-normalized-cost-sensitivity.png",
]


def now_taipei() -> str:
    return datetime.now(TAIPEI).replace(microsecond=0).isoformat()


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def rows(relative: str) -> list[dict[str, str]]:
    with (ROOT / relative).open("r", encoding="utf-8-sig", newline="") as stream:
        return list(csv.DictReader(stream))


def normalize_yaml(value: object) -> object:
    if isinstance(value, (date, datetime)):
        return value.isoformat()
    if isinstance(value, list):
        return [normalize_yaml(item) for item in value]
    if isinstance(value, dict):
        return {str(key): normalize_yaml(item) for key, item in value.items()}
    return value


def parse_time(value: object, label: str, errors: list[str]) -> None:
    if not isinstance(value, str):
        errors.append(f"{label} must be an ISO-8601 string")
        return
    try:
        parsed = datetime.fromisoformat(value)
        if parsed.tzinfo is None:
            errors.append(f"{label} must include a timezone")
    except ValueError:
        errors.append(f"{label} is not valid ISO-8601")


def scan_candidate_dois(paths: list[Path]) -> dict[str, list[str]]:
    hits: dict[str, list[str]] = {}
    for path in paths:
        if not path.is_file() or path.suffix.lower() in {".pdf", ".png", ".ttf", ".zip"}:
            continue
        if path.as_posix().endswith("assets/schemas/cff-1.2.0-schema.json"):
            continue
        try:
            text = path.read_text(encoding="utf-8")
        except (UnicodeDecodeError, OSError):
            continue
        found = sorted(set(ZENODO_RE.findall(text)))
        if found:
            hits[path.relative_to(ROOT).as_posix()] = found
    return hits


def main() -> None:
    errors: list[str] = []
    checks: dict[str, object] = {}
    for relative in REQUIRED:
        path = ROOT / relative
        if not path.is_file() or path.stat().st_size == 0:
            errors.append(f"Missing or empty required file: {relative}")
    if errors:
        result = {"schemaVersion": 2, "status": "FAILED", "validatedAt": now_taipei(), "errors": errors, "checks": checks}
        REPORT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        print(json.dumps({"status": "FAILED", "errors": len(errors)}, ensure_ascii=False))
        raise SystemExit(1)

    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 / "outputs" / "analysis-results.json").read_text(encoding="utf-8"))
    paper = (ROOT / "paper.md").read_text(encoding="utf-8")
    governance = "\n".join((ROOT / name).read_text(encoding="utf-8") for name in ["README.md", "RIGHTS-NOTICE.txt", "cleaning-rules.md", "analysis-plan.md", "protocol.md"])

    expected_meta = {
        "reportNumber": "SHWRP-2026-031",
        "slug": "dji-agricultural-inspection-mvp",
        "version": "1.0",
        "datePublished": "2026-08-03",
        "dateModified": "2026-08-03",
        "peerReviewed": False,
        "fieldDataCollected": False,
    }
    for key, expected in expected_meta.items():
        if meta.get(key) != expected:
            errors.append(f"paper-metadata.json {key}={meta.get(key)!r}, expected {expected!r}")
    if meta.get("author") != {"name": "周秉漢", "nameEnglish": "CHOU, BING-HAN", "orcid": "0009-0000-7018-5096", "email": "handson0102@hotmail.com"}:
        errors.append("Author identity is inconsistent")
    if meta.get("publicContactConsent") is not True:
        errors.append("Public contact consent is not explicit")
    if meta.get("licenseUrl") != "https://creativecommons.org/licenses/by/4.0/" or "作者有權授權" not in meta.get("licenseScope", ""):
        errors.append("CC BY 4.0 scope does not separate third-party rights")
    if not isinstance(meta.get("revisionNote"), str) or "未使用虛構田間結果" not in meta["revisionNote"]:
        errors.append("Revision note must state that no fabricated field results were used")

    doi = meta.get("doi")
    zenodo = meta.get("zenodoUrl")
    if doi is None:
        if zenodo is not None:
            errors.append("zenodoUrl must be null while DOI is null")
    elif not isinstance(doi, str) or not re.fullmatch(r"10\.5281/zenodo\.\d+", doi):
        errors.append("DOI is not a valid Zenodo version DOI")
    elif not isinstance(zenodo, str) or not re.fullmatch(r"https://zenodo\.org/records/\d+", zenodo):
        errors.append("zenodoUrl is missing or malformed")

    mapping = {
        "reportNumber": "reportNumber", "version": "version", "doi": "doi", "zenodoUrl": "zenodoUrl",
        "title": "titleZh", "titleEnglish": "titleEn", "datePublished": "date",
        "dateModified": "revisionDate",
    }
    for source_key, content_key in mapping.items():
        if meta.get(source_key) != content.get(content_key):
            errors.append(f"paper-content.json {content_key} differs from metadata {source_key}")
    if content.get("fieldDataCollected") is not False:
        errors.append("paper-content.json must state fieldDataCollected=false")
    if len(content.get("sections", [])) < 8 or len(content.get("references", [])) < 12:
        errors.append("paper-content.json has insufficient numbered sections or references")

    for marker in ["## 摘要", "## Abstract", "未經外部同儕審查", "本研究沒有採集新田間資料", "葉綠素代理", "地面", "巡檢", "農噴", "CC BY 4.0", "第三方"]:
        if marker not in paper:
            errors.append(f"paper.md is missing required disclosure or section marker: {marker}")
    if len(paper) < 18000:
        errors.append(f"paper.md is unexpectedly short: {len(paper)} characters")
    if re.search(r"本研究(已)?(證明|證實).{0,20}(確診|增產|節藥|省成本|投資報酬)", paper):
        errors.append("paper.md makes an observed efficacy claim despite having no field data")
    for figure in [f"figure-{index}-" for index in range(1, 7)]:
        if figure not in paper:
            errors.append(f"paper.md does not embed figure prefix: {figure}")

    if results.get("reportNumber") != meta["reportNumber"] or results.get("fieldDataCollected") is not False:
        errors.append("analysis-results metadata or field-data status is inconsistent")
    if results.get("observedPerformanceMetrics") != {}:
        errors.append("observedPerformanceMetrics must remain empty before fieldwork")
    expected_counts = {
        "independentFields": 4,
        "independentFixedPlots": 80,
        "plotDateRepeatedObservations": 240,
        "plannedSpadReadings": 3600,
        "primaryFlights": 12,
        "repeatFlights": 8,
        "totalPlannedFlights": 20,
        "independentCheckpoints": 20,
    }
    if results.get("designCounts") != expected_counts:
        errors.append(f"Design counts differ from the predeclared protocol: {results.get('designCounts')}")
    if results.get("selectedAircraft") != "DJI Mavic 3 Multispectral":
        errors.append("Selected aircraft is inconsistent")
    if results.get("costModel", {}).get("currencyValuesPresent") is not False:
        errors.append("Currency values must not be present in v1.0")

    workload = {row["item"]: int(row["planned_count"]) for row in rows("outputs/protocol-workload.csv")}
    expected_workload = {"獨立田區": 4, "固定2×2m樣區": 80, "樣區-日期配對": 240, "SPAD葉片讀值": 3600, "主要航次": 12, "同日重飛航次": 8, "總規劃航次": 20, "獨立幾何檢核點": 20}
    if workload != expected_workload:
        errors.append(f"Protocol workload output is inconsistent: {workload}")

    aircraft = rows("aircraft-comparison.csv")
    if len(aircraft) != 3 or sum(row["selected"] == "是" for row in aircraft) != 1:
        errors.append("Aircraft comparison must contain three DJI models and exactly one selected model")
    selected = next((row for row in aircraft if row["selected"] == "是"), None)
    if not selected or selected.get("model") != "DJI Mavic 3 Multispectral" or selected.get("standard_weight_g") != "951" or selected.get("max_flight_time_min") != "43":
        errors.append("Selected M3M official-spec record is incomplete")
    m4t = next((row for row in aircraft if row["model"] == "DJI Matrice 4T"), {})
    if m4t.get("multispectral_sensor") != "無" or not m4t.get("thermal_sensor"):
        errors.append("Matrice 4T sensor boundary is misstated")
    if any("價格" in key or "price" in key.casefold() for key in aircraft[0]):
        errors.append("Aircraft comparison must not contain unverified prices")

    cost = rows("cost-model-template.csv")
    if not cost or any(row.get("input_value", "").strip() for row in cost):
        errors.append("Cost model input_value must remain blank until quoted or measured")
    field_rows = rows("field-form.csv")
    if any(any(value.strip() for value in row.values() if value is not None) for row in field_rows):
        errors.append("field-form.csv must contain no invented field observations")
    thresholds = rows("decision-thresholds.csv")
    allowed_pending = {"待未來實飛", "待未來pilot鎖定", "待採購報價"}
    if len(thresholds) < 10 or any(row.get("status") not in allowed_pending for row in thresholds):
        errors.append("Every field-performance threshold must remain pending future evidence")

    registry = rows("source-registry.csv")
    log = rows("source-log.csv")
    ids = [row["source_id"] for row in registry]
    if len(registry) < 27 or len(ids) != len(set(ids)):
        errors.append("Source registry is too small or contains duplicate IDs")
    if {row["source_id"] for row in log} != set(ids):
        errors.append("source-log.csv does not cover every source-registry ID exactly")
    for row in registry:
        if not row.get("url", "").startswith("https://") or not row.get("retrieved_at") or not row.get("license_or_terms") or not row.get("limitation"):
            errors.append(f"Source registry record is incomplete: {row.get('source_id')}")
        if row.get("redistributed") != "否":
            errors.append(f"Third-party source must not be redistributed: {row.get('source_id')}")
    for row in log:
        if row.get("full_content_stored") != "false":
            errors.append(f"source-log claims complete third-party content storage: {row.get('source_id')}")
        if row.get("http_status") and not row.get("bytes"):
            errors.append(f"source-log has HTTP status without byte evidence: {row.get('source_id')}")
        if row.get("sha256") and not re.fullmatch(r"[0-9a-f]{64}", row["sha256"]):
            errors.append(f"source-log has invalid SHA-256: {row.get('source_id')}")
    evidence = json.loads((ROOT / "source-evidence.json").read_text(encoding="utf-8"))
    if evidence.get("fullPageContentStored") is not False or evidence.get("rawThirdPartyFilesRedistributed") is not False:
        errors.append("source-evidence.json must confirm third-party data minimization")

    legal_markers = ["3 m/s", "不得超過2 m", "3日內", "116年1月1日", "巡檢", "不執行農噴"]
    for marker in legal_markers:
        if marker not in governance + "\n" + json.dumps(evidence, ensure_ascii=False):
            errors.append(f"Current spray-law boundary is missing: {marker}")
    if re.search(r"現行.{0,20}(一般|原則).{0,20}(植冠|冠層).{0,20}4\s*m", governance):
        errors.append("Governance text incorrectly presents 4 m as the general current spray height")

    rights = (ROOT / "RIGHTS-NOTICE.txt").read_text(encoding="utf-8")
    license_text = (ROOT / "LICENSE.txt").read_text(encoding="utf-8")
    for marker in ["DJI", "第三方", "不重散布", "精確座標", "不存在隸屬、合作、贊助或認可"]:
        if marker not in rights:
            errors.append(f"RIGHTS-NOTICE is missing: {marker}")
    if "作者有權授權" not in license_text or "第三方" not in license_text:
        errors.append("LICENSE.txt does not limit CC BY 4.0 to author-controlled content")

    sensitive_headers = {"full_html", "raw_html", "page_text", "full_description", "review_text", "screenshot", "image_binary", "customer_name", "phone", "email", "address", "precise_latitude", "precise_longitude", "latitude", "longitude"}
    for csv_path in ROOT.rglob("*.csv"):
        if "assets" in csv_path.parts:
            continue
        with csv_path.open("r", encoding="utf-8-sig", newline="") as stream:
            header = next(csv.reader(stream), [])
        found = sensitive_headers.intersection(value.strip().casefold() for value in header)
        if found:
            errors.append(f"Public CSV contains sensitive/raw-content headers {sorted(found)}: {csv_path.relative_to(ROOT)}")

    try:
        schema_path = ROOT / "assets" / "schemas" / "cff-1.2.0-schema.json"
        if sha256(schema_path) != CFF_SCHEMA_SHA256:
            errors.append("Local CFF 1.2.0 schema hash mismatch")
        cff = normalize_yaml(yaml.safe_load((ROOT / "CITATION.cff").read_text(encoding="utf-8")))
        schema = json.loads(schema_path.read_text(encoding="utf-8"))
        jsonschema.validate(cff, schema)
        if cff.get("title") != meta["title"] or cff.get("version") != meta["version"]:
            errors.append("CITATION.cff title or version differs from metadata")
        if cff.get("doi") != doi:
            errors.append("CITATION.cff DOI differs from metadata")
        checks["cffSchema"] = "CFF 1.2.0 PASSED"
    except (OSError, yaml.YAMLError, json.JSONDecodeError, jsonschema.ValidationError, jsonschema.SchemaError, AttributeError) as error:
        errors.append(f"CFF validation failed: {error}")
        checks["cffSchema"] = "FAILED"

    for relative, expected_hash in [("assets/fonts/NotoSansTC-Regular.ttf", FONT_SHA256), ("assets/fonts/OFL.txt", FONT_LICENSE_SHA256)]:
        if sha256(ROOT / relative) != expected_hash:
            errors.append(f"Packaged asset hash mismatch: {relative}")

    for figure in sorted((ROOT / "outputs" / "figures").glob("*.png")):
        if figure.stat().st_size < 50000 or figure.read_bytes()[:8] != b"\x89PNG\r\n\x1a\n":
            errors.append(f"Figure is missing, too small or not PNG: {figure.name}")

    try:
        pdf_path = ROOT / "paper.pdf"
        reader = PdfReader(str(pdf_path))
        if reader.is_encrypted:
            errors.append("PDF must not be encrypted")
        page_texts = [(page.extract_text() or "").strip() for page in reader.pages]
        compact = re.sub(r"\s+", "", "\n".join(page_texts))
        if len(reader.pages) < 7:
            errors.append(f"PDF has unexpectedly few pages: {len(reader.pages)}")
        blanks = [index + 1 for index, text in enumerate(page_texts) if len(text) < 30]
        if blanks:
            errors.append(f"PDF has blank or nearly blank text pages: {blanks}")
        for page_number, page in enumerate(reader.pages, 1):
            width = float(page.mediabox.width)
            height = float(page.mediabox.height)
            if abs(width - 595.28) > 2 or abs(height - 841.89) > 2:
                errors.append(f"PDF page {page_number} is not A4: {width}x{height}")
        for marker in [meta["title"], meta["reportNumber"], "Mavic 3 Multispectral", "80", "3,600", "未經外部同儕審查", "尚未採集田間資料"]:
            if re.sub(r"\s+", "", marker) not in compact:
                errors.append(f"Searchable PDF text is missing: {marker}")
        if any(character in "\n".join(page_texts) for character in BAD_DASHES):
            errors.append("PDF contains typographic dash characters; ASCII hyphens are required")
        pdf_meta = reader.metadata or {}
        expected_subject = "Prospective minimum viable validation protocol for DJI Mavic 3 Multispectral rice scouting in Taiwan"
        expected_keywords = "DJI Mavic 3 Multispectral, agricultural drone, rice scouting, multispectral imaging, ground validation, prospective protocol, Taiwan"
        metadata_values = [str(pdf_meta.get(key, "")) for key in ("/Title", "/Author", "/Subject", "/Keywords")]
        if (
            metadata_values[0] != meta["titleEnglish"]
            or metadata_values[1] != "CHOU, BING-HAN"
            or metadata_values[2] != expected_subject
            or metadata_values[3] != expected_keywords
            or any("\ufffd" in value for value in metadata_values)
        ):
            errors.append("PDF document metadata are inconsistent")
        if doi and doi not in compact:
            errors.append("Searchable PDF does not contain the reserved DOI")
        if not doi and "DOI：尚未建立".replace(" ", "") not in compact:
            errors.append("Pre-reservation PDF must display DOI as not established")
        pdf_hash = sha256(pdf_path)
        qa = json.loads((ROOT / "pdf-visual-qa.json").read_text(encoding="utf-8"))
        parse_time(qa.get("reviewedAt"), "PDF visual QA reviewedAt", errors)
        expected_qa_checks = ["noBlankPages", "noClippedTablesOrCharts", "noOverlappingText", "noMissingGlyphsOrBlackBoxes", "noOutOfBoundsContent", "readableTextAndCaptions", "headersFootersAndPageNumbersPresent", "searchableTextLayerCheckedSeparately"]
        if qa.get("status") != "PASSED" or qa.get("pagesReviewed") != len(reader.pages) or qa.get("pdfSha256") != pdf_hash:
            errors.append("PDF visual QA is missing, incomplete or bound to a different PDF hash")
        if int(qa.get("renderDpi", 0)) < 120 or any(qa.get("checks", {}).get(key) is not True for key in expected_qa_checks):
            errors.append("PDF visual QA does not attest all required layout checks at sufficient DPI")
        if len(qa.get("pageImageSha256", {})) != len(reader.pages):
            errors.append("PDF visual QA page-image hashes do not cover every page")
        checks["pdf"] = {"pages": len(reader.pages), "bytes": pdf_path.stat().st_size, "sha256": pdf_hash, "visualQaBound": True}
    except Exception as error:
        if not isinstance(error, (SystemExit, KeyboardInterrupt)):
            errors.append(f"PDF validation failed: {error}")

    try:
        repro = json.loads((ROOT / "reproducibility-check.json").read_text(encoding="utf-8"))
        parse_time(repro.get("reproducedAt"), "Reproducibility reproducedAt", errors)
        files = repro.get("files", [])
        if repro.get("reportNumber") != meta["reportNumber"] or repro.get("version") != meta["version"] or repro.get("status") != "PASSED":
            errors.append("Reproducibility receipt identity or status is invalid")
        if len(files) != 13 or not all(item.get("bitwiseMatch") is True for item in files):
            errors.append("Controlled-host reproduction did not bitwise-match all 13 derived files")
        checks["reproducibility"] = {"status": repro.get("status"), "filesCompared": len(files), "pandas": repro.get("environment", {}).get("pandas")}
    except (OSError, json.JSONDecodeError) as error:
        errors.append(f"Reproducibility receipt validation failed: {error}")

    doi_hits = scan_candidate_dois([path for path in ROOT.rglob("*") if path.is_file()])
    doi_set = sorted({item for values in doi_hits.values() for item in values})
    if doi is None and doi_set:
        errors.append(f"Pre-reservation package files contain unexpected Zenodo DOI(s): {doi_set}")
    if doi is not None and doi_set != [doi]:
        errors.append(f"Candidate files contain DOI set {doi_set}; expected only {doi}")
    checks["candidateDoiOccurrences"] = doi_hits
    checks["sources"] = {"registryRecords": len(registry), "sourceLogRecords": len(log), "fullThirdPartyContentStored": False}
    checks["studyBoundary"] = {"fieldDataCollected": False, "observedPerformanceMetrics": 0, "currencyInputs": 0}

    result = {
        "schemaVersion": 2,
        "reportNumber": meta["reportNumber"],
        "version": meta["version"],
        "validatedAt": now_taipei(),
        "status": "PASSED" if not errors else "FAILED",
        "scope": "Content, protocol arithmetic, source traceability, rights, current regulation boundary, no-field-data claim, CFF, PDF semantics, hash-bound visual QA, candidate DOI and controlled-host reproducibility.",
        "checks": checks,
        "errors": errors,
    }
    REPORT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(json.dumps({"status": result["status"], "errors": len(errors), "report": str(REPORT)}, ensure_ascii=False))
    if errors:
        sys.exit(1)


if __name__ == "__main__":
    main()
