"""Claim-evidence survival and declared-artifact analysis for STRUST v1."""

from __future__ import annotations

import argparse
import hashlib
import json
import sys
from pathlib import Path
from typing import Any

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import BoundaryNorm, ListedColormap
from matplotlib.patches import Patch
import numpy as np
import pandas as pd


HERE = Path(__file__).resolve().parent
CONFIG_PATH = HERE / "study_config.json"
RESULTS = HERE / "results"
FIGURES = HERE / "figures"

sys.path.insert(0, str(HERE.parents[1] / "common"))
from plot_style import COLORS, configure, label_panels, save_figure  # noqa: E402


def sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def build_claim_evidence(config: dict[str, Any]) -> dict[str, Any]:
    model = config["claim_evidence_model"]
    artifacts = {row["id"]: row for row in model["artifacts"]}
    if len(artifacts) != len(model["artifacts"]):
        raise ValueError("artifact identifiers must be unique")

    edge_rows: list[dict[str, Any]] = []
    survival_rows: list[dict[str, Any]] = []
    for claim in model["claims"]:
        required = list(claim["required_artifacts"])
        unknown = sorted(set(required) - set(artifacts))
        if unknown:
            raise ValueError(f"unknown artifacts for {claim['id']}: {unknown}")
        present = [artifact for artifact in required if artifacts[artifact]["status"] == "present"]
        missing = [artifact for artifact in required if artifacts[artifact]["status"] != "present"]
        for artifact_id in required:
            artifact = artifacts[artifact_id]
            edge_rows.append(
                {
                    "claim_id": claim["id"],
                    "claim_label": claim["label"],
                    "source_locator": claim["source_locator"],
                    "historical_posture": claim["historical_posture"],
                    "artifact_id": artifact_id,
                    "artifact_label": artifact["label"],
                    "artifact_status": artifact["status"],
                    "survives": int(artifact["status"] == "present"),
                }
            )
        survival_rows.append(
            {
                "claim_id": claim["id"],
                "claim_label": claim["label"],
                "source_locator": claim["source_locator"],
                "historical_posture": claim["historical_posture"],
                "required_artifacts": len(required),
                "surviving_artifacts": len(present),
                "missing_artifacts": len(missing),
                "artifact_coverage": len(present) / len(required),
                "auditable_from_surviving_package": len(missing) == 0,
                "declared_missing_bundle_size": len(missing),
                "declared_missing_bundle": ";".join(missing),
                "counterfactual_boundary": model["counterfactual_rule"],
            }
        )

    counterfactual_rows: list[dict[str, Any]] = []
    missing_artifacts = [
        artifact_id
        for artifact_id, artifact in artifacts.items()
        if artifact["status"] != "present"
    ]
    missing_by_claim = {
        row["claim_id"]: set(filter(None, row["declared_missing_bundle"].split(";")))
        for row in survival_rows
    }
    for artifact_id in missing_artifacts:
        required_by = sorted(
            claim_id
            for claim_id, missing in missing_by_claim.items()
            if artifact_id in missing
        )
        completed = sorted(
            claim_id
            for claim_id, missing in missing_by_claim.items()
            if missing == {artifact_id}
        )
        counterfactual_rows.append(
            {
                "artifact_id": artifact_id,
                "artifact_label": artifacts[artifact_id]["label"],
                "required_by_claims": ";".join(required_by),
                "requirements_satisfied_if_recovered": len(required_by),
                "claims_completed_if_recovered_alone": len(completed),
                "completed_claim_ids": ";".join(completed),
                "interpretation": (
                    "Recovery reduces the declared missing bundle; a claim becomes auditable "
                    "only when every required artifact is present."
                ),
            }
        )

    edges = pd.DataFrame(edge_rows)
    survival = pd.DataFrame(survival_rows)
    counterfactual = pd.DataFrame(counterfactual_rows)
    summary = {
        "analysis_role": "second-cycle research-evidence archaeology",
        "analysis_date": config["analysis_date"],
        "claim_classes": int(len(survival)),
        "artifact_classes": int(len(artifacts)),
        "required_claim_artifact_edges": int(len(edges)),
        "surviving_required_edges": int(edges["survives"].sum()),
        "missing_required_edges": int(len(edges) - edges["survives"].sum()),
        "fully_auditable_claims": int(survival["auditable_from_surviving_package"].sum()),
        "blocked_claims": int((~survival["auditable_from_surviving_package"]).sum()),
        "single_missing_artifacts_that_complete_a_blocked_claim": int(
            (counterfactual["claims_completed_if_recovered_alone"] > 0).sum()
        ),
        "claim_survival": survival_rows,
        "counterfactual_boundary": model["counterfactual_rule"],
        "method_source": model["method_source"],
        "limitations": [
            "Each claim has one locally declared conjunctive requirement set; alternative sufficient sets were not assessed, and this is not an ACM badge decision.",
            "Complete artifact coverage would make a claim inspectable, not prove it correct.",
            "Private or lost artifacts may once have existed; the audit covers the recovered package and recorded repository tree.",
            "The historical paper explicitly limits enterprise scale, so the scale row preserves a negative boundary rather than recasting it as a positive result.",
        ],
    }
    return {
        "edges": edges,
        "survival": survival,
        "counterfactual": counterfactual,
        "summary": summary,
        "artifacts": list(artifacts.values()),
    }


