#!/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) for a push event."""
    if not before or before == ZERO_SHA:
        return True, "before-zero", []
    if forced:
        return True, "forced-push", []
    # 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
    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:
            break
    if not connected:
        if not present:
            return True, "before-unreachable", []
        # 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.
        rc, out = git.run("rev-parse", "--is-shallow-repository")
        if rc != 0 or out.strip() != "false":
            return True, "before-undecidable", []
        return True, "before-not-ancestor", []
    files = changed_files(git, before, sha)
    if files is None:
        return True, "diff-failed", []
    if not files:
        return True, "empty-range", []
    code, reason = classify_files(files)
    return code, reason, files


def classify(git, env):
    """(code, reason, files) 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", []
        if not files:
            return True, "empty-range", []
        code, reason = classify_files(files)
        return code, reason, files
    if event == "push":
        return classify_push(
            git,
            env.get("CLASSIFY_BEFORE", ""),
            env.get("GITHUB_SHA", ""),
            env.get("CLASSIFY_FORCED", "false") == "true",
        )
    return True, "event-" + (event or "unknown"), []


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 = classify(git, env)
    except Exception as e:  # any surprise runs units, loudly
        code, reason, files = True, f"classifier-error:{type(e).__name__}", []
    print("changed files:", file=out)
    for f in files:
        print(f, file=out)
    verdict = "true" if code else "false"
    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())
