#!/usr/bin/env python3
"""Offline behavior checks for classify-changes.py; no git repo or runner."""

import importlib.util
import io
import os
from pathlib import Path
import re
import tempfile
import unittest

_spec = importlib.util.spec_from_file_location(
    "classify_changes", Path(__file__).with_name("classify-changes.py"))
cc = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cc)

BEFORE = "b" * 40
SHA = "c" * 40


class FakeGit:
    """Answers git calls from a table; records every call."""

    def __init__(self, diffs=None, reachable_after=0, ancestor=True, raise_on=None,
                 ancestor_after_fetches=0, shallow=True):
        self.diffs = diffs or {}
        self.reachable_after = reachable_after  # cat-file succeeds on the Nth probe (0 = never)
        self.ancestor = ancestor
        # is-ancestor answers False until this many deepen fetches have run: the
        # stale-object shallow graft, where the OBJECT is present but the
        # graph from sha does not reach it yet (measured on kitsubito, IR-147).
        self.ancestor_after_fetches = ancestor_after_fetches
        self.shallow = shallow
        self.raise_on = raise_on
        self.calls = []
        self.probes = 0
        self.fetches = 0

    def run(self, *args):
        self.calls.append(args)
        if self.raise_on and args[0] == self.raise_on:
            raise OSError("boom")
        if args[0] == "cat-file":
            self.probes += 1
            ok = self.reachable_after and self.probes >= self.reachable_after
            return (0 if ok else 1), ""
        if args[0] == "merge-base":
            linked = self.fetches >= self.ancestor_after_fetches
            return (0 if (self.ancestor and linked) else 1), ""
        if args[0] == "fetch":
            self.fetches += 1
            return 0, ""
        if args[0] == "rev-parse":
            return 0, ("true" if self.shallow else "false") + "\n"
        if args[0] == "diff":
            key = (args[2], args[3])
            if key not in self.diffs:
                return 128, ""
            return 0, "\n".join(self.diffs[key]) + "\n"
        raise AssertionError(f"unexpected git call {args}")


def run(git, **env):
    out = io.StringIO()
    with tempfile.TemporaryDirectory() as d:
        target = os.path.join(d, "out")
        env = {"GITHUB_OUTPUT": target, **env}
        cc.main(git=git, env=env, out=out)
        with open(target, encoding="utf-8") as fh:
            written = fh.read()
    line = [l for l in out.getvalue().splitlines() if l.startswith("CLASSIFY ")]
    assert len(line) == 1, out.getvalue()
    return line[0], written


def push(git, before=BEFORE, forced="false"):
    return run(git, GITHUB_EVENT_NAME="push", GITHUB_SHA=SHA,
               CLASSIFY_BEFORE=before, CLASSIFY_FORCED=forced)


# [unit->REQ-CI-PUSH-DOCS-ONLY-THIN]
class PullRequestArm(unittest.TestCase):
    def pr(self, files):
        return run(FakeGit({("HEAD^1", "HEAD"): files}), GITHUB_EVENT_NAME="pull_request")

    def test_docs_only_pr_is_thin(self):
        line, out = self.pr(["docs/RELEASE-RUNBOOK.md", "traceable-reqs.toml"])
        self.assertEqual(line, "CLASSIFY code=false reason=docs-only")
        self.assertEqual(out, "code=false\n")

    def test_code_and_docs_site_prs_run_units(self):
        self.assertIn("code=true reason=code-path:crates/x.rs", self.pr(["README.md", "crates/x.rs"])[0])
        self.assertIn("code=true reason=code-path:docs-site/src/a.md", self.pr(["docs-site/src/a.md"])[0])


