#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-or-later

"""Render the documentation tables from canonical YAML and verify them.

docs/tables/*.yaml is the single source of truth for the tables below. This script
renders the markdown between `<!-- gen:NAME -->` and `<!-- /gen:NAME -->` markers,
then either checks that the committed documents match (default) or rewrites them
(--write).

It also cross-checks the machine definitions against docs/event-catalogue.md and
docs/glossary.md, so the documentation half of the conformance gate is mechanical:

- every transition event is catalogued, and every catalogued event naming a machine
  appears in that machine's transitions;
- every machine state is in the matching glossary section and vice versa;
- terminal states are declared states, are not reopened, and every transition
  endpoint is a declared state or a defined wildcard.
"""

import argparse
import re
import sys
from pathlib import Path

import yaml

PROJECT_ROOT = Path(__file__).resolve().parent.parent
TABLES_DIR = PROJECT_ROOT / "docs" / "tables"
CONTROL_PLANE = PROJECT_ROOT / "docs" / "control-plane.md"
EXPERIMENT = PROJECT_ROOT / "docs" / "experiment.md"
EVENT_CATALOGUE = PROJECT_ROOT / "docs" / "event-catalogue.md"
GLOSSARY = PROJECT_ROOT / "docs" / "glossary.md"

# Canonical machine keys shared by the event catalogue "Affected machine" column
# and the transition tables.
MACHINE_ALIASES = {
    "node": "node",
    "attempt": "attempt",
    "artefact": "artefact",
    "accounting": "accounting",
    "freshness": "freshness",
}

MACHINES = {
    "node-lifecycle.yaml": {
        "doc": CONTROL_PLANE,
        "machine": "node",
        "states_region": "node-states",
        "terminal_region": "node-terminal",
        "transitions_region": "node-transitions",
        "headers": ["Current state", "Event", "Guard", "Next state"],
        "glossary_heading": "## Node states",
    },
    "attempt-lifecycle.yaml": {
        "doc": CONTROL_PLANE,
        "machine": "attempt",
        "states_region": "attempt-states",
        "terminal_region": "attempt-terminal",
        "transitions_region": "attempt-transitions",
        "headers": ["Current state", "Event", "Guard", "Next state"],
        "glossary_heading": "## Attempt states",
    },
    "artefact-lifecycle.yaml": {
        "doc": CONTROL_PLANE,
        "machine": "artefact",
        "states_region": "artefact-states",
        "terminal_region": "artefact-terminal",
        "transitions_region": "artefact-transitions",
        "headers": ["Current state", "Event", "Guard", "Next state"],
        "glossary_heading": "## Artefact states",
    },
    "accounting-lifecycle.yaml": {
        "doc": CONTROL_PLANE,
        "machine": "accounting",
        "states_region": "accounting-states",
        "terminal_region": None,
        "transitions_region": "accounting-transitions",
        "headers": ["Current accounting state", "Event", "Guard", "Next accounting state"],
        "glossary_heading": "## Accounting states",
    },
    "result-freshness.yaml": {
        "doc": CONTROL_PLANE,
        "machine": "freshness",
        "states_region": "freshness-states",
        "terminal_region": "freshness-terminal",
        "transitions_region": "freshness-transitions",
        "headers": ["Current state", "Event", "Guard", "Next state"],
        "glossary_heading": "## Result freshness values",
    },
}

ROWS = {
    "policy-completeness.yaml": {
        "doc": CONTROL_PLANE,
        "region": "policy-completeness",
        "headers": ["Condition", "Applicable source states", "Required event path"],
    },
    "run-outcomes.yaml": {
        "doc": CONTROL_PLANE,
        "region": "run-outcomes",
        "headers": ["Root condition", "Run outcome"],
    },
}

YAML_BLOCKS = {
    "limits.yaml": {
        "doc": EXPERIMENT,
        "region": "limits",
    },
}


class DocumentError(Exception):
    """Raised when a source document cannot be parsed or rendered unambiguously."""


def display_path(path):
    try:
        return path.relative_to(PROJECT_ROOT)
    except ValueError:
        return path


MACHINE_KEYS = ("machine", "states", "terminal", "transitions")
TRANSITION_KEYS = ("from", "event", "to", "guard")


