#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Generate paired publication data from preserved, validated receipts."""
import argparse
import csv
import hashlib
import io
import json
from pathlib import Path
import observe

ROOT = Path(__file__).parent
SLUG = "ddrescue-image-provenance"
BUNDLE = ("observe.py", "test_observe.py", "results.json", "replay-20261018.json",
          "sources.json", "claims.json", "LICENSE", "report.md", "build_publication.py", "test_publication.py",
          "build_report.py", "report.template.md")
COLUMNS = [
    ("case", "Stable scenario ID, not an independent fleet observation."),
    ("mode", "Rescue, resume, approximate map generation or post-copy modification."),
    ("source_bytes", "Known fixture extent in bytes."),
    ("finished_bytes", "Bytes in map ranges marked +, not independently verified image bytes."),
    ("matching_source_bytes", "Bytes equal to the fixture; differences in these cases occupy whole sectors."),
    ("operation_status", "Operation exit status; modification is a fixture action, not ddrescue."),
    ("done_status", "ddrescuelog --done-status over the full map domain."),
    ("first_sector_done_status", "The same check restricted to the first 512 bytes."),
    ("image_sha256", "Output digest, not source authentication or acquisition history."),
]
CITATIONS = [
    {"id": "gnu", "title": "GNU ddrescue 1.30 manual: sparse output, generate mode and ddrescuelog",
     "url": "https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html", "accessed": "2026-10-18"},
    {"id": "source", "title": "GNU ddrescue 1.30 source release: rescuebook.cc and genbook.cc",
     "url": "https://ftp.gnu.org/gnu/ddrescue/ddrescue-1.30.tar.lz", "accessed": "2026-10-18"},
]


def digest(data):
    return hashlib.sha256(data).hexdigest()


def validate_protocol(receipt):
    """Validate the recipe as well as outcomes; this does not authenticate evidence."""
    observe.validate(receipt)
    require = observe.require
    require(receipt["schemaVersion"] == 1, "unsupported receipt schema")
    require(receipt["experiment"] == "ddrescue-output-provenance", "wrong experiment")
    require(receipt["evidenceType"] == "constructed-software-experiment", "wrong evidence type")
    require(receipt["source"]["sectorBytes"] == observe.SECTOR, "wrong sector size")
    require(receipt["upstream"]["version"] == "1.30", "wrong upstream version")
    require(receipt["upstream"]["archiveSha256"] == observe.ARCHIVE_SHA256, "wrong upstream archive")
    for case in receipt["cases"]:
        ident, stem = case["id"], case["id"]
        command = ["ddrescue", "--quiet", "--sector-size=512"]
        if ident.startswith("generated-"):
            mode = "generate"
            require(case["generatedMapIsApproximate"] is True, "generated map must be approximate")
            require(case["parentCase"] == ident.removeprefix("generated-"), "wrong generation parent")
            command.append("--generate-mode")
        elif ident == "modified-after-completion":
            mode, stem = "post-copy-modification", "modified"
            require(case["parentCase"] == "fresh-healthy", "wrong modification parent")
        else:
            mode = "resume" if ident == "resume-sparse-reused" else "rescue"
            sparse = ident.startswith("sparse-")
            require(case["sparse"] is sparse, "wrong sparse mode")
            command += ["--cluster-size=1", "--retry-passes=" + ("1" if mode == "resume" else "0")]
            if sparse:
                command.append("--sparse")
            bad = 1 if ident.endswith("bad-zero") else 2 if ident.endswith("bad-data") else None
            require(case["simulatedBadSector"] == bad, "wrong simulated error")
            if bad is not None:
                command.append("--test-mode=bad-" + ("zero" if bad == 1 else "data") + ".map")
            if mode == "resume":
                stem = "sparse-reused-bad-data"
                require(case["parentCase"] == stem, "wrong resume parent")
            else:
                require(case["destinationInitially"] == ("Z-filled" if "reused" in ident else "absent"),
                        "wrong destination precondition")
        require(case["mode"] == mode, "wrong case mode")
        command += ["source.img", stem + ".img", stem + ".map"]
        if mode == "post-copy-modification":
            command = ["fixture-sector-overwrite", "sector=3", "byte=0x58"]
        require(case["operation"]["argv"] == command, "wrong operation recipe")
        require(case["doneStatus"]["argv"] == ["ddrescuelog", "--done-status", stem + ".map"],
                "wrong full-domain check")
        require(case["firstSectorDoneStatus"]["argv"] ==
                ["ddrescuelog", "--done-status", "--size=512", stem + ".map"], "wrong limited-domain check")
    return True


