"""Unit tests for .github/scripts/combine-ai-reviews.sh.

The AI Code Reviewer workflow runs N models over a PR diff (network) and writes
each reviewer's raw stdout to resp_<i>.json and exit code to code_<i>. The
combine-ai-reviews.sh helper turns those files into the single sticky-comment
body, the deduped label set, the pass/fail decision, and the success count.

These tests exercise that pure-assembly logic directly with canned fixtures —
no network, no GitHub API — so the non-trivial bash/jq/perl parts (comma-split
already happens in the workflow; here it's header/footer stripping, anonymized
"Reviewer N" section assembly, label union, and decision aggregation) are
covered in CI.
"""

import json
import os
import shutil
import subprocess
from pathlib import Path

import pytest

SCRIPT = (
    Path(__file__).resolve().parents[2]
    / ".github"
    / "scripts"
    / "combine-ai-reviews.sh"
)

_MISSING_TOOLS = [t for t in ("bash", "jq", "perl") if shutil.which(t) is None]
_IN_CI = bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"))

# Skip ONLY when the tools are missing locally. In CI they must be present
# (jq+perl are installed into the test image), so we deliberately do NOT skip
# there: if they're ever missing in CI these tests run and fail loudly instead
# of silently skipping and leaving combine-ai-reviews.sh with zero enforced
# coverage. Failing per-test (vs a module-level raise) keeps the failure
# localized to this lane — see test_required_tools_present_in_ci for the
# explicit, clearly-messaged guard.
pytestmark = pytest.mark.skipif(
    bool(_MISSING_TOOLS) and not _IN_CI,
    reason=f"combine-ai-reviews.sh requires {_MISSING_TOOLS} (missing locally)",
)


def test_required_tools_present_in_ci():
    """The test image must ship the tools the helper needs. If this fails in CI,
    install the missing tool in the Dockerfile `ldr-test` stage — otherwise the
    rest of this suite would be a silent no-op."""
    if _IN_CI:
        assert not _MISSING_TOOLS, (
            f"CI test image is missing {_MISSING_TOOLS}; add it to the Dockerfile "
            "ldr-test stage so combine-ai-reviews.sh keeps real CI coverage."
        )


FOOTER_SINGULAR = (
    "*Review by [Friendly AI Reviewer]"
    "(https://github.com/LearningCircuit/Friendly-AI-Reviewer) - made with ❤️*"
)


def make_response(body, decision="pass", labels=None, *, trailing=""):
    """Build a realistic ai-reviewer.sh JSON response (review + verdict + labels)."""
    review = f"## AI Code Review\n\n{body}\n\n---\n{FOOTER_SINGULAR}{trailing}"
    return json.dumps(
        {
            "review": review,
            "fail_pass_workflow": decision,
            "labels_added": labels or [],
        }
    )


def run_combine(work_dir, reviewers, *, head_sha="abc1234def", debug=False):
    """Write fixtures for each reviewer and run the helper; return its outputs.

    `reviewers` is a list of dicts: {"code": int, "resp": str}. Model names are
    deliberately distinctive so tests can assert they never leak into the
    (anonymized) comment body.
    """
    models = []
    for i, rv in enumerate(reviewers):
        (work_dir / f"code_{i}").write_text(str(rv["code"]))
        (work_dir / f"resp_{i}.json").write_text(rv["resp"])
        (work_dir / f"err_{i}.log").write_text("")
        models.append(f"secret-model-{i}-DO-NOT-LEAK")

    env = {"HEAD_SHA": head_sha, "DEBUG_MODE": "true" if debug else "false"}
    result = subprocess.run(
        ["bash", str(SCRIPT), str(work_dir), *models],
        capture_output=True,
        text=True,
        env={**env, "PATH": __import__("os").environ["PATH"]},
    )
    assert result.returncode == 0, (
        f"script failed ({result.returncode}): {result.stderr}"
    )
    return {
        "models": models,
        "comment": (work_dir / "comment_body.md").read_text(),
        "labels": (work_dir / "labels.txt").read_text(),
        "decision": (work_dir / "decision.txt").read_text(),
        "success_count": (work_dir / "success_count.txt").read_text(),
        "stderr": result.stderr,
    }


