from __future__ import annotations

import csv
import hashlib
import json
import os
import re
import subprocess
import sys
from pathlib import Path

import numpy as np
from pypdf import PdfReader

from m3m_twin.io import sha256_tree


ROOT = Path(__file__).resolve().parent
REPORT = ROOT / "validation-report.json"
REPORT_NUMBER = "SHWRP-2026-034"
VERSION = "1.1"
DOI = "10.5281/zenodo.21791470"
PREVIOUS_DOI = "10.5281/zenodo.21789964"
CONCEPT_DOI = "10.5281/zenodo.21789963"


def load_json(name: str) -> dict:
    return json.loads((ROOT / name).read_text(encoding="utf-8"))


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


def digest_file(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 main() -> None:
    errors: list[str] = []
    warnings: list[str] = []
    checks: dict[str, object] = {}
    required_files = [
        "paper.md", "paper.pdf", "paper-metadata.json", "paper-content.json", "CITATION.cff",
        "README.md", "LICENSE.txt", "RIGHTS-NOTICE.txt", "source-registry.csv",
        "source-registry-v1.1-addendum.csv", "requirements-traceability-v1.1.csv",
        "data-management-plan.md", "flight-safety-and-consent.md", "pdf-render-report.json",
        "pdf-visual-qa.json", "reproducibility-check.json", "package_release.py",
        "validate_package.py", "update_zenodo_request.py", "run_focused_analysis.py",
        "outputs/focused/focused-analysis-receipt.json",
        "outputs/focused/m3m-spectral-response-receipt.json",
        "outputs/focused/m3m-spectral-response-summary.csv",
        "outputs/focused/mission-feasibility-envelope.csv",
        "outputs/figures/figure-5-measured-spectral-response.png",
        "outputs/figures/figure-6-mission-feasibility-envelope.png",
        "assets/fonts/NotoSansTC-Regular.ttf", "assets/fonts/OFL.txt",
    ]
    missing_files = [name for name in required_files if not (ROOT / name).is_file()]
    checks["requiredFiles"] = {"count": len(required_files), "missing": missing_files}
    errors.extend(f"missing required release file: {name}" for name in missing_files)

    test = subprocess.run(
        [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "tests", "tests_v1_1"],
        cwd=ROOT,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="replace",
        timeout=300,
        check=False,
        env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
    )
    combined_test_output = test.stdout + test.stderr
    count_match = re.search(r"(\d+) passed", combined_test_output)
    progress_line = next((line for line in combined_test_output.splitlines() if "[100%]" in line), "")
    test_count = int(count_match.group(1)) if count_match else progress_line.split("[100%]", 1)[0].count(".")
    tests_ok = test.returncode == 0 and test_count == 45
    checks["tests"] = {
        "passed": tests_ok,
        "testCount": test_count,
        "summary": combined_test_output.strip().splitlines()[-1] if combined_test_output.strip() else "",
    }
    if not tests_ok:
        errors.append("expected exactly 45 passing tests across tests and tests_v1_1")

    receipt = load_json("outputs/demo/engineering-demo-receipt.json")
    analysis = load_json("outputs/demo/system-analysis.json")
    mission = load_json("outputs/demo/mission-summary.json")
    focused = load_json("outputs/focused/focused-analysis-receipt.json")
    spectral_receipt = load_json("outputs/focused/m3m-spectral-response-receipt.json")
    mission_rows = load_csv("outputs/focused/mission-feasibility-envelope.csv")
    spectral_rows = load_csv("outputs/focused/m3m-spectral-response-summary.csv")
    derived_ok = all((
        receipt.get("runStatus") == "PASSED",
        receipt.get("fieldFlightExecuted") is False,
        receipt.get("realMavic3MFilesProcessed") is False,
        receipt.get("captureEventCount") == 408,
        focused.get("status") == "PASSED",
        focused.get("measuredSpectralResponseBands") == 4,
        focused.get("missionEnvelopeScenarioCount") == 35,
        spectral_receipt.get("status") == "PASSED",
        len(mission_rows) == 35,
        len(spectral_rows) == 4,
        all(row.get("rawFileRedistributed", "").lower() in {"no", "false"} for row in spectral_rows),
    ))
    if not derived_ok:
        errors.append("demo or focused-analysis evidence is inconsistent")
    checks["derivedEvidence"] = {
        "passed": derived_ok,
        "captureEvents": receipt.get("captureEventCount"),
        "missionScenarios": len(mission_rows),
        "spectralBands": len(spectral_rows),
        "thirdPartyRawFilesRedistributed": False,
    }
    if any(
        analysis.get(key) is not False
        for key in (
            "fieldFlightExecuted", "realMavic3MFilesProcessed", "diseaseConfirmed",
            "agronomicCauseAssigned", "automationAuthorized",
        )
    ):
        errors.append("analysis contains an unauthorized field, diagnosis, cause, or automation claim")
    if mission.get("flightExecutionAuthorized") is not False or mission.get("notForFlight") is not True:
        errors.append("mission artifact is not clearly non-operational")

    engineering_errors: list[str] = []
    receipt_paths = {record.get("path") for record in receipt.get("artifacts", [])}
    if "outputs/demo/engineering-demo-receipt.json" in receipt_paths:
        engineering_errors.append("engineering receipt hashes itself")
    for record in receipt.get("artifacts", []):
        path = ROOT / str(record.get("path", ""))
        if (
            not path.is_file()
            or path.stat().st_size != record.get("bytes")
            or digest_file(path) != record.get("sha256")
        ):
            engineering_errors.append(f"receipt artifact mismatch: {record.get('path')}")
    if receipt.get("sourceTreeSha256") != sha256_tree(ROOT / "m3m_twin"):
        engineering_errors.append("receipt source-tree hash is stale")
    capture_rows = load_csv("outputs/demo/capture-manifest.csv")
    missing_red_edge = [row for row in capture_rows if row.get("missingBand") == "red_edge"]
    if len(missing_red_edge) != 2 or any(
        "_MS_RE.TIF" in row.get("filenames", "")
        or int(row.get("fileCountObserved", 0)) != 4
        or "missing_band" not in row.get("qcFailures", "")
        for row in missing_red_edge
    ):
        engineering_errors.append("missing-red-edge fixture does not represent a genuinely absent file")
    with np.load(ROOT / "outputs" / "demo" / "synthetic-analysis-arrays.npz") as arrays:
        review_mask = arrays["review_mask"].astype(bool)
        truth = arrays["truth_lower_vigor"].astype(bool)
    mask_area = int(review_mask.sum())
    zone_area = sum(int(zone["area_m2"]) for zone in analysis.get("reviewZones", []))
    true_positive = int((review_mask & truth).sum())
    false_positive = int((review_mask & ~truth).sum())
    false_negative = int((~review_mask & truth).sum())
    computed_metrics = {
        "precision": round(true_positive / max(true_positive + false_positive, 1), 6),
        "recall": round(true_positive / max(true_positive + false_negative, 1), 6),
        "intersectionOverUnion": round(
            true_positive / max(true_positive + false_positive + false_negative, 1), 6
        ),
    }
    if mask_area != analysis.get("reviewAreaM2") or mask_area != zone_area:
        engineering_errors.append("review mask, reviewAreaM2 and retained-zone areas differ")
    reported_metrics = analysis.get("syntheticFunctionalTest", {})
    if any(reported_metrics.get(name) != value for name, value in computed_metrics.items()):
        engineering_errors.append("reported functional metrics do not use the final filtered review mask")
    checks["engineeringConsistency"] = {
        "passed": not engineering_errors,
        "receiptArtifactCount": len(receipt_paths),
        "missingRedEdgeEventCount": len(missing_red_edge),
        "reviewMaskAreaM2": mask_area,
        "retainedZoneAreaM2": zone_area,
        "functionalMetrics": computed_metrics,
    }
    errors.extend(engineering_errors)

    metadata = load_json("paper-metadata.json")
    release_ok = all((
        metadata.get("reportNumber") == REPORT_NUMBER,
        metadata.get("version") == VERSION,
        metadata.get("dateCreated") == "2026-08-04",
        metadata.get("dateModified") == "2026-08-04",
        metadata.get("publicationStatus") == "公開研究工作論文",
        metadata.get("doi") == DOI,
        metadata.get("zenodoUrl") == "https://zenodo.org/records/21791470",
        metadata.get("previousVersionDoi") == PREVIOUS_DOI,
        metadata.get("conceptDoi") == CONCEPT_DOI,
        metadata.get("peerReviewed") is False,
        metadata.get("fieldFlightExecuted") is False,
        metadata.get("realMavic3MFilesProcessed") is False,
        metadata.get("measuredSpectralResponseDataAnalysed") is True,
        metadata.get("softwarePrototypeImplemented") is True,
        str(metadata.get("license", "")).startswith("CC BY 4.0"),
    ))
    checks["releaseIdentity"] = release_ok
    if not release_ok:
        errors.append("public v1.1 metadata, DOI, version lineage, license or evidence boundary is inconsistent")

    paper = (ROOT / "paper.md").read_text(encoding="utf-8")
    required_disclosures = [
        "沒有起飛", "沒有真實 Mavic 3M 影像", "相對光譜低值", "航線可行域",
        "公開研究工作論文", DOI, PREVIOUS_DOI, CONCEPT_DOI, "CC BY 4.0", "利益衝突",
        "第三方權利", "更正申請", "未經外部工程、農藝、測量、法律或同儕審查",
        "不存在隸屬、合作、贊助、認可或背書關係",
    ]
    missing_disclosures = [text for text in required_disclosures if text not in paper]
    stale_public_markers = [
        text for text in ("尚未公開新版本", "尚未建立或公開新的版本 DOI", "本草稿若公開")
        if text in paper
    ]
    if missing_disclosures:
        errors.append("paper disclosure phrases missing: " + ", ".join(missing_disclosures))
    if stale_public_markers:
        errors.append("paper still carries draft-only publication markers: " + ", ".join(stale_public_markers))
    figure_sequence = [int(value) for value in re.findall(r"!\[圖(\d+)", paper)]
    if figure_sequence != [1, 2, 3, 4, 5, 6]:
        errors.append(f"figure captions are not sequential in reading order: {figure_sequence}")
    checks["paperDisclosures"] = {
        "passed": not missing_disclosures and not stale_public_markers and figure_sequence == [1, 2, 3, 4, 5, 6],
        "requiredCount": len(required_disclosures),
        "staleMarkers": stale_public_markers,
        "figureSequence": figure_sequence,
    }

    content = load_json("paper-content.json")
    content_ok = content.get("metadata") == metadata and len(content.get("sections", [])) >= 10
    checks["canonicalContent"] = content_ok
    if not content_ok:
        errors.append("paper-content.json is not the canonical projection of v1.1 metadata and paper sections")

    citation = (ROOT / "CITATION.cff").read_text(encoding="utf-8")
    citation_ok = all(value in citation for value in (
        'version: "1.1"', f'value: "{DOI}"', 'license: "CC-BY-4.0"',
        "m3m-digital-twin-end-to-end",
    )) and PREVIOUS_DOI not in citation
    checks["citationMetadata"] = citation_ok
    if not citation_ok:
        errors.append("CITATION.cff is inconsistent with public v1.1")

    license_text = (ROOT / "LICENSE.txt").read_text(encoding="utf-8")
    rights_text = (ROOT / "RIGHTS-NOTICE.txt").read_text(encoding="utf-8")
    font_license = (ROOT / "assets" / "fonts" / "OFL.txt").read_text(encoding="utf-8")
    rights_ok = all(value in license_text.lower() for value in (
        "version 1.1 public", "cc by 4.0", "creativecommons.org/licenses/by/4.0", "third-party",
    )) and all(value in rights_text.lower() for value in (
        DOI.lower(), PREVIOUS_DOI.lower(), CONCEPT_DOI.lower(), "third-party", "not affiliated",
        "dji", "noto sans tc", "sil open font license 1.1", "not stored or repackaged",
    )) and all(value in font_license.lower() for value in (
        "sil open font license version 1.1", "copyright 2014-2021 adobe",
    ))
    checks["licenseAndThirdPartyRights"] = rights_ok
    if not rights_ok:
        errors.append("license scope, version history, or third-party rights boundary is incomplete")

    sources = load_csv("source-registry.csv")
    source_ids = [row.get("source_id") for row in sources]
    redistributed = [row for row in sources if row.get("redistributed") == "Yes"]
    required_source_ids = {
        "SRF-M3M", "DJI-RELEASE", "DJI-MSDK", "DJI-WPML", "DJI-CLOUD-SUPPORT",
        "SU-2019", "ABDULRIDHA-2019", "ALBETIS-2017", "GARCIA-2020",
        "HUGENHOLTZ-2016", "JIANG-2025", "KARPINSKI-2025", "YAMAGUCHI-2024",
        "PLOTON-2020", "DANIELS-2023", "LI-2026", "FONT-NOTO",
    }
    source_registry_ok = all((
        len(sources) == 40,
        len(set(source_ids)) == len(source_ids),
        required_source_ids.issubset(set(source_ids)),
        {row.get("source_id") for row in redistributed} == {"FONT-NOTO"},
        all(row.get("redistributed") in {"No", "Yes"} for row in sources),
    ))
    if not source_registry_ok:
        errors.append("canonical source registry merge, uniqueness, or redistribution boundary failed")
    checks["sourceRegistry"] = {
        "passed": source_registry_ok,
        "sourceCount": len(sources),
        "redistributedSourceIds": [row.get("source_id") for row in redistributed],
    }
    traceability = load_csv("requirements-traceability-v1.1.csv")
    traceability_ok = len(traceability) == 10 and all(row.get("field_status") for row in traceability) and all(
        "remains draft" not in row.get("field_status", "") for row in traceability
    )
    checks["requirementsTraceability"] = {"passed": traceability_ok, "requirementCount": len(traceability)}
    if not traceability_ok:
        errors.append("v1.1 requirements traceability is incomplete or still marks the release as a draft")

    pdf_path = ROOT / "paper.pdf"
    reader = PdfReader(str(pdf_path))
    pdf_text = "\n".join(page.extract_text() or "" for page in reader.pages)
    pdf_hash = digest_file(pdf_path)
    pdf_ok = len(reader.pages) == 16 and all(text in pdf_text for text in (
        REPORT_NUMBER, "REVIEW_REQUIRED", "pytest", DOI, "CC BY 4.0",
        "未經外部同儕審查", "參考文獻與資料來源", "55.813", "30.795", "6,124",
    ))
    if not pdf_ok:
        errors.append("searchable v1.1 PDF content, DOI, results, or page-count check failed")
    render = load_json("pdf-render-report.json")
    visual = load_json("pdf-visual-qa.json")
    render_ok = render.get("status") == "PASSED" and render.get("pdfSha256") == pdf_hash and len(render.get("pages", [])) == len(reader.pages)
    visual_ok = all((
        visual.get("status") == "PASSED",
        visual.get("pdfSha256") == pdf_hash,
        visual.get("pageCount") == len(reader.pages),
        visual.get("pagesInspected") == list(range(1, len(reader.pages) + 1)),
    ))
    if not render_ok or not visual_ok:
        errors.append("Poppler render or all-page visual QA does not match the canonical PDF")
    checks["pdf"] = {
        "passed": pdf_ok and render_ok and visual_ok,
        "pages": len(reader.pages),
        "textCharacters": len(pdf_text),
        "sha256": pdf_hash,
        "renderPassed": render_ok,
        "visualQaPassed": visual_ok,
    }

    reproducibility = load_json("reproducibility-check.json")
    if reproducibility.get("status") != "PASSED":
        errors.append("reproducibility check failed")
    checks["reproducibility"] = reproducibility.get("status") == "PASSED"
    cache_dirs = [str(path.relative_to(ROOT)) for path in ROOT.rglob("__pycache__") if path.is_dir()]
    if cache_dirs:
        warnings.append("Python cache directories exist locally and are excluded from the release package")

    payload = {
        "schemaVersion": 1,
        "validatedOn": "2026-08-04",
        "status": "PASSED" if not errors else "FAILED",
        "scope": "Public v1.1 identity and version lineage, DOI, CC BY and third-party rights boundary, 45 automated tests, focused derived evidence, simulation/field claim boundary, merged source registry, reproducibility, searchable PDF, Poppler render and all-page visual QA.",
        "checks": checks,
        "errors": errors,
        "warnings": warnings,
    }
    REPORT.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(json.dumps({"status": payload["status"], "errors": len(errors), "warnings": len(warnings)}, ensure_ascii=False))
    if errors:
        raise SystemExit(1)


if __name__ == "__main__":
    main()
