#!/usr/bin/env python3
"""Exercise aicommit routing and plan validation without invoking an AI CLI."""

import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest


SOURCE = Path(__file__).resolve().parent / "func"
ROUTE = '''
source "$1"
shift
_aicommit_engine() { printf 'ENGINE %s %s\n' "$1" "$2"; }
_aicommit_modules() { printf 'MODULES %s %s\n' "$1" "$2"; return 7; }
aicommit "$@"
'''


class AicommitTests(unittest.TestCase):
    def setUp(self):
        self.temp = tempfile.TemporaryDirectory(prefix="aicommit-test-")
        self.addCleanup(self.temp.cleanup)
        self.root = Path(self.temp.name)
        self.repo = self.root / "repo"
        self.repo.mkdir()
        self.home = self.root / "home"
        config = self.home / ".config"
        config.mkdir(parents=True)
        for name in ("ai-commit-prompt.txt", "ai-commit-split-prompt.txt"):
            (config / name).write_text((SOURCE.parent / name).read_text())
        self.env = {**os.environ, "HOME": str(self.home)}
        self.git("init", "-q")

    def git(self, *args):
        return subprocess.check_output(
            ["git", *args], cwd=self.repo, env=self.env
        )

    def shell(self, script, *args, cwd=None):
        return subprocess.run(
            ["zsh", "-f", "-c", script, "test", str(SOURCE), *args],
            cwd=cwd or self.repo, env=self.env, text=True, capture_output=True,
        )

    def stage(self, count, suffix=".txt"):
        # A newline is part of one path, not an extra staged file.
        files = [f"file {i}{suffix}" for i in range(count)]
        if files:
            files[0] = "line\nbreak" + suffix
        for name in files:
            (self.repo / name).write_text("staged content\n")
        self.git("add", "--all")
        return files

    def test_thirty_paths_use_single_commit(self):
        self.stage(30)
        result = self.shell(ROUTE, "opencode")
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(result.stdout.strip(), "ENGINE opencode commit")

    def test_thirty_one_paths_route_all_engines_and_forward_yes(self):
        self.stage(31)
        for engine in ("claude", "copilot", "opencode"):
            with self.subTest(engine=engine):
                result = self.shell(ROUTE, "--yes", engine)
                self.assertEqual(result.returncode, 7, result.stderr)
                self.assertEqual(result.stdout.strip(), f"MODULES {engine} 1")
                self.assertIn("exceed 30", result.stderr)

    def test_explicit_modules_below_threshold(self):
        self.stage(1)
        result = self.shell(ROUTE, "--modules", "copilot")
        self.assertEqual(result.returncode, 7, result.stderr)
        self.assertEqual(result.stdout.strip(), "MODULES copilot 0")

    def test_filtered_files_still_trigger_modules_from_subdirectory(self):
        self.stage(31, ".lock")
        nested = self.repo / "nested"
        nested.mkdir()
        result = self.shell(ROUTE, cwd=nested)
        self.assertEqual(result.returncode, 7, result.stderr)
        self.assertEqual(result.stdout.strip(), "MODULES claude 0")

    def test_empty_index_and_git_errors_never_call_engine(self):
        for cwd in (self.repo, self.root):
            with self.subTest(cwd=cwd):
                result = self.shell(ROUTE, cwd=cwd)
                self.assertNotEqual(result.returncode, 0)
                self.assertNotIn("ENGINE", result.stdout)
                self.assertNotIn("MODULES", result.stdout)

    def plan(self, count, groups):
        run = self.root / "plan"
        run.mkdir()
        files = [f"file-{i}" for i in range(count)]
        (run / "staged").write_bytes(b"".join(p.encode() + b"\0" for p in files))
        (run / "plan.out").write_text(json.dumps({"commits": [
            {"type": "feat", "scope": "test", "files": group(files)}
            for group in groups
        ]}))
        return self.shell('source "$1"; _aicommit_plan "$2"', str(run)), run

    def test_single_group_plan_allowed_at_thirty(self):
        result, run = self.plan(30, [lambda files: files])
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual((run / "count").read_text(), "1")

    def test_single_group_plan_rejected_above_thirty_after_deduplication(self):
        result, run = self.plan(31, [lambda files: files, lambda files: files])
        self.assertNotEqual(result.returncode, 0)
        self.assertIn("require multiple module commits", result.stderr)
        self.assertFalse((run / "count").exists())

    def test_empty_plan_cannot_fall_back_to_one_large_commit(self):
        result, _ = self.plan(31, [])
        self.assertNotEqual(result.returncode, 0)
        self.assertIn("require multiple module commits", result.stderr)

    def test_multiple_groups_allowed_even_with_large_module(self):
        result, run = self.plan(32, [lambda files: files[:-1], lambda files: files[-1:]])
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual((run / "count").read_text(), "2")

    def test_rejected_auto_plan_preserves_partial_index_and_worktree(self):
        files = self.stage(31)
        changed = self.repo / files[0]
        changed.write_text("unstaged content\n")
        before = self.git("write-tree")
        plan = self.root / "single-plan.json"
        plan.write_text(json.dumps({"commits": [{"files": files}]}))
        result = self.shell('''
source "$1"
plan_file="$2"
_aicommit_engine() {
  if [ "$2" = plan ]; then cat "$plan_file"; else printf 'UNEXPECTED COMMIT\n'; fi
}
aicommit --yes opencode
''', str(plan))
        self.assertNotEqual(result.returncode, 0)
        self.assertIn("require multiple module commits", result.stderr)
        self.assertNotIn("UNEXPECTED COMMIT", result.stdout)
        self.assertEqual(self.git("write-tree"), before)
        self.assertEqual(changed.read_text(), "unstaged content\n")


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