def plot_claim_evidence(bundle: dict[str, Any]) -> None:
    configure()
    survival = bundle["survival"]
    edges = bundle["edges"]
    artifacts = bundle["artifacts"]
    claim_ids = survival["claim_id"].tolist()
    artifact_ids = [row["id"] for row in artifacts]
    claim_labels = dict(zip(survival["claim_id"], survival["claim_label"]))
    artifact_labels = {row["id"]: row["label"] for row in artifacts}
    short_artifact_labels = {
        "historical_document": "A1\npaper",
        "ledger_transcription": "A2\nledger",
        "executable_subjects": "A3\nsubjects",
        "fixtures_expected": "A4\nfixtures",
        "raw_traces": "A5\ntraces",
        "comparator_policy": "A6\npolicy",
        "environment_manifest": "A7\nenvironment",
        "independent_labels": "A8\nlabels",
        "timing_resources": "A9\ntiming",
        "multi_program_workload": "A10\nworkload",
    }
    matrix = np.full((len(claim_ids), len(artifact_ids)), np.nan)
    claim_index = {value: index for index, value in enumerate(claim_ids)}
    artifact_index = {value: index for index, value in enumerate(artifact_ids)}
    for row in edges.itertuples(index=False):
        matrix[claim_index[row.claim_id], artifact_index[row.artifact_id]] = (
            1.0 if row.artifact_status == "present" else -1.0
        )

    figure = plt.figure(figsize=(7.09, 2.75), constrained_layout=True)
    grid = figure.add_gridspec(1, 2, width_ratios=(2.4, 0.85))
    ax_matrix = figure.add_subplot(grid[0, 0])
    ax_bar = figure.add_subplot(grid[0, 1])

    cmap = ListedColormap([COLORS["vermillion"], COLORS["blue"]])
    cmap.set_bad("white")
    norm = BoundaryNorm([-1.5, 0, 1.5], cmap.N)
    ax_matrix.imshow(matrix, cmap=cmap, norm=norm, aspect="auto", interpolation="nearest")
    ax_matrix.set_xticks(
        np.arange(len(artifact_ids)),
        [short_artifact_labels[value] for value in artifact_ids],
        rotation=0,
        ha="center",
    )
    ax_matrix.set_yticks(
        np.arange(len(claim_ids)),
        [claim_labels[value] for value in claim_ids],
    )
    ax_matrix.set_title("Required evidence map", loc="left")
    ax_matrix.legend(
        handles=[
            Patch(facecolor=COLORS["blue"], label="Present"),
            Patch(facecolor=COLORS["vermillion"], label="Missing"),
        ],
        loc="lower right",
        bbox_to_anchor=(1.0, 1.005),
        ncol=2,
        borderaxespad=0,
        handlelength=1.0,
    )
    ax_matrix.set_xlabel("Required artifact class")
    for row in range(len(claim_ids) + 1):
        ax_matrix.axhline(row - 0.5, color=COLORS["light_gray"], linewidth=0.4)
    for column in range(len(artifact_ids) + 1):
        ax_matrix.axvline(column - 0.5, color=COLORS["light_gray"], linewidth=0.4)

    y = np.arange(len(survival))
    coverage = survival["artifact_coverage"].to_numpy(float)
    colors = [
        COLORS["blue"] if value == 1.0 else COLORS["vermillion"]
        for value in coverage
    ]
    ax_bar.barh(
        y,
        coverage,
        color=colors,
        edgecolor=COLORS["ink"],
        linewidth=0.4,
        height=0.58,
    )
    ax_bar.set_yticks(y, survival["claim_label"].tolist())
    ax_bar.invert_yaxis()
    ax_bar.set_xlim(0, 1.12)
    ax_bar.set_xlabel("Required artifact coverage")
    ax_bar.set_title("Claim coverage", loc="left")
    ax_bar.spines[["top", "right", "left"]].set_visible(False)
    ax_bar.tick_params(axis="y", length=0)
    for index, row in enumerate(survival.itertuples(index=False)):
        ax_bar.text(
            float(row.artifact_coverage) + 0.025,
            index,
            f"{row.surviving_artifacts}/{row.required_artifacts}",
            va="center",
            ha="left",
            color=COLORS["ink"],
            fontsize=6.5,
        )

    label_panels([ax_matrix, ax_bar])
    save_figure(figure, FIGURES, "claim_evidence_survival")


