"""Tests for builder_loop.py: validation, migration, iteration loop."""

import io
import os
import shutil
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

import builder_loop
import common


# ═══════════════════════════════════════════════════════════════════════════
# _validate_project_dir
# ═══════════════════════════════════════════════════════════════════════════


class TestValidateProjectDir:
    def test_accepts_valid_directory(self, tmp_path: Path):
        builder_loop._validate_project_dir(tmp_path)

    def test_rejects_repo_root(self):
        with pytest.raises(SystemExit):
            builder_loop._validate_project_dir(common.REPO_ROOT)

    def test_rejects_autobuilder_dir(self):
        with pytest.raises(SystemExit):
            builder_loop._validate_project_dir(common.AUTOBUILDER_DIR)

    def test_rejects_subdirectory_of_autobuilder(self):
        with pytest.raises(SystemExit):
            builder_loop._validate_project_dir(common.AUTOBUILDER_DIR / "skills")


# ═══════════════════════════════════════════════════════════════════════════
# _migrate_to_initiative_layout
# ═══════════════════════════════════════════════════════════════════════════


class TestMigrateToInitiativeLayout:
    def test_noop_when_already_migrated(self, tmp_path: Path):
        ab = tmp_path / ".autobuilder"
        init_dir = ab / "initiatives" / "0001-app"
        (init_dir / "tasks").mkdir(parents=True)
        (init_dir / "spec.md").write_text("# Spec\n")
        builder_loop._migrate_to_initiative_layout(tmp_path, "app")
        assert not (init_dir / "tasks" / "0001-app").exists()

    def test_migrates_loose_md_files(self, tmp_path: Path):
        ab = tmp_path / ".autobuilder"
        init_base = ab / "initiatives"
        init_base.mkdir(parents=True)
        (init_base / "task-a.md").write_text("---\nid: a\n---\n")
        (init_base / "task-b.md").write_text("---\nid: b\n---\n")
        (ab / "scope.md").write_text("# Scope\n## Vision\nA great app.\n")

        builder_loop._migrate_to_initiative_layout(tmp_path, "myapp")

        init_dir = init_base / "0001-myapp"
        assert (init_dir / "tasks" / "task-a.md").exists()
        assert (init_dir / "tasks" / "task-b.md").exists()
        assert (init_dir / "spec.md").exists()

    def test_copies_constraints(self, tmp_path: Path):
        ab = tmp_path / ".autobuilder"
        init_base = ab / "initiatives"
        init_base.mkdir(parents=True)
        (ab / "scope.md").write_text("# Scope\n")
        (ab / "constraints.md").write_text("Only REST.\n")

        builder_loop._migrate_to_initiative_layout(tmp_path, "app")

        init_dir = init_base / "0001-app"
        assert (init_dir / "constraints.md").exists()
        assert "REST" in (init_dir / "constraints.md").read_text()

    def test_sets_active_initiative(self, tmp_path: Path):
        ab = tmp_path / ".autobuilder"
        init_base = ab / "initiatives"
        init_base.mkdir(parents=True)
        (ab / "scope.md").write_text("# Scope\n")

        builder_loop._migrate_to_initiative_layout(tmp_path, "app")

        active_file = ab / "active-initiative"
        assert active_file.exists()
        assert "0001-app" in active_file.read_text()

    def test_noop_when_no_scope_no_tasks(self, tmp_path: Path):
        ab = tmp_path / ".autobuilder"
        ab.mkdir(parents=True)
        builder_loop._migrate_to_initiative_layout(tmp_path, "app")
        assert not (ab / "initiatives" / "0001-app").exists()


# ═══════════════════════════════════════════════════════════════════════════
# _ensure_project_exists
# ═══════════════════════════════════════════════════════════════════════════


class TestEnsureProjectExists:
    def test_noop_when_exists(self, tmp_path: Path):
        builder_loop._ensure_project_exists(tmp_path)
        assert tmp_path.exists()

    def test_creates_directory_and_init(self, tmp_path: Path):
        new_dir = tmp_path / "new-project"
        with patch("builder_loop.subprocess.run"):
            builder_loop._ensure_project_exists(new_dir)
        assert new_dir.exists()
        assert (new_dir / ".gitignore").exists()


# ═══════════════════════════════════════════════════════════════════════════
# _iteration_loop
# ═══════════════════════════════════════════════════════════════════════════


