#!/usr/bin/env python3
"""NILSARO-LOGGEN verifier: checks an evidence bundle or a full export.

    python3 nilsaro-loggen-verify.py DK-evidence-2026-10-08.zip
    python3 nilsaro-loggen-verify.py DK-evidence-2026-10-08/
    python3 nilsaro-loggen-verify.py NILSARO-LOGGEN-full-export-2026-10-12.json
    python3 nilsaro-loggen-verify.py DK-evidence-2026-10-08.zip --file drawing.pdf

For every project it is given, it runs the four checks VERIFY.md describes:

1. Each entry record, written as bytes by the hash rules, hashes to its entry
   hash. A redacted entry has no record left, so its content cannot be
   checked; it is named, not counted as a fault.
2. Each chain link, rebuilt from its parts, hashes to its chain hash.
3. Each link's previous hash is the chain hash of the link before it, and the
   numbers run 1, 2, 3 ... without gaps.
4. The last chain hash is the one the manifest names.

It also checks what the manifest claims: how many entries there are, how many
are redacted, the first and last number, and the hash rules versions used.

--file computes a file's SHA-256 and names every entry whose file references
carry that fingerprint.

Exit code 0: everything checks out. 1: something does not. 2: it could not
check at all -- not a bundle or a full export, unreadable, or a bundle format
or rules version this script does not know, which a newer script will.

It needs Python 3.8 or later and nothing but the standard library, and reads
nothing but the files named on its command line. The rules it follows are in
VERIFY.md, in every bundle.
"""

import argparse
import hashlib
import json
import os
import sys
import zipfile

VERSION = "1.0"
BUNDLE_FORMATS = (1, 2)
FULL_EXPORT_FORMATS = (1,)
RULES_VERSIONS = (1, 2)
BUNDLE_FILES = ("manifest.json", "entries.json", "chain.json")
LINK_PARTS = ("v", "project_id", "number", "entry_id", "entry_hash", "locked_at", "previous")


class CannotCheck(Exception):
    """What was given cannot be checked at all."""


def as_bytes(value):
    """A record or a chain link written as bytes, by the hash rules."""
    text = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    return text.encode("utf-8")


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


def listed(numbers):
    return ", ".join(str(number) for number in numbers)


def counted(count, singular, plural):
    return f"{count} {singular if count == 1 else plural}"


def in_words(numbers):
    words = [str(number) for number in numbers]
    return " and ".join([", ".join(words[:-1]), words[-1]]) if len(words) > 1 else "".join(words)


def parse(data, name):
    try:
        return json.loads(data.decode("utf-8"))
    except (UnicodeDecodeError, ValueError):
        raise CannotCheck(f"{name} is not readable JSON") from None


def read_bundle(path):
    """The three JSON files of a bundle, zipped or unpacked."""
    files = {}
    if os.path.isdir(path):
        for name in BUNDLE_FILES:
            try:
                with open(os.path.join(path, name), "rb") as handle:
                    files[name] = parse(handle.read(), name)
            except OSError:
                raise CannotCheck(
                    f"the folder has no {name}; it is not an unpacked bundle"
                ) from None
        return files
    try:
        with zipfile.ZipFile(path) as archive:
            names = archive.namelist()
            for name in BUNDLE_FILES:
                if name not in names:
                    raise CannotCheck(f"the ZIP file has no {name}; it is not an evidence bundle")
                files[name] = parse(archive.read(name), name)
    except (zipfile.BadZipFile, OSError):
        raise CannotCheck("the ZIP file cannot be read") from None
    return files


def load(path):
    """("bundle", files) or ("full export", data), whichever was given."""
    if not os.path.exists(path):
        raise CannotCheck(f"there is no file or folder called {path}")
    if os.path.isdir(path) or zipfile.is_zipfile(path):
        return "bundle", read_bundle(path)
    try:
        with open(path, "rb") as handle:
            data = parse(handle.read(), os.path.basename(path))
    except OSError:
        raise CannotCheck(f"{path} cannot be read") from None
    if not isinstance(data, dict) or not isinstance(data.get("projects"), list):
        raise CannotCheck(
            "this is neither an evidence bundle (a ZIP file or its folder) nor a full export"
        )
    return "full export", data


def known_formats(kind, formats):
    return f"this script knows {kind} formats {in_words(formats)}"