def validate_machine(filename, data):
    for key in MACHINE_KEYS:
        if key not in data:
            raise DocumentError(f"{filename}: missing required key '{key}'")
    for key in ("states", "terminal", "transitions"):
        if not isinstance(data[key], list):
            raise DocumentError(f"{filename}: '{key}' must be a list")
    for index, transition in enumerate(data["transitions"]):
        if not isinstance(transition, dict):
            raise DocumentError(f"{filename}: transition {index} is not a mapping")
        for key in TRANSITION_KEYS:
            if key not in transition:
                raise DocumentError(
                    f"{filename}: transition {index} is missing required key '{key}'"
                )


def validate_rows(filename, data):
    if "rows" not in data:
        raise DocumentError(f"{filename}: missing required key 'rows'")
    if not isinstance(data["rows"], list):
        raise DocumentError(f"{filename}: 'rows' must be a list")
    headers = ROWS[filename]["headers"]
    for index, row in enumerate(data["rows"]):
        if not isinstance(row, dict):
            raise DocumentError(f"{filename}: row {index} is not a mapping")
        for header in headers:
            if header not in row:
                raise DocumentError(
                    f"{filename}: row {index} is missing required key '{header}'"
                )


def load_yaml(filename):
    with open(TABLES_DIR / filename) as handle:
        data = yaml.safe_load(handle)
    if not isinstance(data, dict):
        raise DocumentError(f"{filename}: expected a mapping at the top level")
    if filename in MACHINES:
        validate_machine(filename, data)
    elif filename in ROWS:
        validate_rows(filename, data)
    return data


def state_name(entry):
    return entry["name"] if isinstance(entry, dict) else entry


def state_names(data):
    return [state_name(entry) for entry in data["states"]]


def render_state_list(data, field):
    lines = []
    for entry in data[field]:
        if isinstance(entry, dict):
            name = entry["name"]
            description = entry.get("description")
        else:
            name = entry
            description = None
        if description:
            lines.append(f"- `{name}`: {description}")
        else:
            lines.append(f"- `{name}`")
    return lines


def assert_no_pipe(filename, cells):
    for cell in cells:
        if "|" in cell:
            raise DocumentError(
                f"{filename}: cell {cell!r} contains a literal '|', which would break "
                f"the generated markdown table"
            )


def render_transitions(filename, data, headers):
    wildcards = data.get("wildcards") or {}
    lines = [
        "| " + " | ".join(headers) + " |",
        "|" + "---|" * len(headers),
    ]
    for transition in data["transitions"]:
        source = transition["from"]
        if source is None:
            source_text = "None"
        elif source in wildcards:
            source_text = wildcards[source]["label"]
        else:
            source_text = f"`{source}`"
        cells = [
            source_text,
            f"`{transition['event']}`",
            transition["guard"],
            f"`{transition['to']}`",
        ]
        assert_no_pipe(filename, cells)
        lines.append("| " + " | ".join(cells) + " |")
    return lines


def render_rows(filename, rows, headers):
    lines = [
        "| " + " | ".join(headers) + " |",
        "|" + "---|" * len(headers),
    ]
    for row in rows:
        cells = [str(row[header]) for header in headers]
        assert_no_pipe(filename, cells)
        lines.append("| " + " | ".join(cells) + " |")
    return lines


def render_yaml_block(data):
    text = yaml.safe_dump(data, sort_keys=False, default_flow_style=False).rstrip("\n")
    return ["```yaml"] + text.split("\n") + ["```"]


def doc_meta(filename):
    for registry in (MACHINES, ROWS, YAML_BLOCKS):
        if filename in registry:
            return registry[filename]
    raise KeyError(filename)


def regions_for(filename):
    """Yield (region_name, content_lines) for one canonical YAML file."""
    if filename in MACHINES:
        meta = MACHINES[filename]
        data = load_yaml(filename)
        yield meta["states_region"], render_state_list(data, "states")
        if meta["terminal_region"]:
            yield meta["terminal_region"], render_state_list(data, "terminal")
        yield meta["transitions_region"], render_transitions(
            filename, data, meta["headers"]
        )
    elif filename in ROWS:
        meta = ROWS[filename]
        data = load_yaml(filename)
        yield meta["region"], render_rows(filename, data["rows"], meta["headers"])
    elif filename in YAML_BLOCKS:
        meta = YAML_BLOCKS[filename]
        yield meta["region"], render_yaml_block(load_yaml(filename))


