"""Tests for the check-author-identity pre-commit hook.

All test data uses throwaway addresses (``*.test`` / ``example.com``) or public
GitHub noreply addresses; no real personal email appears in this file.
"""

import importlib.util
import json
from pathlib import Path

import pytest

# Load the hook module by path (hyphenated filename isn't importable directly).
_HOOK_PATH = (
    Path(__file__).resolve().parents[2]
    / ".pre-commit-hooks"
    / "check-author-identity.py"
)
_spec = importlib.util.spec_from_file_location(
    "check_author_identity", _HOOK_PATH
)
mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(mod)


PYPROJECT = """\
[project]
name = "x"
authors = [
    {name = "LearningCircuit", email = "185559241+LearningCircuit@users.noreply.github.com"},
    {name = "djpetti", email = "djpetti@example.com"},
]
"""
DECLARED = {
    "learningcircuit": {"185559241+learningcircuit@users.noreply.github.com"},
    "djpetti": {"djpetti@example.com"},
}


class TestParseIdentities:
    def test_parses_and_lowercases(self):
        assert mod.parse_identities(PYPROJECT) == DECLARED

    def test_key_order_independent(self):
        text = (
            'authors = [\n    {email = "bob@example.com", name = "Bob"},\n]\n'
        )
        assert mod.parse_identities(text) == {"bob": {"bob@example.com"}}

    def test_anchored_ignores_other_authors_keys(self):
        text = (
            'co_authors = ["x"]\n'
            'authors = [\n    {name = "A", email = "a@b.test"},\n]\n'
        )
        assert mod.parse_identities(text) == {"a": {"a@b.test"}}

    def test_no_block_returns_empty(self):
        assert mod.parse_identities('[project]\nname = "x"\n') == {}

    def test_multiple_emails_per_name(self):
        text = (
            "authors = [\n"
            '    {name = "A", email = "a@x.test"},\n'
            '    {name = "A", email = "a2@x.test"},\n'
            "]\n"
        )
        assert mod.parse_identities(text) == {"a": {"a@x.test", "a2@x.test"}}


class TestIsNoreply:
    def test_user_noreply_allowed(self):
        assert mod._is_noreply("123+user@users.noreply.github.com")
        assert mod._is_noreply("user@users.noreply.github.com")

    def test_personal_and_webflow_not_user_noreply(self):
        assert not mod._is_noreply("user@example.com")
        assert not mod._is_noreply("noreply@github.com")  # web-flow committer


class TestMismatch:
    def test_any_noreply_allowed_for_declared_author(self):
        # djpetti is declared with a non-noreply email; his GH noreply must pass.
        assert (
            mod._mismatch(
                "author",
                "djpetti",
                "7475340+djpetti@users.noreply.github.com",
                DECLARED,
            )
            is None
        )

    def test_declared_email_allowed(self):
        assert (
            mod._mismatch("author", "djpetti", "djpetti@example.com", DECLARED)
            is None
        )

    def test_declared_author_personal_email_flagged(self):
        msg = mod._mismatch(
            "author", "LearningCircuit", "personal@nope.test", DECLARED
        )
        assert msg is not None
        assert "LearningCircuit" in msg

    def test_case_insensitive_name_match(self):
        # A lowercase display name must still be enforced (the hashedviking gap).
        assert (
            mod._mismatch(
                "author", "learningcircuit", "personal@nope.test", DECLARED
            )
            is not None
        )

    def test_unknown_name_not_enforced(self):
        assert (
            mod._mismatch("author", "Outsider", "out@example.com", DECLARED)
            is None
        )

    def test_message_never_contains_offending_email(self):
        secret = "do-not-leak@secret.test"
        msg = mod._mismatch("author", "LearningCircuit", secret, DECLARED)
        assert msg is not None
        assert secret not in msg
        assert "secret" not in msg.lower()

    def test_empty_declared_never_flags(self):
        assert mod._mismatch("author", "Anyone", "x@y.test", {}) is None