def test_two_reviewers_pass_and_fail(tmp_path):
    out = run_combine(
        tmp_path,
        [
            {
                "code": 0,
                "resp": make_response(
                    "All good here.", "pass", ["enhancement"]
                ),
            },
            {
                "code": 0,
                "resp": make_response("Race condition.", "fail", ["bug"]),
            },
        ],
    )
    c = out["comment"]
    # Both anonymized sections present, in order.
    assert "### 👤 Reviewer 1" in c
    assert "### 👤 Reviewer 2" in c
    assert c.index("Reviewer 1") < c.index("Reviewer 2")
    # Bodies carried through.
    assert "All good here." in c
    assert "Race condition." in c
    # Aggregation: any fail -> fail; labels unioned + sorted; both succeeded.
    assert out["decision"] == "fail"
    assert out["labels"].split() == ["bug", "enhancement"]
    assert out["success_count"] == "2"


def test_header_and_per_review_footer_are_stripped(tmp_path):
    out = run_combine(
        tmp_path,
        [{"code": 0, "resp": make_response("Body one.", "pass")}],
    )
    c = out["comment"]
    # The per-review "## AI Code Review" H2 is removed (only the combined H2 with
    # the reviewer count remains), and the singular per-review footer is gone.
    assert "## 🤖 AI Code Review (1 reviewer)" in c
    assert "## AI Code Review\n" not in c
    assert "Review by [Friendly AI Reviewer]" not in c
    # Exactly one combined (plural) footer is appended.
    assert c.count("Reviews by [Friendly AI Reviewer]") == 1


def test_footer_strip_tolerates_trailing_whitespace(tmp_path):
    # LLMs sometimes append trailing newlines/spaces after the footer.
    out = run_combine(
        tmp_path,
        [
            {
                "code": 0,
                "resp": make_response("Body.", "pass", trailing="\n\n   \n"),
            }
        ],
    )
    assert "Review by [Friendly AI Reviewer]" not in out["comment"]
    # ...but the actual review body must survive (guard against an over-greedy
    # strip that nukes everything).
    assert "Body." in out["comment"]
    assert out["success_count"] == "1"


def test_failed_reviewer_degrades_without_sinking_others(tmp_path):
    out = run_combine(
        tmp_path,
        [
            {
                "code": 0,
                "resp": make_response("Good review.", "pass", ["enhancement"]),
            },
            {"code": 1, "resp": ""},  # hard error: non-zero exit, empty stdout
            {"code": 0, "resp": "this is not json"},  # garbage stdout
        ],
    )
    c = out["comment"]
    assert "Good review." in c
    assert c.count("could not complete its review") == 2
    # Two of three reviewers failed; the survivor still counts.
    assert out["success_count"] == "1"
    # A reviewer that errors out never flips the decision to fail.
    assert out["decision"] == "pass"
    # Failed reviewers contribute no labels.
    assert out["labels"].split() == ["enhancement"]


def test_all_reviewers_failed(tmp_path):
    out = run_combine(
        tmp_path,
        [{"code": 1, "resp": ""}, {"code": 1, "resp": ""}],
    )
    assert out["success_count"] == "0"
    assert out["decision"] == "pass"
    assert out["comment"].count("could not complete its review") == 2


def test_labels_are_unioned_and_deduped(tmp_path):
    out = run_combine(
        tmp_path,
        [
            {
                "code": 0,
                "resp": make_response("a", "pass", ["bug", "security"]),
            },
            {
                "code": 0,
                "resp": make_response("b", "pass", ["bug", "enhancement"]),
            },
        ],
    )
    assert out["labels"].split() == ["bug", "enhancement", "security"]