def check_project(manifest, entries, chain, where):
    """One project's faults, and what was checked.

    ``where`` names the project in each fault; it is empty for a bundle, which
    holds only one.
    """
    faults = []
    prefix = f"{where}, " if where else ""

    def fault(message):
        faults.append(prefix + message)

    redacted = []
    records: dict = {}
    numbers = {}
    for item in entries:
        number, record = item["number"], item["record"]
        numbers[item["entry_hash"]] = number
        if record is None:
            # Check 1 cannot be done for a redacted entry: there is no record to hash.
            if "redacted" not in item:
                fault(f"entry {number}: no record, and not redacted")
            redacted.append(number)
            records[item["entry_hash"]] = None
            continue
        if record.get("v") not in RULES_VERSIONS:
            raise CannotCheck(
                f"{prefix}entry {number} uses hash rules version {record.get('v')}; "
                f"this script knows versions {in_words(RULES_VERSIONS)}. A newer script is needed."
            )
        # 1. The record, written as bytes by the rules, hashes to its entry hash.
        if sha256(as_bytes(record)) != item["entry_hash"]:
            fault(f"entry {number}: the record does not match its entry hash")
        records[item["entry_hash"]] = record

    project_id = manifest["project"]["id"]
    versions = set()
    previous = None
    for expected, line in enumerate(chain, start=1):
        number = line["number"]
        if line["v"] not in RULES_VERSIONS:
            raise CannotCheck(
                f"{prefix}chain line {number} uses hash rules version {line['v']}; "
                f"this script knows versions {in_words(RULES_VERSIONS)}. A newer script is needed."
            )
        versions.add(line["v"])
        # 2. The chain link, rebuilt from its parts, hashes to its chain hash.
        link = {part: line[part] for part in LINK_PARTS}
        if sha256(as_bytes(link)) != line["chain_hash"]:
            fault(f"line {number}: the chain link does not match its chain hash")
        # 3. Each previous is the chain hash of the line before; no gaps in the numbers.
        if number != expected:
            fault(f"line {number}: expected number {expected} here")
        if line["previous"] != previous:
            fault(f"line {number}: previous is not the line before's chain hash")
        if line["project_id"] != project_id:
            fault(f"line {number}: it belongs to another project")
        if line["entry_hash"] not in records:
            fault(f"line {number}: no entry has this entry hash")
        else:
            record = records[line["entry_hash"]]
            if record is not None and record["entry_id"] != line["entry_id"]:
                fault(f"line {number}: the entry with this hash has another id")
            if numbers[line["entry_hash"]] != number:
                fault(f"line {number}: the entry with this hash has another number")
        previous = line["chain_hash"]

    # 4. The last chain hash is the one the manifest names.
    if previous != manifest["last_chain_hash"]:
        fault("the last chain hash is not the one in manifest.json")

    # What the manifest claims.
    if not len(entries) == len(chain) == manifest["entry_count"]:
        fault("entries.json, chain.json and manifest.json count different numbers")
    claims_redactions = "redacted_count" in manifest or manifest["format"] >= 2
    if claims_redactions and manifest.get("redacted_count") != len(redacted):
        fault(
            f"manifest.json says {manifest.get('redacted_count')} entries are redacted; "
            f"{len(redacted)} are"
        )
    first = chain[0]["number"] if chain else None
    last = chain[-1]["number"] if chain else None
    if (manifest["first_number"], manifest["last_number"]) != (first, last):
        fault("the first and last number in manifest.json are not the chain's")
    if manifest["rules_versions"] != sorted(versions):
        fault(
            f"manifest.json lists hash rules versions {manifest['rules_versions']}; "
            f"the entries use {sorted(versions)}"
        )

    project = manifest["project"]
    summary = {
        "name": f"{project['name']} ({project['prefix']})",
        "count": len(chain),
        "first": first,
        "last": last,
        "last_hash": previous,
        "redacted": redacted,
        "versions": versions,
        "entries": entries,
    }
    return faults, summary


def check_bundle(files):
    manifest = files["manifest.json"]
    if manifest.get("format") not in BUNDLE_FORMATS:
        raise CannotCheck(
            f"this is bundle format {manifest.get('format')}; "
            f"{known_formats('bundle', BUNDLE_FORMATS)}. A newer script is needed."
        )
    faults, summary = check_project(manifest, files["entries.json"], files["chain.json"], "")
    return faults, [summary]


def check_full_export(data):
    if data.get("format") not in FULL_EXPORT_FORMATS:
        raise CannotCheck(
            f"this is full export format {data.get('format')}; "
            f"{known_formats('full export', FULL_EXPORT_FORMATS)}. A newer script is needed."
        )
    faults = []
    summaries = []
    versions = set()
    for part in data["projects"]:
        manifest = part["manifest"]
        if manifest.get("format") not in BUNDLE_FORMATS:
            raise CannotCheck(
                f"{manifest['project']['name']} is in bundle format {manifest.get('format')}; "
                f"{known_formats('bundle', BUNDLE_FORMATS)}. A newer script is needed."
            )
        where = f"{manifest['project']['name']} ({manifest['project']['prefix']})"
        project_faults, summary = check_project(manifest, part["entries"], part["chain"], where)
        if part["project"]["id"] != manifest["project"]["id"]:
            project_faults.append(f"{where}: its details name another project")
        faults.extend(project_faults)
        summaries.append(summary)
        versions |= summary["versions"]
    if data["rules_versions"] != sorted(versions):
        faults.append(
            f"the export lists hash rules versions {data['rules_versions']}; "
            f"its entries use {sorted(versions)}"
        )
    return faults, summaries