class TestCoAuthorRegex:
    def test_matches_trailer(self):
        m = mod._CO_AUTHOR.search("b\n\nCo-authored-by: Some One <a@b.test>\n")
        assert m is not None
        assert m.group("name") == "Some One"
        assert m.group("email") == "a@b.test"

    def test_indented_trailer_matches(self):
        assert (
            mod._CO_AUTHOR.search("  Co-authored-by: X <x@y.test>") is not None
        )

    def test_no_false_match_on_prose(self):
        assert mod._CO_AUTHOR.search("This was co-authored by someone") is None


class TestParseLog:
    def test_basic_record(self):
        raw = "sha1\x00An\x00ae@x.test\x00Cn\x00ce@x.test\x00body line\x1e"
        assert mod._parse_log(raw) == [
            ("sha1", "An", "ae@x.test", "Cn", "ce@x.test", "body line")
        ]

    def test_nul_in_body_is_preserved(self):
        # A NUL earlier in the body must not truncate it: a real trailer (on its
        # own line) after the NUL must survive and still be detected.
        body = "head\x00more\n\nCo-authored-by: LearningCircuit <bad@nope.test>"
        raw = f"sha1\x00An\x00a@x.test\x00Cn\x00c@x.test\x00{body}\x1e"
        recs = mod._parse_log(raw)
        assert (
            recs[0][5] == body
        )  # full body kept (old fields[5] would truncate)
        errs = mod._check(recs[0], DECLARED)
        assert any("Co-authored-by" in e for e in errs)


class TestCheck:
    @staticmethod
    def _rec(an="A", ae="a@x.test", cn="C", ce="c@x.test", body=""):
        return ("deadbeef0123", an, ae, cn, ce, body)

    def test_author_violation(self):
        errs = mod._check(
            self._rec(an="LearningCircuit", ae="bad@nope.test"), DECLARED
        )
        assert len(errs) == 1
        assert "author" in errs[0]
        assert "deadbeef0" in errs[0]

    def test_committer_violation(self):
        errs = mod._check(
            self._rec(cn="LearningCircuit", ce="bad@nope.test"), DECLARED
        )
        assert len(errs) == 1
        assert "committer" in errs[0]

    def test_co_author_trailer_violation(self):
        errs = mod._check(
            self._rec(
                body="m\n\nCo-authored-by: LearningCircuit <bad@nope.test>\n"
            ),
            DECLARED,
        )
        assert len(errs) == 1
        assert "Co-authored-by" in errs[0]

    def test_clean_noreply_passes(self):
        good = "185559241+learningcircuit@users.noreply.github.com"
        errs = mod._check(
            self._rec(
                an="LearningCircuit", ae=good, cn="LearningCircuit", ce=good
            ),
            DECLARED,
        )
        assert errs == []

    def test_external_contributor_passes(self):
        assert (
            mod._check(self._rec(an="Outsider", ae="out@example.com"), DECLARED)
            == []
        )

    def test_no_offending_email_in_any_message(self):
        secret = "leak-me@secret.test"
        errs = mod._check(
            self._rec(
                an="LearningCircuit",
                ae=secret,
                body=f"x\n\nCo-authored-by: LearningCircuit <{secret}>\n",
            ),
            DECLARED,
        )
        assert errs
        assert all(secret not in e for e in errs)


@pytest.fixture
def clean_ci_env(monkeypatch):
    """Isolate the GitHub Actions env (set in real CI -> would flake otherwise)."""
    monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False)
    monkeypatch.delenv("GITHUB_EVENT_NAME", raising=False)
    return monkeypatch


class TestParseLogFailClosed:
    def test_malformed_record_raises(self):
        # An injected record separator splits a record into a <6-field chunk.
        raw = "s\x00An\x00a@x.test\x00Cn\x00c@x.test\x00body\x1eINJECTED\x1e"
        with pytest.raises(RuntimeError):
            mod._parse_log(raw)

    def test_multiple_records(self):
        raw = (
            "s1\x00A\x00a@x.test\x00C\x00c@x.test\x00b1\x1e"
            "s2\x00A\x00a@x.test\x00C\x00c@x.test\x00b2\x1e"
        )
        assert [r[0] for r in mod._parse_log(raw)] == ["s1", "s2"]