def splice(text, region, content):
    begin = f"<!-- gen:{region} -->"
    end = f"<!-- /gen:{region} -->"
    lines = text.split("\n")
    if begin not in lines:
        raise KeyError(f"missing marker '{begin}'")
    if end not in lines:
        raise KeyError(f"missing marker '{end}'")
    start = lines.index(begin)
    stop = lines.index(end)
    if stop <= start:
        raise KeyError(f"markers out of order for '{region}'")
    return "\n".join(lines[: start + 1] + [""] + content + [""] + lines[stop:])


def render_documents():
    documents = {}
    for filename in list(MACHINES) + list(ROWS) + list(YAML_BLOCKS):
        doc = doc_meta(filename)["doc"]
        documents.setdefault(doc, doc.read_text())
        for region, content in regions_for(filename):
            documents[doc] = splice(documents[doc], region, content)
    return documents


def first_difference(expected, actual):
    expected_lines = expected.split("\n")
    actual_lines = actual.split("\n")
    for number, (want, got) in enumerate(zip(expected_lines, actual_lines), start=1):
        if want != got:
            return number, want, got
    if len(expected_lines) != len(actual_lines):
        number = min(len(expected_lines), len(actual_lines)) + 1
        want = expected_lines[number - 1] if number <= len(expected_lines) else "<eof>"
        got = actual_lines[number - 1] if number <= len(actual_lines) else "<eof>"
        return number, want, got
    return None


def check_generated(documents):
    errors = []
    for doc, expected in documents.items():
        actual = doc.read_text()
        if actual == expected:
            continue
        difference = first_difference(expected, actual)
        if difference:
            number, want, got = difference
            errors.append(
                f"{display_path(doc)}:{number}: generated content is out of "
                f"date; expected {want!r} but found {got!r}. Run scripts/tables.py --write"
            )
        else:
            errors.append(
                f"{display_path(doc)}: generated content is out of date. "
                f"Run scripts/tables.py --write"
            )
    return errors


def strip_cell(cell):
    cell = cell.strip().strip("|").strip()
    cell = cell.replace("`", "")
    cell = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", cell)
    return cell.strip()


def parse_markdown_tables(doc_path):
    lines = doc_path.read_text().split("\n")
    tables = []
    columns = None
    rows = []

    for number, line in enumerate(lines, start=1):
        stripped = line.strip()
        if not stripped.startswith("|"):
            if columns is not None:
                tables.append(rows)
                columns = None
                rows = []
            continue

        cells = [strip_cell(c) for c in stripped.split("|")[1:-1]]

        if columns is None:
            columns = cells
            continue

        if re.match(r"^[\|:\-\s]+$", stripped):
            continue

        if len(cells) != len(columns):
            raise DocumentError(
                f"{display_path(doc_path)}:{number}: expected "
                f"{len(columns)} cells but found {len(cells)}; escape a literal '|' "
                f"inside a cell as '\\|'"
            )

        rows.append(dict(zip(columns, cells)))

    if columns is not None:
        tables.append(rows)

    return tables


def parse_glossary_list(doc_path, heading):
    lines = doc_path.read_text().split("\n")
    in_section = False
    values = []

    for line in lines:
        stripped = line.strip()
        if not in_section:
            if stripped == heading:
                in_section = True
            continue
        if stripped.startswith("## "):
            break
        match = re.match(r"^-\s+`([^`]+)`", stripped)
        if match:
            values.append(match.group(1))

    return values


def normalize_machine(part):
    part = part.strip().lower().replace(" state machine", "").strip()
    return MACHINE_ALIASES.get(part)


def affected_machines(value):
    machines = set()
    for part in value.split("+"):
        machine = normalize_machine(part)
        if machine:
            machines.add(machine)
    return machines


def load_event_catalogue():
    events = {}
    for table in parse_markdown_tables(EVENT_CATALOGUE):
        if not table:
            continue
        if "Event" not in table[0] or "Affected machine" not in table[0]:
            continue
        for row in table:
            events[row["Event"]] = row["Affected machine"]
    return events