def derive(receipt):
    validate_protocol(receipt)
    rows = [{
        "case": c["id"], "mode": c["mode"], "source_bytes": receipt["source"]["bytes"],
        "finished_bytes": c["finishedBytes"], "matching_source_bytes": c["matchingSourceBytes"],
        "operation_status": c["operation"]["returncode"], "done_status": c["doneStatus"]["returncode"],
        "first_sector_done_status": c["firstSectorDoneStatus"]["returncode"], "image_sha256": c["imageSha256"],
    } for c in receipt["cases"]]
    stream = io.StringIO(newline="")
    writer = csv.DictWriter(stream, fieldnames=[name for name, _ in COLUMNS], lineterminator="\n")
    writer.writeheader()
    writer.writerows(rows)
    return rows, stream.getvalue().encode()


def build(dev_root):
    receipt = json.loads((ROOT / "results.json").read_text())
    replay = json.loads((ROOT / "replay-20261018.json").read_text())
    validate_protocol(replay)
    observe.require(observe.stable_observations(receipt) == observe.stable_observations(replay), "replay differs")
    for data in (receipt, replay):
        observe.require(data["harnessSha256"] == digest((ROOT / "observe.py").read_bytes()), "harness identity mismatch")
    rows, csv_bytes = derive(receipt)
    sources = json.loads((ROOT / "sources.json").read_text())
    observe.require(sources["archiveSha256"] == receipt["upstream"]["archiveSha256"], "source archive mismatch")
    raw = "/research/ddrescue-provenance/"
    meta = {
        "slug": SLUG, "title": "ddrescue image provenance: twelve controlled cases",
        "summary": "Complete maps, stale output bytes, approximate map reconstruction and identical images with different acquisition histories.",
        "published": "2026-10-18", "updated": "2026-10-18",
        "method": "Eight rescue/resume cases, three approximate-map cases and one post-copy modification on a known 4096-byte fixture. Read errors are simulated with --test-mode.",
        "limits": receipt["limitations"] + ["Matching-byte totals apply to fixtures with whole-sector differences, not a generic byte-difference calculation."],
        "license": "Hesela-authored code, observations and derived CSV: MIT. GNU source and documentation retain their licenses and are not redistributed.",
        "licenseUrl": "https://hesela.dev" + raw + "LICENSE",
        "downloadPath": "/data/" + SLUG + ".csv", "rawPath": raw + "results.json",
        "codePath": raw + "observe.py", "testsPath": raw + "test_observe.py",
        "generatorPath": raw + "build_publication.py", "validationTestsPath": raw + "test_publication.py",
        "replayPath": raw + "replay-20261018.json", "humanPage": "https://hesela.com/analyses/" + SLUG + "/",
        "rowCount": len(rows), "columns": [
            {"name": n, "type": "string" if n in {"case", "mode", "image_sha256"} else "integer", "description": d}
            for n, d in COLUMNS], "rows": rows,
        "environment": receipt["environment"], "replayEnvironment": replay["environment"],
        "generatedAt": receipt["generatedAt"], "replayedAt": replay["generatedAt"], "citations": CITATIONS,
        "sha256": {name: digest((ROOT / name).read_bytes()) for name in BUNDLE}, "csvSha256": digest(csv_bytes),
    }
    public = dev_root / "sites/hesela-dev/public"
    outputs = {public / "data/datasets" / (SLUG + ".json"): (json.dumps(meta, indent=2) + "\n").encode(),
               public / "data" / (SLUG + ".csv"): csv_bytes}
    outputs.update({public / raw.lstrip("/") / name: (ROOT / name).read_bytes() for name in BUNDLE})
    # Add accessible scrolling only to the generated web manuscript, not the PDF source.
    manuscript = (ROOT / "report.md").read_text()
    web, in_table, table_number = [], False, 0
    for line in manuscript.splitlines():
        if line.startswith("|") and not in_table:
            table_number += 1
            web.extend([f'<div class="table-scroll" role="region" tabindex="0" aria-label="Recovery evidence table {table_number}">', ""])
            in_table = True
        elif not line.startswith("|") and in_table:
            web.extend(["", "</div>", ""])
            in_table = False
        web.append(line)
    if in_table:
        web.extend(["", "</div>"])
    outputs[ROOT / "web-manuscript.md"] = ("\n".join(web) + "\n").encode()
    for path, data in outputs.items():
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(data)
    return outputs


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--dev-root", required=True, type=Path)
    args = parser.parse_args()
    print(f"Generated {len(build(args.dev_root))} files from validated evidence")