class TestResolveMergeBase:
    def test_resolves_locally_without_fetching(self, monkeypatch):
        # With full history present, merge-base succeeds first try -> NO fetch
        # (the hook must not mutate git state inside `pre-commit run`).
        runs = []
        monkeypatch.setattr(
            mod.subprocess, "run", lambda *a, **k: runs.append(a[0])
        )
        monkeypatch.setattr(mod, "_git", lambda *a: (0, "abc1234\n"))
        assert mod._resolve_merge_base("b", "h") == "abc1234"
        assert runs == []  # read-only: no git fetch was issued

    def test_fetches_then_succeeds(self, monkeypatch):
        # Shallow clone: first merge-base fails, a depth fetch makes it resolve.
        runs = []
        monkeypatch.setattr(
            mod.subprocess, "run", lambda *a, **k: runs.append(a[0])
        )
        seq = iter([(1, ""), (0, "abc1234\n")])
        monkeypatch.setattr(mod, "_git", lambda *a: next(seq))
        assert mod._resolve_merge_base("b", "h") == "abc1234"
        assert any("--depth=1000" in r for r in runs)
        assert not any("--unshallow" in r for r in runs)

    def test_retries_unshallow_then_succeeds(self, monkeypatch):
        runs = []
        monkeypatch.setattr(
            mod.subprocess, "run", lambda *a, **k: runs.append(a[0])
        )
        seq = iter([(1, ""), (1, ""), (0, "abc1234\n")])
        monkeypatch.setattr(mod, "_git", lambda *a: next(seq))
        assert mod._resolve_merge_base("b", "h") == "abc1234"
        assert any("--unshallow" in r for r in runs)  # full-history path taken

    def test_raises_when_unresolvable(self, monkeypatch):
        monkeypatch.setattr(mod.subprocess, "run", lambda *a, **k: None)
        monkeypatch.setattr(mod, "_git", lambda *a: (1, ""))
        with pytest.raises(RuntimeError):
            mod._resolve_merge_base("b", "h")


class TestPrContext:
    @staticmethod
    def _event(tmp_path):
        ev = tmp_path / "event.json"
        ev.write_text(
            json.dumps(
                {
                    "pull_request": {
                        "base": {"sha": "BASE"},
                        "head": {"sha": "HEAD"},
                    }
                }
            )
        )
        return ev

    def test_reads_allowlist_and_range_from_base(self, clean_ci_env, tmp_path):
        mp = clean_ci_env
        mp.setenv("GITHUB_EVENT_NAME", "pull_request")
        mp.setenv("GITHUB_EVENT_PATH", str(self._event(tmp_path)))
        mp.setattr(mod, "_resolve_merge_base", lambda b, h: "MB")
        calls = []

        def fake_git(*a):
            calls.append(a)
            if a[0] == "log":
                return (0, "s\x00n\x00e@x.test\x00n\x00e@x.test\x00b\x1e")
            if a[0] == "show":
                return (0, PYPROJECT)
            return (0, "")

        mp.setattr(mod, "_git", fake_git)
        records, text = mod._pr_context()
        assert ("show", "BASE:pyproject.toml") in calls  # base, not work-tree
        assert any(c[0] == "log" and "MB..HEAD" in c[1] for c in calls)
        assert text == PYPROJECT
        assert records and records[0][0] == "s"

    def test_base_pyproject_read_fails_closed(self, clean_ci_env, tmp_path):
        mp = clean_ci_env
        mp.setenv("GITHUB_EVENT_NAME", "pull_request")
        mp.setenv("GITHUB_EVENT_PATH", str(self._event(tmp_path)))
        mp.setattr(mod, "_resolve_merge_base", lambda b, h: "MB")

        def fake_git(*a):
            if a[0] == "log":
                return (0, "s\x00n\x00e@x.test\x00n\x00e@x.test\x00b\x1e")
            if a[0] == "show":
                return (1, "")  # base pyproject read fails
            return (0, "")

        mp.setattr(mod, "_git", fake_git)
        with pytest.raises(RuntimeError):
            mod._pr_context()

    def test_non_pr_event_returns_none(self, clean_ci_env):
        # No event + not a PR event -> local mode (None), not fail-closed.
        assert mod._pr_context() is None

    def test_pr_event_without_payload_fails_closed(self, clean_ci_env):
        clean_ci_env.setenv("GITHUB_EVENT_NAME", "pull_request")
        with pytest.raises(RuntimeError):
            mod._pr_context()