def _csv_text(frame: pd.DataFrame) -> str:
    return frame.to_csv(index=False, lineterminator="\n", float_format="%.10g")


def expected_text_outputs(bundle: dict[str, Any]) -> dict[Path, str]:
    return {
        RESULTS / "claim_evidence_edges.csv": _csv_text(bundle["edges"]),
        RESULTS / "evidence_survival.csv": _csv_text(bundle["survival"]),
        RESULTS / "artifact_recovery_counterfactual.csv": _csv_text(
            bundle["counterfactual"]
        ),
        RESULTS / "evidence_survival_summary.json": (
            json.dumps(bundle["summary"], indent=2, sort_keys=True, allow_nan=False)
            + "\n"
        ),
    }


def write_outputs(bundle: dict[str, Any]) -> None:
    RESULTS.mkdir(parents=True, exist_ok=True)
    for path, content in expected_text_outputs(bundle).items():
        path.write_text(content, encoding="utf-8", newline="\n")
    plot_claim_evidence(bundle)


def check_outputs(bundle: dict[str, Any]) -> None:
    problems: list[str] = []
    for path, content in expected_text_outputs(bundle).items():
        if not path.is_file():
            problems.append(f"missing {path.relative_to(HERE)}")
        elif path.read_text(encoding="utf-8") != content:
            problems.append(f"stale {path.relative_to(HERE)}")
    pdf = FIGURES / "claim_evidence_survival.pdf"
    png = FIGURES / "claim_evidence_survival.png"
    if not pdf.is_file() or not pdf.read_bytes().startswith(b"%PDF"):
        problems.append("missing or invalid figures/claim_evidence_survival.pdf")
    if not png.is_file() or not png.read_bytes().startswith(b"\x89PNG"):
        problems.append("missing or invalid figures/claim_evidence_survival.png")
    if problems:
        raise AssertionError("; ".join(problems))


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--check", action="store_true")
    args = parser.parse_args()
    config = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
    bundle = build_claim_evidence(config)
    check_outputs(bundle) if args.check else write_outputs(bundle)
    print(json.dumps(bundle["summary"], indent=2, sort_keys=True))


if __name__ == "__main__":
    main()
