#!/usr/bin/env python3
"""Classify a ci.yml run as code or docs-only (REQ-CI-DOCS-ONLY-THIN, IR-147).

ONE classifier for both arms, so the pull_request rule and the push rule can
never drift apart:

- pull_request: the files the PR merge commit changes (HEAD^1..HEAD), as before.
- push: the files the WHOLE push changed, github.event.before..github.sha.
  Never HEAD^1..HEAD on a push. Under ff-only main, HEAD^1 is the previous
  main only for a one-commit lane; a multi-commit lane (#254 landed five) puts
  HEAD^1 inside the lane, and a classifier reading only the last commit would
  skip unit on a merge whose product change sat earlier. That false negative is
  worse than the wasted run IR-147 exists to remove.

The default is code=true. Only a clean, fully resolved, non-empty, docs-only
range turns it false. Every conservative exit names its reason on a
`CLASSIFY code=... reason=...` line, the way the IR-144 helper does.
"""

import os
import subprocess
import sys

ZERO_SHA = "0" * 40
# Bounded deepening along github.sha until event.before is reachable. The
# checkout is fetch-depth 2; a lane is rarely more than a handful of commits.
DEEPEN_STEPS = (50, 500)


# Markdown that is compiled into a binary or test is CODE, whatever its
# extension says. The selftest re-derives this set from every
# `include_str!`/`include_bytes!` of a `.md` in the tree and fails if they
# disagree, so a new compiled-in doc cannot slip through as "docs".
COMPILED_MARKDOWN = frozenset({"docs/ER-SKELETON.md"})


def is_docs_path(path):
    """True ONLY for paths positively known not to affect lint or unit:
    the declarative traceable-reqs.toml registry, repo-root Markdown, and
    Markdown under docs/ that nothing compiles in. Everything else is code,
    including .github/** (a CI change decides whether units run),
    docs-site/** (built and link-checked), crates/** and any path this list
    does not name."""
    if path == "traceable-reqs.toml":
        return True
    if not path.endswith(".md") or path in COMPILED_MARKDOWN:
        return False
    if "/" not in path:
        return True
    return path.startswith("docs/")


def classify_files(files):
    """(code, reason) for a resolved, non-empty file list."""
    code_files = [f for f in files if not is_docs_path(f)]
    if code_files:
        return True, "code-path:" + code_files[0]
    return False, "docs-only"


class Git:
    """The only process boundary. The selftest replaces it."""

    def run(self, *args):
        """(returncode, stdout)."""
        p = subprocess.run(["git", *args], capture_output=True, text=True)
        return p.returncode, p.stdout


def changed_files(git, base, head):
    rc, out = git.run("diff", "--name-only", base, head)
    if rc != 0:
        return None
    return [line for line in out.splitlines() if line.strip()]


def classify_push(git, before, sha, forced):
    """(code, reason, files, probe) for a push whose `before` is a real,
    unforced commit; `classify` handles before-zero and forced-push first."""
    assert before and before != ZERO_SHA and not forced
    # Deepen until the GRAPH from sha connects to before, not until the before
    # OBJECT exists. A self-hosted runner reuses its workspace, so a stale
    # before object from an earlier run can be present while the current
    # depth-2 history is grafted short of it (measured on kitsubito, main run
    # 35998753683: c185326b present, 0854bbe8 grafted with no parents,
    # is-ancestor exit 1 on a true ancestor).
    present = connected = False
    connected_at = "none"
    for step in (0, *DEEPEN_STEPS):
        if step:
            git.run("fetch", "--no-tags", f"--deepen={step}", "origin", sha)
        present = git.run("cat-file", "-e", f"{before}^{{commit}}")[0] == 0
        connected = present and git.run("merge-base", "--is-ancestor", before, sha)[0] == 0
        if connected:
            connected_at = "step0" if not step else f"deepen{step}"
            break
    # IR-148: shallowness is read ONCE, here, at decision time. The probe
    # line reports this value, and the undecidable/not-ancestor split below
    # uses the SAME value, so the log can never show a second, later state
    # under the same name.
    rc, out = git.run("rev-parse", "--is-shallow-repository")
    shallow = "true" if rc != 0 or out.strip() != "false" else "false"
    probe = {"connected_at": connected_at, "shallow": shallow}
    if not connected:
        if not present:
            return True, "before-unreachable", [], probe
        # is-ancestor answers "no" both for a real non-ancestor and for a graph
        # still too shallow to decide. Only a complete graph makes "no" a fact.
        if shallow == "true":
            return True, "before-undecidable", [], probe
        return True, "before-not-ancestor", [], probe
    files = changed_files(git, before, sha)
    if files is None:
        return True, "diff-failed", [], probe
    if not files:
        return True, "empty-range", [], probe
    code, reason = classify_files(files)
    return code, reason, files, probe


# The probe for any outcome that never ran the ancestry search.
NOT_PROBED = {"connected_at": "not-probed", "shallow": "n/a"}


def classify(git, env):
    """(code, reason, files, probe) for this run's event."""
    event = env.get("GITHUB_EVENT_NAME", "")
    if event == "pull_request":
        files = changed_files(git, "HEAD^1", "HEAD")
        if files is None:
            return True, "diff-failed", [], NOT_PROBED
        if not files:
            return True, "empty-range", [], NOT_PROBED
        code, reason = classify_files(files)
        return code, reason, files, NOT_PROBED
    if event == "push":
        before = env.get("CLASSIFY_BEFORE", "")
        forced = env.get("CLASSIFY_FORCED", "false") == "true"
        if not before or before == ZERO_SHA:
            return True, "before-zero", [], NOT_PROBED
        if forced:
            return True, "forced-push", [], NOT_PROBED
        return classify_push(git, before, env.get("GITHUB_SHA", ""), forced)
    return True, "event-" + (event or "unknown"), [], NOT_PROBED


def main(git=None, env=None, out=sys.stdout):
    git = git or Git()
    env = os.environ if env is None else env
    try:
        code, reason, files, probe = classify(git, env)
    except Exception as e:  # any surprise runs units, loudly
        code, reason, files, probe = True, f"classifier-error:{type(e).__name__}", [], NOT_PROBED
    print("changed files:", file=out)
    for f in files:
        print(f, file=out)
    verdict = "true" if code else "false"
    # [impl->REQ-CI-CLASSIFY-PROBE-WITNESS] the mechanism line (IR-148): which
    # step connected before..sha, and whether the graph was shallow then.
    print(f"CLASSIFY_PROBE connected_at={probe['connected_at']} shallow={probe['shallow']}", file=out)
    print(f"CLASSIFY code={verdict} reason={reason}", file=out)
    target = env.get("GITHUB_OUTPUT")
    if target:
        with open(target, "a", encoding="utf-8") as fh:
            fh.write(f"code={verdict}\n")
    return 0


if __name__ == "__main__":
    sys.exit(main())