class TestMain:
    def test_fails_closed_on_unresolvable_range(self, monkeypatch, capsys):
        def boom():
            raise RuntimeError("could not resolve the PR commit range")

        monkeypatch.setattr(mod, "_pr_context", boom)
        assert mod.main() == 1
        assert "failing closed" in capsys.readouterr().err

    def test_violation_in_pr_range_returns_1(self, monkeypatch, capsys):
        rec = (
            "deadbeef0",
            "LearningCircuit",
            "bad@nope.test",
            "C",
            "c@x.test",
            "",
        )
        monkeypatch.setattr(mod, "_pr_context", lambda: ([rec], PYPROJECT))
        assert mod.main() == 1
        err = capsys.readouterr().err
        assert "LearningCircuit" in err
        assert "bad@nope.test" not in err  # offending email never printed

    def test_clean_pr_range_returns_0(self, monkeypatch):
        good = "185559241+learningcircuit@users.noreply.github.com"
        rec = (
            "deadbeef0",
            "LearningCircuit",
            good,
            "LearningCircuit",
            good,
            "",
        )
        monkeypatch.setattr(mod, "_pr_context", lambda: ([rec], PYPROJECT))
        assert mod.main() == 0

    def test_no_declared_authors_returns_0(self, monkeypatch):
        rec = (
            "deadbeef0",
            "LearningCircuit",
            "bad@nope.test",
            "C",
            "c@x.test",
            "",
        )
        monkeypatch.setattr(
            mod, "_pr_context", lambda: ([rec], '[project]\nname = "x"\n')
        )
        assert mod.main() == 0


class TestPendingRecords:
    def test_parses_git_idents(self, monkeypatch):
        ident = "LearningCircuit <bad@nope.test> 1700000000 +0000"
        monkeypatch.setattr(mod, "_git", lambda *a: (0, ident))
        recs = mod._pending_records()
        assert recs[0][1] == "LearningCircuit"
        assert recs[0][2] == "bad@nope.test"


class TestAnchorBoundary:
    def test_authors_prefixed_key_not_hijacked(self):
        text = (
            'authors_extra = [{name = "E", email = "evil@x.test"}]\n'
            'authors = [\n    {name = "A", email = "a@b.test"},\n]\n'
        )
        assert mod.parse_identities(text) == {"a": {"a@b.test"}}


class TestAllowlistFailClosed:
    """The guard must not silently disable itself if the authors block changes."""

    def test_real_repo_pyproject_still_parses_to_nonempty(self):
        # If a future reformat of the real pyproject.toml breaks parsing, this
        # trips here (and the CI path fails closed) instead of silently passing.
        root = mod.Path(__file__).resolve().parents[2]
        declared = mod.parse_identities(
            (root / "pyproject.toml").read_text(encoding="utf-8")
        )
        assert declared, (
            "pyproject.toml authors no longer parse -> guard disabled"
        )
        assert "learningcircuit" in declared
        assert any(
            e.endswith("@users.noreply.github.com")
            for e in declared["learningcircuit"]
        )

    def test_ci_fails_closed_when_authors_declared_but_unparsed(
        self, monkeypatch
    ):
        # array-of-tables form parse_identities does not understand: authors ARE
        # declared but none parse -> fail closed rather than return 0.
        unparsable = '[[project.authors]]\nname = "X"\nemail = "x@y.test"\n'
        monkeypatch.setattr(mod, "_pr_context", lambda: ([], unparsable))
        assert mod.main() == 1

    def test_ci_passes_when_no_authors_block_at_all(self, monkeypatch):
        # genuinely no authors declared -> not enforced -> pass (no over-block).
        monkeypatch.setattr(
            mod, "_pr_context", lambda: ([], '[project]\nname = "x"\n')
        )
        assert mod.main() == 0
