import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
from bot.lib import fixer


class TestProposeFix(unittest.TestCase):
    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())
        self.repo = self.tmp / "repo"
        self.repo.mkdir()
        self.report = self.repo / "report.md"
        self.report.write_text("# patch report\n\n- removed: GetOldThing\n", encoding="utf-8")

    def _fake_subprocess_factory(self, claude_stdout="", claude_returncode=0, diff_output="M\tCritMatic.lua\n"):
        """Returns a side_effect function for subprocess.run that fakes git and claude."""
        def run(cmd, *args, **kwargs):
            result = MagicMock(returncode=0, stdout="", stderr="")
            if cmd[0] == "git" and "worktree" in cmd:
                pass  # just succeed
            elif cmd[0] == "git" and cmd[1:2] == ["-C"] and "diff" in cmd and "--name-only" in cmd:
                result.stdout = diff_output
            elif cmd[0] == "git" and cmd[1:2] == ["-C"] and "add" in cmd:
                pass
            elif cmd[0] == "git" and cmd[1:2] == ["-C"] and "commit" in cmd:
                pass
            elif cmd[0] == "git" and cmd[1:2] == ["-C"] and "rev-parse" in cmd:
                result.stdout = "deadbeef\n"
            elif cmd[0] == "git" and cmd[1:2] == ["-C"] and "status" in cmd:
                # Return non-empty status iff the diff_output suggests changes.
                result.stdout = "M CritMatic.lua\n" if diff_output.strip() else ""
            elif cmd[0] == "claude":
                result.stdout = claude_stdout
                result.returncode = claude_returncode
            return result
        return run

    def test_creates_worktree_runs_claude_and_returns_fix_result(self):
        valid_json = '{"confidence": 88, "rationale": "renamed", "files_changed": ["CritMatic.lua"]}'
        with patch("subprocess.run", side_effect=self._fake_subprocess_factory(
            claude_stdout=f"...some output...\n{valid_json}\n",
            diff_output="CritMatic.lua\n",
        )):
            result = fixer.propose_fix(
                report_path=self.report,
                branch_name="bot/patch-test",
                removed_symbols={"GetOldThing"},
                repo_root=self.repo,
            )
        self.assertEqual(result.branch, "bot/patch-test")
        self.assertEqual(result.confidence, 88)
        self.assertEqual(result.rationale, "renamed")
        self.assertEqual(result.files_changed, ["CritMatic.lua"])
        self.assertEqual(result.claude_exit_code, 0)
        self.assertEqual(result.commit_sha, "deadbeef")

    def test_no_diff_means_no_commit(self):
        valid_json = '{"confidence": 0, "rationale": "no edits", "files_changed": []}'
        with patch("subprocess.run", side_effect=self._fake_subprocess_factory(
            claude_stdout=valid_json + "\n",
            diff_output="",  # empty diff
        )):
            result = fixer.propose_fix(
                report_path=self.report,
                branch_name="bot/patch-empty",
                removed_symbols={"X"},
                repo_root=self.repo,
            )
        self.assertIsNone(result.commit_sha)

    def test_unparseable_json_yields_zero_confidence(self):
        with patch("subprocess.run", side_effect=self._fake_subprocess_factory(
            claude_stdout="garbage no json here\n",
            diff_output="CritMatic.lua\n",
        )):
            result = fixer.propose_fix(
                report_path=self.report,
                branch_name="bot/patch-bad",
                removed_symbols={"X"},
                repo_root=self.repo,
            )
        self.assertEqual(result.confidence, 0)
        self.assertEqual(result.rationale, "claude output unparseable")

    def test_claude_nonzero_exit_still_returns_result(self):
        with patch("subprocess.run", side_effect=self._fake_subprocess_factory(
            claude_stdout="",
            claude_returncode=2,
            diff_output="",
        )):
            result = fixer.propose_fix(
                report_path=self.report,
                branch_name="bot/patch-fail",
                removed_symbols={"X"},
                repo_root=self.repo,
            )
        self.assertEqual(result.claude_exit_code, 2)
        self.assertEqual(result.confidence, 0)

    def test_prompt_includes_report_text_and_removed_symbols(self):
        captured = {}
        def capture(cmd, *args, **kwargs):
            result = MagicMock(returncode=0, stdout="", stderr="")
            if cmd[0] == "claude":
                idx = cmd.index("-p") + 1
                captured["prompt"] = cmd[idx]
                result.stdout = '{"confidence": 50, "rationale": "x", "files_changed": []}\n'
            elif cmd[0] == "git" and "diff" in cmd and "--name-only" in cmd:
                result.stdout = ""
            elif cmd[0] == "git" and "rev-parse" in cmd:
                result.stdout = "abc\n"
            elif cmd[0] == "git" and "status" in cmd:
                result.stdout = ""
            return result

        with patch("subprocess.run", side_effect=capture):
            fixer.propose_fix(
                report_path=self.report,
                branch_name="bot/patch-prompt",
                removed_symbols={"GetOldThing", "GetOtherDeadAPI"},
                repo_root=self.repo,
            )
        self.assertIn("# patch report", captured["prompt"])
        self.assertIn("GetOldThing", captured["prompt"])
        self.assertIn("GetOtherDeadAPI", captured["prompt"])
        self.assertIn("confidence", captured["prompt"])
        self.assertIn("Libs/", captured["prompt"])

    def test_rationale_with_brace_parses_correctly(self):
        # A `}` inside the rationale string used to truncate the JSON match
        # via the old non-greedy regex. The line-level json.loads now handles it.
        valid_json = '{"confidence": 92, "rationale": "replaced GetX{} with new API", "files_changed": ["X.lua"]}'
        with patch("subprocess.run", side_effect=self._fake_subprocess_factory(
            claude_stdout=valid_json + "\n",
            diff_output="X.lua\n",
        )):
            result = fixer.propose_fix(
                report_path=self.report,
                branch_name="bot/patch-brace",
                removed_symbols={"GetX"},
                repo_root=self.repo,
            )
        self.assertEqual(result.confidence, 92)
        self.assertEqual(result.rationale, "replaced GetX{} with new API")

    def test_worktree_failure_raises_with_useful_message(self):
        def fakes(cmd, *args, **kwargs):
            result = MagicMock(returncode=0, stdout="", stderr="")
            if cmd[0] == "git" and "worktree" in cmd and "add" in cmd:
                result.returncode = 128
                result.stderr = "fatal: branch 'bot/patch-x' already exists"
            return result
        with patch("subprocess.run", side_effect=fakes):
            with self.assertRaises(RuntimeError) as ctx:
                fixer.propose_fix(
                    report_path=self.report,
                    branch_name="bot/patch-x",
                    removed_symbols=set(),
                    repo_root=self.repo,
                )
        self.assertIn("worktree add failed", str(ctx.exception))
        self.assertIn("already exists", str(ctx.exception))


if __name__ == "__main__":
    unittest.main()