def test_model_names_never_leak_into_comment(tmp_path):
    out = run_combine(
        tmp_path,
        [
            {"code": 0, "resp": make_response("x", "pass")},
            {"code": 0, "resp": make_response("y", "fail")},
        ],
    )
    for model in out["models"]:
        assert model not in out["comment"]


def test_single_reviewer_uses_singular_wording_and_sticky_marker(tmp_path):
    out = run_combine(
        tmp_path,
        [{"code": 0, "resp": make_response("Solo.", "pass")}],
    )
    c = out["comment"]
    assert "(1 reviewer)" in c
    assert "(1 reviewers)" not in c
    # Sticky marker must lead the body (the workflow searches for it verbatim).
    assert c.startswith("<!-- ai-code-review:sticky -->")
    # Commit sha line rendered with real backticks (not escaped backslashes).
    assert "_Last reviewed at commit `abc1234`_" in c


def test_blank_line_before_horizontal_rule(tmp_path):
    # A "text\n---" with no blank line is a setext H2 in Markdown; ensure the
    # divider before the footer is preceded by a blank line.
    out = run_combine(
        tmp_path,
        [{"code": 0, "resp": make_response("Body.", "pass")}],
    )
    assert "\n\n---\n" in out["comment"]


# --- Regression tests for resilience (one bad reviewer must not sink others) ---


def test_valid_json_non_object_does_not_sink_other_reviewers(tmp_path):
    # A model can return valid JSON that is a bare string/array (e.g. a refusal),
    # not the expected object. That must degrade to a per-reviewer failure note,
    # not crash the whole combine and lose the good review. (Regression: a bare
    # `jq .` gate accepted non-objects, then `.review` errored under set -e.)
    out = run_combine(
        tmp_path,
        [
            {
                "code": 0,
                "resp": '"I refuse to review this"',
            },  # valid JSON string
            {
                "code": 0,
                "resp": '["also", "not", "an", "object"]',
            },  # JSON array
            {"code": 0, "resp": make_response("Real review.", "pass", ["bug"])},
        ],
    )
    c = out["comment"]
    assert "Real review." in c
    assert c.count("could not complete its review") == 2
    assert out["success_count"] == "1"
    assert out["labels"].split() == ["bug"]


def test_weird_verdict_string_does_not_crash(tmp_path):
    # A verdict value containing a quote previously aborted the script via
    # `| xargs` (unmatched quote). It must now be handled gracefully.
    resp = json.dumps(
        {
            "review": f"## AI Code Review\n\nBody.\n\n---\n{FOOTER_SINGULAR}",
            "fail_pass_workflow": "it's complicated",
            "labels_added": [],
        }
    )
    out = run_combine(tmp_path, [{"code": 0, "resp": resp}])
    assert out["success_count"] == "1"
    # A non-"fail" verdict (even a weird one) does not request changes.
    assert out["decision"] == "pass"


def test_empty_exit_code_file_counts_as_failed(tmp_path):
    # An empty/unreadable code_<i> must be treated as a failed reviewer, not
    # silently counted as a success (regression: `[ "" -ne 0 ]` errored and fell
    # through to the success path).
    out = run_combine(
        tmp_path,
        [{"code": "", "resp": make_response("Should be ignored.", "pass")}],
    )
    assert out["success_count"] == "0"
    assert "could not complete its review" in out["comment"]
    assert "Should be ignored." not in out["comment"]


def test_non_numeric_exit_code_counts_as_failed(tmp_path):
    out = run_combine(
        tmp_path,
        [{"code": "boom", "resp": make_response("Ignored.", "pass")}],
    )
    assert out["success_count"] == "0"
    assert "integer expression" not in out["stderr"]