def expand_from(value, data):
    """Expand a wildcard source into the concrete states it selects.

    Only state-level predicates in ``select`` are applied here (currently
    ``terminal``). Entity-level predicates such as ``root`` describe the entity
    rather than its state and must be applied by the loader that resolves the
    transition; they are deliberately not treated as a state-set expansion.
    """
    if value is None:
        return set()
    wildcards = data.get("wildcards") or {}
    if value in wildcards:
        select = wildcards[value].get("select") or {}
        expanded = set()
        for entry in data["states"]:
            name = state_name(entry)
            if select.get("terminal") is False and name in data["terminal"]:
                continue
            expanded.add(name)
        return expanded
    return {value}


def check_events(catalogue):
    errors = []
    for filename, meta in MACHINES.items():
        data = load_yaml(filename)
        machine = meta["machine"]
        events = {transition["event"] for transition in data["transitions"]}
        for event in sorted(events):
            if event not in catalogue:
                errors.append(f"{filename}: event '{event}' is not in event-catalogue.md")
            elif machine not in affected_machines(catalogue[event]):
                errors.append(
                    f"event-catalogue.md: event '{event}' is used by {filename} but its "
                    f"'Affected machine' does not name '{machine}'"
                )
        for event, affected in catalogue.items():
            if machine in affected_machines(affected) and event not in events:
                errors.append(
                    f"event-catalogue.md: event '{event}' names machine '{machine}' "
                    f"but is absent from {filename}"
                )
    return errors


def check_states():
    errors = []
    for filename, meta in MACHINES.items():
        data = load_yaml(filename)
        declared = state_names(data)
        declared_set = set(declared)
        terminal = set(data["terminal"])
        glossary = set(parse_glossary_list(GLOSSARY, meta["glossary_heading"]))

        for state in declared:
            if state not in glossary:
                errors.append(
                    f"{filename}: state '{state}' is not in glossary "
                    f"'{meta['glossary_heading']}'"
                )
        for state in sorted(glossary - declared_set):
            errors.append(
                f"glossary.md: state '{state}' in '{meta['glossary_heading']}' "
                f"is absent from {filename}"
            )
        for state in sorted(terminal - declared_set):
            errors.append(f"{filename}: terminal state '{state}' is not a declared state")

        wildcards = data.get("wildcards") or {}
        for transition in data["transitions"]:
            source = transition["from"]
            if source is not None and source not in declared_set and source not in wildcards:
                errors.append(
                    f"{filename}: transition '{transition['event']}' has undeclared "
                    f"source '{source}'"
                )
            if transition["to"] not in declared_set:
                errors.append(
                    f"{filename}: transition '{transition['event']}' has undeclared "
                    f"target '{transition['to']}'"
                )
            reopened = expand_from(source, data) & terminal
            if reopened and transition["to"] not in terminal:
                errors.append(
                    f"{filename}: transition '{transition['event']}' reopens terminal "
                    f"state '{sorted(reopened)[0]}'"
                )
    return errors


def check_reachability():
    errors = []
    for filename, meta in MACHINES.items():
        data = load_yaml(filename)
        declared = state_names(data)
        terminal = set(data["terminal"])
        wildcards = data.get("wildcards") or {}

        incoming = set()
        outgoing = set()
        for transition in data["transitions"]:
            target = transition["to"]
            if target in wildcards:
                incoming |= expand_from(target, data)
            else:
                incoming.add(target)
            outgoing |= expand_from(transition["from"], data)

        for state in declared:
            if state not in incoming:
                errors.append(f"{filename}: state '{state}' has no incoming transition")
            if state not in terminal and state not in outgoing:
                errors.append(
                    f"{filename}: non-terminal state '{state}' has no outgoing transition"
                )
    return errors


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--write",
        action="store_true",
        help="rewrite the generated regions in place instead of checking them",
    )
    args = parser.parse_args()

    try:
        documents = render_documents()
    except DocumentError as error:
        print("Table verification failed:", file=sys.stderr)
        print(f"  {error}", file=sys.stderr)
        sys.exit(1)

    if args.write:
        for doc, content in documents.items():
            doc.write_text(content)
        print("Generated regions updated.")
        return

    errors = check_generated(documents)
    try:
        catalogue = load_event_catalogue()
        errors.extend(check_events(catalogue))
        errors.extend(check_states())
        errors.extend(check_reachability())
    except DocumentError as error:
        errors.append(str(error))

    if errors:
        print("Table verification failed:", file=sys.stderr)
        for error in dict.fromkeys(errors):
            print(f"  {error}", file=sys.stderr)
        sys.exit(1)

    print("Table verification passed.")


if __name__ == "__main__":
    main()