# [unit->REQ-CI-PUSH-DOCS-ONLY-THIN]
class PushArm(unittest.TestCase):
    def test_docs_only_push_range_is_thin(self):
        g = FakeGit({(BEFORE, SHA): ["docs/INFRA-REGISTER.md"]}, reachable_after=1)
        line, out = push(g)
        self.assertEqual(line, "CLASSIFY code=false reason=docs-only")
        self.assertEqual(out, "code=false\n")

    def test_multi_commit_push_reads_the_whole_range_not_head_parent(self):
        # The IR-147 false negative: the LAST commit of the lane is docs-only,
        # an EARLIER one is code. HEAD^1..HEAD would say docs-only; the push
        # range must say code.
        g = FakeGit({
            (BEFORE, SHA): ["crates/xtask/src/bundle.rs", "docs/INFRA-REGISTER.md"],
            ("HEAD^1", "HEAD"): ["docs/INFRA-REGISTER.md"],
        }, reachable_after=1)
        line, out = push(g)
        self.assertEqual(line, "CLASSIFY code=true reason=code-path:crates/xtask/src/bundle.rs")
        self.assertEqual(out, "code=true\n")
        self.assertNotIn(("diff", "--name-only", "HEAD^1", "HEAD"), g.calls)

    def test_before_zero_runs_units(self):
        self.assertEqual(push(FakeGit(), before="0" * 40)[0], "CLASSIFY code=true reason=before-zero")
        self.assertEqual(push(FakeGit(), before="")[0], "CLASSIFY code=true reason=before-zero")

    def test_forced_push_runs_units(self):
        g = FakeGit({(BEFORE, SHA): ["a.md"]}, reachable_after=1)
        self.assertEqual(push(g, forced="true")[0], "CLASSIFY code=true reason=forced-push")

    def test_before_reached_only_after_deepening(self):
        g = FakeGit({(BEFORE, SHA): ["a.md"]}, reachable_after=2)
        self.assertEqual(push(g)[0], "CLASSIFY code=false reason=docs-only")
        fetches = [c for c in g.calls if c[0] == "fetch"]
        self.assertEqual(fetches, [("fetch", "--no-tags", "--deepen=50", "origin", SHA)])

    def test_before_unreachable_after_bounded_deepening_runs_units(self):
        g = FakeGit({(BEFORE, SHA): ["a.md"]}, reachable_after=0)
        self.assertEqual(push(g)[0], "CLASSIFY code=true reason=before-unreachable")
        fetches = [c[2] for c in g.calls if c[0] == "fetch"]
        self.assertEqual(fetches, ["--deepen=50", "--deepen=500"], "deepening is bounded")

    def test_before_not_ancestor_on_a_complete_graph_runs_units(self):
        g = FakeGit({(BEFORE, SHA): ["a.md"]}, reachable_after=1, ancestor=False, shallow=False)
        self.assertEqual(push(g)[0], "CLASSIFY code=true reason=before-not-ancestor")

    def test_present_object_on_a_shallow_graft_still_deepens(self):
        # IR-147 field defect (main run 35998753683): a STALE before object is
        # present, so a presence probe passes at step 0, but the depth-2 graph
        # from sha is grafted short of it and is-ancestor says no. Presence
        # must not stop the deepening; connectivity must.
        g = FakeGit({(BEFORE, SHA): ["docs/a.md"]}, reachable_after=1, ancestor_after_fetches=1)
        self.assertEqual(push(g)[0], "CLASSIFY code=false reason=docs-only")
        self.assertEqual([c[2] for c in g.calls if c[0] == "fetch"], ["--deepen=50"])

    def test_a_shallow_graph_that_never_decides_is_undecidable_not_non_ancestor(self):
        g = FakeGit({(BEFORE, SHA): ["docs/a.md"]}, reachable_after=1, ancestor=False, shallow=True)
        self.assertEqual(push(g)[0], "CLASSIFY code=true reason=before-undecidable")
        self.assertEqual([c[2] for c in g.calls if c[0] == "fetch"], ["--deepen=50", "--deepen=500"])

    def test_diff_failure_runs_units(self):
        g = FakeGit({}, reachable_after=1)
        self.assertEqual(push(g)[0], "CLASSIFY code=true reason=diff-failed")

    def test_empty_range_runs_units(self):
        g = FakeGit({(BEFORE, SHA): []}, reachable_after=1)
        self.assertEqual(push(g)[0], "CLASSIFY code=true reason=empty-range")


# [unit->REQ-CI-PUSH-DOCS-ONLY-THIN]
class DocsSetIsPositive(unittest.TestCase):
    def test_docs_set(self):
        for p in ["README.md", "CHANGELOG.md", "docs/INFRA-REGISTER.md",
                  "docs/adr/0050-golden-ci-merge-integration.md", "traceable-reqs.toml"]:
            self.assertTrue(cc.is_docs_path(p), p)

    def test_everything_else_is_code(self):
        for p in [".github/workflows/ci.yml", ".github/ci/classify-changes.py",
                  ".github/PULL_REQUEST_TEMPLATE.md", "docs-site/src/a.md",
                  "docs/ER-SKELETON.md", "crates/spt-daemon/tests/fixtures/enlyzeam/README.md",
                  "releases-repo/SYNC.md", "docs/instruments/ir21/bindeps-probe/Cargo.toml",
                  "Cargo.toml", "Cargo.lock", "crates/spt/src/cli.rs", "notes.txt"]:
            self.assertFalse(cc.is_docs_path(p), p)

    def test_a_ci_only_push_runs_units(self):
        g = FakeGit({(BEFORE, SHA): [".github/workflows/ci.yml", "docs/a.md"]}, reachable_after=1)
        self.assertEqual(push(g)[0], "CLASSIFY code=true reason=code-path:.github/workflows/ci.yml")

    def test_compiled_markdown_matches_the_tree(self):
        # Every .md the Rust tree compiles in must be in COMPILED_MARKDOWN,
        # and nothing else may be: the list is derived, not remembered.
        root = Path(__file__).resolve().parents[2]
        pat = re.compile(r'include_(?:str|bytes)!\(\s*"([^"]+\.md)"')
        found = set()
        for rs in root.rglob("*.rs"):
            parts = rs.relative_to(root).parts
            if parts[0] in ("target", ".worktrees") or "target" in parts:
                continue
            for rel in pat.findall(rs.read_text(encoding="utf-8", errors="replace")):
                found.add((rs.parent / rel).resolve().relative_to(root).as_posix())
        self.assertTrue(found, "control: the tree compiles in at least one .md today")
        self.assertEqual(found, set(cc.COMPILED_MARKDOWN))


# [unit->REQ-CI-PUSH-DOCS-ONLY-THIN]
class OtherEventsAndErrors(unittest.TestCase):
    def test_dispatch_runs_units(self):
        line, _ = run(FakeGit(), GITHUB_EVENT_NAME="workflow_dispatch")
        self.assertEqual(line, "CLASSIFY code=true reason=event-workflow_dispatch")

    def test_unexpected_exception_runs_units(self):
        g = FakeGit(raise_on="cat-file")
        line, out = push(g)
        self.assertEqual(line, "CLASSIFY code=true reason=classifier-error:OSError")
        self.assertEqual(out, "code=true\n")


if __name__ == "__main__":
    unittest.main(verbosity=2)