def test_mixed_pass_fail_error_aggregation(tmp_path):
    out = run_combine(
        tmp_path,
        [
            {"code": 0, "resp": make_response("ok", "pass", ["enhancement"])},
            {"code": 0, "resp": make_response("blocker", "fail", ["bug"])},
            {"code": 1, "resp": ""},  # hard error
        ],
    )
    # Only usable reviews count; the one real "fail" verdict drives the decision;
    # labels union only from successful reviewers; exactly one failure note.
    assert out["success_count"] == "2"
    assert out["decision"] == "fail"
    assert out["labels"].split() == ["bug", "enhancement"]
    assert out["comment"].count("could not complete its review") == 1


def test_valid_json_missing_review_field(tmp_path):
    resp = json.dumps({"fail_pass_workflow": "pass", "labels_added": []})
    out = run_combine(tmp_path, [{"code": 0, "resp": resp}])
    # Object with no .review still counts as a (degenerate) success and renders
    # the jq fallback text rather than crashing.
    assert out["success_count"] == "1"
    assert "No review provided" in out["comment"]


def test_debug_mode_does_not_change_comment_and_keeps_model_in_stderr(tmp_path):
    reviewers = [{"code": 0, "resp": make_response("Body.", "pass")}]
    # Separate dirs so the two runs' fixtures/outputs don't collide.
    plain_dir = tmp_path / "plain"
    dbg_dir = tmp_path / "debug"
    plain_dir.mkdir()
    dbg_dir.mkdir()
    plain = run_combine(plain_dir, reviewers)
    dbg = run_combine(dbg_dir, reviewers, debug=True)
    # The posted comment must be byte-identical regardless of DEBUG_MODE — debug
    # content (raw response, model name) goes only to stderr, never the comment.
    assert dbg["comment"] == plain["comment"]
    # DEBUG must not perturb any of the machine-read outputs either.
    assert dbg["decision"] == plain["decision"]
    assert dbg["labels"] == plain["labels"]
    assert dbg["success_count"] == plain["success_count"]
    assert "RAW AI RESPONSE" in dbg["stderr"]
    assert "RAW AI RESPONSE" not in plain["stderr"]
    # Model name leaks only into stderr logs (anonymization), never the comment.
    assert dbg["models"][0] in dbg["stderr"]
    assert dbg["models"][0] not in dbg["comment"]


def test_four_reviewers_keep_order(tmp_path):
    out = run_combine(
        tmp_path,
        [
            {"code": 0, "resp": make_response(f"body-{n}", "pass")}
            for n in range(4)
        ],
    )
    c = out["comment"]
    positions = [c.index(f"Reviewer {n}") for n in range(1, 5)]
    assert positions == sorted(positions)
    for n in range(4):
        assert f"body-{n}" in c


def test_usage_errors_exit_nonzero(tmp_path):
    # The script's only non-zero exits: bad work_dir and zero model args.
    env = {**os.environ}
    no_dir = subprocess.run(
        ["bash", str(SCRIPT), str(tmp_path / "does-not-exist"), "m"],
        capture_output=True,
        text=True,
        env=env,
    )
    assert no_dir.returncode == 2
    no_models = subprocess.run(
        ["bash", str(SCRIPT), str(tmp_path)],
        capture_output=True,
        text=True,
        env=env,
    )
    assert no_models.returncode == 2


def test_exit_code_with_trailing_newline(tmp_path):
    # The workflow writes exit codes via `echo $? > code_i`, i.e. WITH a trailing
    # newline ("0\n"), whereas the other tests use the bare "0" form. Confirm the
    # real workflow shape is handled (command substitution strips the newline).
    (tmp_path / "code_0").write_text("0\n")
    (tmp_path / "resp_0.json").write_text(make_response("ok", "pass"))
    (tmp_path / "err_0.log").write_text("")
    result = subprocess.run(
        ["bash", str(SCRIPT), str(tmp_path), "m"],
        capture_output=True,
        text=True,
        env={
            "HEAD_SHA": "abc1234def",
            "DEBUG_MODE": "false",
            "PATH": os.environ["PATH"],
        },
    )
    assert result.returncode == 0, result.stderr
    assert (tmp_path / "success_count.txt").read_text() == "1"