def fingerprint(path):
    digest = hashlib.sha256()
    try:
        with open(path, "rb") as handle:
            for block in iter(lambda: handle.read(1024 * 1024), b""):
                digest.update(block)
    except OSError:
        raise CannotCheck(f"the file {path} cannot be read") from None
    return digest.hexdigest()


def carrying(summaries, digest, single):
    """Where a fingerprint appears: one line per file reference that has it."""
    found = []
    for summary in summaries:
        for item in summary["entries"]:
            record = item["record"]
            if record is None:
                continue  # its fingerprints went with its record
            for reference in record.get("files") or []:
                if str(reference.get("sha256", "")).lower() == digest:
                    place = f"entry {item['number']}"
                    if not single:
                        place = f"{place} of {summary['name']}"
                    found.append(f"{place}, as {reference.get('path', '')}")
    return found


def report(kind, checked, faults, summaries, fingerprints):
    single = kind == "bundle"
    for message in faults:
        print("FAULT:", message)
    if not faults:
        for summary in summaries:
            if summary["count"]:
                if summary["first"] == summary["last"]:
                    numbers = f"number {summary['first']}"
                else:
                    numbers = f"numbers {summary['first']} to {summary['last']}"
                print(
                    f"OK: {counted(summary['count'], 'entry', 'entries')} in {summary['name']}, "
                    f"{numbers}, last chain hash {summary['last_hash']}"
                )
            else:
                print(f"OK: no entries locked in {summary['name']}")
    for summary in summaries:
        if len(summary["redacted"]) == 1:
            which, whose = "entry", "its"
        else:
            which, whose = "entries", "their"
        if summary["redacted"]:
            where = "" if single else f"{summary['name']}, "
            print(
                f"REDACTED: {where}{which} {listed(summary['redacted'])}; "
                f"{whose} content cannot be checked"
            )
    for path, digest in fingerprints:
        name = os.path.basename(path)
        found = carrying(summaries, digest, single)
        if found:
            print(f"FILE {name}: SHA-256 {digest}")
            for place in found:
                print(f"  in {place}")
        else:
            print(f"FILE {name}: SHA-256 {digest}; no entry here carries this fingerprint")
    versions = set()
    for summary in summaries:
        versions |= summary["versions"]
    what = checked
    if versions:
        rules = "hash rules version" if len(versions) == 1 else "hash rules versions"
        what = f"{what}, {rules} {in_words(sorted(versions))}"
    if not single:
        entries = sum(summary["count"] for summary in summaries)
        projects = counted(len(summaries), "project", "projects")
        what = f"{what}, {projects}, {counted(entries, 'entry', 'entries')}"
    if faults:
        verdict = f"{counted(len(faults), 'fault', 'faults')} found."
    else:
        verdict = "Everything checks out."
    print(f"Checked by nilsaro-loggen-verify {VERSION}: {what}. {verdict}")


def main(argv=None):
    parser = argparse.ArgumentParser(
        prog="nilsaro-loggen-verify.py",
        description="Check a NILSARO-LOGGEN evidence bundle or full export, by the rules in "
        "VERIFY.md.",
    )
    parser.add_argument("path", help="the bundle (ZIP file or unpacked folder) or full export")
    parser.add_argument(
        "--file",
        action="append",
        default=[],
        metavar="PATH",
        help="a file to look for by its SHA-256 among the entries' file references",
    )
    parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
    arguments = parser.parse_args(argv)
    # A name the console cannot show is shown as ? rather than stopping the check.
    reconfigure = getattr(sys.stdout, "reconfigure", None)
    if reconfigure is not None:
        reconfigure(errors="replace")
    try:
        fingerprints = [(path, fingerprint(path)) for path in arguments.file]
        kind, given = load(arguments.path)
        try:
            if kind == "bundle":
                faults, summaries = check_bundle(given)
                checked = f"evidence bundle, format {given['manifest.json']['format']}"
            else:
                faults, summaries = check_full_export(given)
                checked = f"full export, format {given['format']}"
        except (KeyError, TypeError, AttributeError, IndexError) as missing:
            raise CannotCheck(
                f"the {kind} is not laid out as VERIFY.md describes ({missing!r})"
            ) from None
        report(kind, checked, faults, summaries, fingerprints)
    except CannotCheck as reason:
        print(f"CANNOT CHECK: {reason}")
        return 2
    return 1 if faults else 0


if __name__ == "__main__":
    sys.exit(main())