class TestIterationLoop:
    def _base_kwargs(self, project_dir: Path, log_fh):
        return dict(
            max_iterations=3,
            project_dir=project_dir,
            project_id="test-app",
            root_initiative="test-app",
            backlog_path=project_dir / ".autobuilder" / "backlog.md",
            status_path=project_dir / ".autobuilder" / "status.md",
            model="test-model",
            executor="cursor",
            env_file=None,
            port=3000,
            no_push=True,
            log_fh=log_fh,
            previous_improvements=[],
            consecutive_crashes=0,
            consecutive_idle=0,
            consecutive_agent_failures=0,
            base_env={},
        )

    def test_stop_signal_exits_cleanly(self, project_dir: Path):
        log_fh = io.StringIO()
        (project_dir / ".autobuilder" / "stop").write_text("stop")
        with patch("builder_loop.run_hook", return_value=None), \
             patch("builder_loop.write_status"):
            rc = builder_loop._iteration_loop(**self._base_kwargs(project_dir, log_fh))
        assert rc == 0

    def test_graceful_stop_exception_exits(self, project_dir: Path):
        from iteration import GracefulStop
        log_fh = io.StringIO()
        with patch("builder_loop.check_stop_signal", return_value=False), \
             patch("builder_loop.run_hook", return_value=None), \
             patch("builder_loop.run_iteration", side_effect=GracefulStop()):
            rc = builder_loop._iteration_loop(**self._base_kwargs(project_dir, log_fh))
        assert rc == 0

    def test_nothing_to_improve_backs_off(self, project_dir: Path):
        from iteration import NothingToImprove
        log_fh = io.StringIO()
        exc = NothingToImprove(journal_data={"iter": 1, "strategy": "surface-sweep"})
        with patch("builder_loop.check_stop_signal", return_value=False), \
             patch("builder_loop.run_hook", return_value=None), \
             patch("builder_loop.run_iteration", side_effect=exc), \
             patch("builder_loop.write_status"), \
             patch("builder_loop.append_journal_entry") as mock_journal, \
             patch("builder_loop.pick_review_strategy", return_value={"name": "deep-dive"}), \
             patch("time.sleep"):
            builder_loop._iteration_loop(**self._base_kwargs(project_dir, log_fh))
        mock_journal.assert_called()

    def test_agent_failed_backs_off(self, project_dir: Path):
        from iteration import AgentFailed
        log_fh = io.StringIO()
        exc = AgentFailed("exit 1", journal_data={"iter": 1})
        with patch("builder_loop.check_stop_signal", return_value=False), \
             patch("builder_loop.run_hook", return_value=None), \
             patch("builder_loop.run_iteration", side_effect=exc), \
             patch("builder_loop.write_status"), \
             patch("builder_loop.append_journal_entry"), \
             patch("time.sleep"):
            builder_loop._iteration_loop(**self._base_kwargs(project_dir, log_fh))

    def test_crash_backs_off_and_retries(self, project_dir: Path):
        log_fh = io.StringIO()
        call_count = [0]

        def side_effect(**kwargs):
            call_count[0] += 1
            if call_count[0] <= 2:
                raise RuntimeError("boom")
            return {"iter": 3, "outcome": "productive"}

        with patch("builder_loop.check_stop_signal", return_value=False), \
             patch("builder_loop.run_hook", return_value=None), \
             patch("builder_loop.run_iteration", side_effect=side_effect), \
             patch("builder_loop.write_status"), \
             patch("builder_loop.append_journal_entry"), \
             patch("time.sleep"):
            rc = builder_loop._iteration_loop(**self._base_kwargs(project_dir, log_fh))
        assert rc == 0
        assert "CRASH" in log_fh.getvalue()

    def test_safety_cap_reached(self, project_dir: Path):
        log_fh = io.StringIO()
        with patch("builder_loop.check_stop_signal", return_value=False), \
             patch("builder_loop.run_hook", return_value=None), \
             patch("builder_loop.run_iteration", return_value={"iter": 1}), \
             patch("builder_loop.write_status"), \
             patch("builder_loop.append_journal_entry"):
            kwargs = self._base_kwargs(project_dir, log_fh)
            kwargs["max_iterations"] = 2
            rc = builder_loop._iteration_loop(**kwargs)
        assert rc == 0
        assert "cap" in log_fh.getvalue().lower()
