"""
Tests for Phase 7: Advanced Features.

Covers budgets, templates, evaluators, semantic search,
multi-repo projects, and remote storage.
"""

import json
import os
import subprocess
import sys
from pathlib import Path

import pytest


def run_fla(*args, cwd=None, expect_fail=False):
    """Run a flanes CLI command and return (returncode, stdout, stderr)."""
    cmd = [sys.executable, "-X", "utf8", "-m", "flanes.cli"] + list(args)
    # On Windows, CREATE_NEW_PROCESS_GROUP prevents spurious CTRL_C_EVENT
    # from the CI runner reaching the child process.
    kwargs = {}
    if sys.platform == "win32":
        kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
    result = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        cwd=cwd,
        env={**os.environ, "PYTHONPATH": str(Path(__file__).parent.parent)},
        **kwargs,
    )
    if not expect_fail:
        if result.returncode != 0:
            print(f"STDOUT: {result.stdout}")
            print(f"STDERR: {result.stderr}")
    return result.returncode, result.stdout, result.stderr


@pytest.fixture
def repo(tmp_path):
    """A Repository object for direct API testing."""
    from flanes.repo import Repository

    (tmp_path / "hello.txt").write_text("Hello, World!\n")
    repo = Repository.init(tmp_path)
    return repo


@pytest.fixture
def repo_dir(tmp_path):
    """A temporary directory with an initialized flanes repo."""
    (tmp_path / "hello.txt").write_text("Hello, World!\n")
    rc, out, err = run_fla("init", cwd=tmp_path)
    assert rc == 0, f"Init failed: {err}"
    return tmp_path


# ══════════════════════════════════════════════════════════════
# 1. Cost Budgets
# ══════════════════════════════════════════════════════════════


class TestBudgets:
    def test_budget_config_serialization(self):
        from flanes.budgets import BudgetConfig

        config = BudgetConfig(
            max_tokens_in=1000,
            max_tokens_out=500,
            max_api_calls=10,
            max_wall_time_ms=60000.0,
            alert_threshold_pct=75.0,
        )
        d = config.to_dict()
        restored = BudgetConfig.from_dict(d)
        assert restored.max_tokens_in == 1000
        assert restored.max_tokens_out == 500
        assert restored.max_api_calls == 10
        assert restored.max_wall_time_ms == 60000.0
        assert restored.alert_threshold_pct == 75.0

    def test_set_and_get_budget(self, repo):
        from flanes.budgets import BudgetConfig, get_lane_budget, set_lane_budget

        config = BudgetConfig(max_tokens_in=5000, max_api_calls=20)
        set_lane_budget(repo.wsm, "main", config)
        loaded = get_lane_budget(repo.wsm, "main")
        assert loaded is not None
        assert loaded.max_tokens_in == 5000
        assert loaded.max_api_calls == 20

    def test_compute_budget_status(self, repo):
        import uuid

        from flanes.budgets import BudgetConfig, compute_budget_status, set_lane_budget
        from flanes.state import AgentIdentity, CostRecord, Intent

        config = BudgetConfig(max_tokens_in=1000, max_tokens_out=500)
        set_lane_budget(repo.wsm, "main", config)

        # Create a transition with cost
        head = repo.head()
        agent = AgentIdentity(agent_id="test", agent_type="test")
        intent = Intent(id=str(uuid.uuid4()), prompt="test", agent=agent)
        cost = CostRecord(tokens_in=300, tokens_out=100, api_calls=1)
        repo.wsm.propose(head, head, intent, "main", cost)

        status = compute_budget_status(repo.wsm, "main")
        assert status is not None
        assert status.total_tokens_in == 300
        assert status.total_tokens_out == 100

    def test_budget_warning_at_threshold(self, repo):
        import uuid

        from flanes.budgets import BudgetConfig, compute_budget_status, set_lane_budget
        from flanes.state import AgentIdentity, CostRecord, Intent

        config = BudgetConfig(max_tokens_in=1000, alert_threshold_pct=80.0)
        set_lane_budget(repo.wsm, "main", config)

        head = repo.head()
        agent = AgentIdentity(agent_id="test", agent_type="test")
        intent = Intent(id=str(uuid.uuid4()), prompt="test", agent=agent)
        cost = CostRecord(tokens_in=850)
        repo.wsm.propose(head, head, intent, "main", cost)

        status = compute_budget_status(repo.wsm, "main")
        assert "tokens_in" in status.warnings

    def test_budget_exceeded_on_propose(self, repo):

        from flanes.budgets import BudgetConfig, BudgetError, set_lane_budget
        from flanes.state import AgentIdentity, CostRecord

        config = BudgetConfig(max_tokens_in=100)
        set_lane_budget(repo.wsm, "main", config)

        agent = AgentIdentity(agent_id="test", agent_type="test")

        # First commit uses up the budget
        repo.quick_commit(
            workspace="main",
            prompt="first",
            agent=agent,
            cost=CostRecord(tokens_in=90),
            auto_accept=True,
        )

        # Second commit should exceed the budget
        with pytest.raises(BudgetError):
            repo.quick_commit(
                workspace="main",
                prompt="second",
                agent=agent,
                cost=CostRecord(tokens_in=20),
                auto_accept=True,
            )

    def test_budget_no_config_passthrough(self, repo):
        from flanes.state import AgentIdentity, CostRecord

        agent = AgentIdentity(agent_id="test", agent_type="test")
        # No budget set — propose should work normally
        result = repo.quick_commit(
            workspace="main",
            prompt="no budget",
            agent=agent,
            cost=CostRecord(tokens_in=999999),
            auto_accept=True,
        )
        assert result["status"] == "accepted"

    def test_budget_cli_show(self, repo_dir):
        # Set budget first
        rc, _, _ = run_fla("budget", "set", "main", "--max-tokens-in", "1000", cwd=repo_dir)
        assert rc == 0

        rc, out, _ = run_fla("--json", "budget", "show", "main", cwd=repo_dir)
        assert rc == 0
        data = json.loads(out)
        assert data["config"]["max_tokens_in"] == 1000

    def test_budget_cli_set(self, repo_dir):
        rc, _, _ = run_fla(
            "budget",
            "set",
            "main",
            "--max-tokens-in",
            "5000",
            "--max-api-calls",
            "100",
            cwd=repo_dir,
        )
        assert rc == 0

        rc, out, _ = run_fla("--json", "budget", "show", "main", cwd=repo_dir)
        assert rc == 0
        data = json.loads(out)
        assert data["config"]["max_tokens_in"] == 5000
        assert data["config"]["max_api_calls"] == 100


# ══════════════════════════════════════════════════════════════
# 2. Workspace Templates
# ══════════════════════════════════════════════════════════════


class TestTemplates:
    def test_template_save_and_load(self, repo):
        from flanes.templates import TemplateFile, TemplateManager, WorkspaceTemplate

        tm = TemplateManager(repo.flanes_dir)
        template = WorkspaceTemplate(
            name="python-basic",
            description="Basic Python project",
            files=[TemplateFile(path="main.py", content="print('hello')")],
            directories=["src", "tests"],
            flanesignore_patterns=["__pycache__", "*.pyc"],
        )
        tm.save(template)
        loaded = tm.load("python-basic")
        assert loaded is not None
        assert loaded.name == "python-basic"
        assert loaded.description == "Basic Python project"
        assert len(loaded.files) == 1
        assert loaded.files[0].content == "print('hello')"

    def test_template_list(self, repo):
        from flanes.templates import TemplateManager, WorkspaceTemplate

        tm = TemplateManager(repo.flanes_dir)
        tm.save(WorkspaceTemplate(name="tmpl-a", description="A"))
        tm.save(WorkspaceTemplate(name="tmpl-b", description="B"))
        templates = tm.list()
        names = [t.name for t in templates]
        assert "tmpl-a" in names
        assert "tmpl-b" in names

    def test_template_apply_creates_files(self, repo, tmp_path):
        from flanes.templates import TemplateFile, TemplateManager, WorkspaceTemplate

        tm = TemplateManager(repo.flanes_dir)
        template = WorkspaceTemplate(
            name="test-files",
            files=[
                TemplateFile(path="README.md", content="# Hello"),
                TemplateFile(path="src/app.py", content="app = True"),
            ],
        )
        target = tmp_path / "workspace"
        target.mkdir()
        tm.apply(template, target)
        assert (target / "README.md").read_text() == "# Hello"
        assert (target / "src" / "app.py").read_text() == "app = True"

    def test_template_apply_creates_directories(self, repo, tmp_path):
        from flanes.templates import TemplateManager, WorkspaceTemplate

        tm = TemplateManager(repo.flanes_dir)
        template = WorkspaceTemplate(
            name="test-dirs",
            directories=["src", "tests", "docs/api"],
        )
        target = tmp_path / "workspace"
        target.mkdir()
        tm.apply(template, target)
        assert (target / "src").is_dir()
        assert (target / "tests").is_dir()
        assert (target / "docs" / "api").is_dir()

    def test_template_apply_flaignore(self, repo, tmp_path):
        from flanes.templates import TemplateManager, WorkspaceTemplate

        tm = TemplateManager(repo.flanes_dir)
        template = WorkspaceTemplate(
            name="test-ignore",
            flanesignore_patterns=["__pycache__", "*.pyc", "node_modules"],
        )
        target = tmp_path / "workspace"
        target.mkdir()
        tm.apply(template, target)
        flaignore = (target / ".flanesignore").read_text()
        assert "__pycache__" in flaignore
        assert "*.pyc" in flaignore

    def test_workspace_create_with_template(self, repo):
        from flanes.templates import TemplateFile, TemplateManager, WorkspaceTemplate

        tm = TemplateManager(repo.flanes_dir)
        template = WorkspaceTemplate(
            name="py-project",
            files=[TemplateFile(path="setup.py", content="# setup")],
            directories=["src"],
        )
        tm.save(template)

        # Create workspace and apply template manually (as repo.workspace_create would)
        ws = repo.workspace_create("test-ws", lane="test-ws")
        loaded = tm.load("py-project")
        tm.apply(loaded, ws.path)
        assert (ws.path / "setup.py").read_text() == "# setup"
        assert (ws.path / "src").is_dir()

    def test_template_cli(self, repo_dir):
        # Create
        rc, out, _ = run_fla(
            "template", "create", "my-template", "--description", "Test template", cwd=repo_dir
        )
        assert rc == 0

        # List
        rc, out, _ = run_fla("--json", "template", "list", cwd=repo_dir)
        assert rc == 0
        data = json.loads(out)
        assert len(data) == 1
        assert data[0]["name"] == "my-template"

        # Show
        rc, out, _ = run_fla("--json", "template", "show", "my-template", cwd=repo_dir)
        assert rc == 0
        data = json.loads(out)
        assert data["name"] == "my-template"
        assert data["description"] == "Test template"


# ══════════════════════════════════════════════════════════════
# 3. Evaluation Plugins
# ══════════════════════════════════════════════════════════════


class TestEvaluators:
    def test_evaluator_config_loading(self):
        from flanes.evaluators import load_evaluators

        config = {
            "evaluators": [
                {"name": "pytest", "command": "python -m pytest", "required": True},
                {"name": "ruff", "command": "ruff check .", "required": False},
            ]
        }
        evaluators = load_evaluators(config)
        assert len(evaluators) == 2
        assert evaluators[0].name == "pytest"
        assert evaluators[1].required is False

    def test_run_evaluator_pass(self, tmp_path):
        from flanes.evaluators import EvaluatorConfig, run_evaluator

        ev = EvaluatorConfig(name="pass-test", command=f'{sys.executable} -c "exit(0)"')
        result = run_evaluator(ev, tmp_path)
        assert result.passed is True
        assert result.returncode == 0

    def test_run_evaluator_fail(self, tmp_path):
        from flanes.evaluators import EvaluatorConfig, run_evaluator

        ev = EvaluatorConfig(name="fail-test", command=f'{sys.executable} -c "exit(1)"')
        result = run_evaluator(ev, tmp_path)
        assert result.passed is False
        assert result.returncode == 1

    def test_run_evaluator_timeout(self, tmp_path):
        from flanes.evaluators import EvaluatorConfig, run_evaluator

        ev = EvaluatorConfig(
            name="timeout-test",
            command=f'{sys.executable} -c "import time; time.sleep(10)"',
            timeout_seconds=1,
        )
        result = run_evaluator(ev, tmp_path)
        assert result.passed is False
        assert "timed out" in result.stderr

    def test_required_vs_optional(self, tmp_path):
        from flanes.evaluators import EvaluatorConfig, run_all_evaluators

        evaluators = [
            EvaluatorConfig(
                name="required-pass",
                command=f'{sys.executable} -c "exit(0)"',
                required=True,
            ),
            EvaluatorConfig(
                name="optional-fail",
                command=f'{sys.executable} -c "exit(1)"',
                required=False,
            ),
        ]
        result = run_all_evaluators(evaluators, tmp_path)
        # Overall should pass because the required one passed
        assert result.passed is True
        assert result.checks["required-pass"] is True
        assert result.checks["optional-fail"] is False

    def test_required_fail_overall_fail(self, tmp_path):
        from flanes.evaluators import EvaluatorConfig, run_all_evaluators

        evaluators = [
            EvaluatorConfig(
                name="required-fail",
                command=f'{sys.executable} -c "exit(1)"',
                required=True,
            ),
            EvaluatorConfig(
                name="optional-pass",
                command=f'{sys.executable} -c "exit(0)"',
                required=False,
            ),
        ]
        result = run_all_evaluators(evaluators, tmp_path)
        assert result.passed is False

    def test_evaluate_cli(self, repo_dir):
        # No evaluators configured — should pass
        rc, out, _ = run_fla("--json", "evaluate", cwd=repo_dir)
        assert rc == 0
        data = json.loads(out)
        assert data["passed"] is True


# ══════════════════════════════════════════════════════════════
# 4. Semantic Search (Embeddings)
# ══════════════════════════════════════════════════════════════


class TestEmbeddings:
    def test_cosine_similarity(self):
        from flanes.embeddings import cosine_similarity

        # Identical vectors
        assert cosine_similarity([1, 0, 0], [1, 0, 0]) == pytest.approx(1.0)
        # Orthogonal vectors
        assert cosine_similarity([1, 0, 0], [0, 1, 0]) == pytest.approx(0.0)
        # Opposite vectors
        assert cosine_similarity([1, 0], [-1, 0]) == pytest.approx(-1.0)
        # Zero vector
        assert cosine_similarity([0, 0], [1, 1]) == pytest.approx(0.0)
        # Mismatched lengths should raise
        with pytest.raises(ValueError, match="Vector length mismatch"):
            cosine_similarity([1, 0], [1, 0, 0])

    def test_embedding_storage_retrieval(self, repo):
        from flanes.embeddings import bytes_to_embedding, embedding_to_bytes

        embedding = [0.1, 0.2, 0.3, 0.4]
        emb_bytes = embedding_to_bytes(embedding)

        # Store via WSM
        repo.wsm.store_embedding("intent-1", emb_bytes, "test-model", 4)

        # Retrieve
        raw = repo.wsm.get_embedding("intent-1")
        assert raw is not None
        restored = bytes_to_embedding(raw)
        assert len(restored) == 4
        assert restored[0] == pytest.approx(0.1, abs=1e-5)

    def test_embedding_all_embeddings(self, repo):
        from flanes.embeddings import embedding_to_bytes

        repo.wsm.store_embedding("a", embedding_to_bytes([1.0, 0.0]), "m", 2)
        repo.wsm.store_embedding("b", embedding_to_bytes([0.0, 1.0]), "m", 2)

        all_embs = repo.wsm.all_embeddings()
        assert len(all_embs) == 2

    def test_semantic_search_fallback(self, repo):
        """When no embedding API is configured, falls back to text search."""
        import uuid

        from flanes.state import AgentIdentity, Intent

        agent = AgentIdentity(agent_id="test", agent_type="test")
        intent = Intent(id=str(uuid.uuid4()), prompt="add authentication module", agent=agent)
        head = repo.head()
        repo.wsm.propose(head, head, intent, "main")

        results = repo.semantic_search("authentication")
        assert len(results) > 0
        assert "authentication" in results[0]["prompt"]

    def test_semantic_search_cli(self, repo_dir):
        # Commit something first
        rc, _, _ = run_fla(
            "commit",
            "-m",
            "add user login feature",
            "--agent-id",
            "test",
            "--agent-type",
            "human",
            "--auto-accept",
            cwd=repo_dir,
        )
        assert rc == 0

        rc, out, _ = run_fla("--json", "semantic-search", "login", cwd=repo_dir)
        assert rc == 0
        data = json.loads(out)
        assert len(data) > 0


# ══════════════════════════════════════════════════════════════
# 5. Multi-Repo Projects
# ══════════════════════════════════════════════════════════════


class TestProject:
    def test_project_init(self, tmp_path):
        from flanes.project import Project

        project = Project.init(tmp_path, name="my-project")
        assert (tmp_path / ".flanes-project.json").exists()
        assert project.config.name == "my-project"

    def test_project_add_repo(self, tmp_path):
        from flanes.project import Project
        from flanes.repo import Repository

        # Create project
        project = Project.init(tmp_path, name="multi")

        # Create a flanesnes repo inside
        repo_path = tmp_path / "repo-a"
        repo_path.mkdir()
        (repo_path / "file.txt").write_text("content")
        Repository.init(repo_path)

        project.add_repo("repo-a", "frontend")
        assert len(project.config.repos) == 1
        assert project.config.repos[0].mount_point == "frontend"

        # Reload to verify persistence
        project2 = Project(tmp_path)
        assert len(project2.config.repos) == 1

    def test_project_status(self, tmp_path):
        from flanes.project import Project
        from flanes.repo import Repository

        project = Project.init(tmp_path, name="status-test")

        repo_path = tmp_path / "repo-a"
        repo_path.mkdir()
        (repo_path / "file.txt").write_text("content")
        Repository.init(repo_path)

        project.add_repo("repo-a", "service")
        status = project.status()
        assert status["project"] == "status-test"
        assert "service" in status["repos"]
        assert status["repos"]["service"]["status"] == "ok"

    def test_project_coordinated_snapshot(self, tmp_path):
        from flanes.project import Project
        from flanes.repo import Repository

        project = Project.init(tmp_path, name="snap-test")

        repo_path = tmp_path / "repo-a"
        repo_path.mkdir()
        (repo_path / "file.txt").write_text("content")
        Repository.init(repo_path)

        project.add_repo("repo-a", "backend")
        result = project.coordinated_snapshot()
        assert "backend" in result["snapshots"]

    def test_project_find(self, tmp_path):
        from flanes.project import Project

        Project.init(tmp_path, name="findable")

        # Should find from a subdirectory
        subdir = tmp_path / "deep" / "nested"
        subdir.mkdir(parents=True)
        found = Project.find(subdir)
        assert found.config.name == "findable"

    def test_project_cli(self, tmp_path):
        # Init
        rc, out, _ = run_fla("--json", "project", "init", "--name", "cli-project", cwd=tmp_path)
        assert rc == 0
        data = json.loads(out)
        assert data["name"] == "cli-project"

        # Status (empty)
        rc, out, _ = run_fla("--json", "project", "status", cwd=tmp_path)
        assert rc == 0
        data = json.loads(out)
        assert data["project"] == "cli-project"


# ══════════════════════════════════════════════════════════════
# 6. Remote Storage
# ══════════════════════════════════════════════════════════════


class TestRemote:
    def test_remote_backend_mock(self):
        from flanes.remote import InMemoryBackend

        backend = InMemoryBackend()

        backend.upload("key1", b"data1")
        backend.upload("key2", b"data2")

        assert backend.exists("key1")
        assert not backend.exists("key3")
        assert backend.download("key1") == b"data1"
        assert backend.download("key3") is None

        keys = backend.list_keys()
        assert "key1" in keys
        assert "key2" in keys

        backend.delete("key1")
        assert not backend.exists("key1")

    def test_local_cache_layer(self, tmp_path):
        from flanes.remote import InMemoryBackend, LocalCacheLayer

        backend = InMemoryBackend()
        cache = LocalCacheLayer(backend, tmp_path / "cache")

        # Put data — goes to both backend and cache
        cache.put("hash1", b"content1")
        assert backend.exists("hash1")

        # Get from cache (should hit cache)
        data = cache.get("hash1")
        assert data == b"content1"

        # Get from backend when not in cache
        backend.upload("hash2", b"content2")
        data = cache.get("hash2")
        assert data == b"content2"

        # Cache miss for nonexistent key
        data = cache.get("hash3")
        assert data is None

    def test_remote_sync_push(self, repo):
        from flanes.remote import InMemoryBackend, RemoteSyncManager

        backend = InMemoryBackend()
        sync = RemoteSyncManager(repo.store, backend, repo.flanes_dir / "cache")

        result = sync.push()
        assert result["pushed"] > 0
        assert result["skipped"] == 0

        # Push again — should skip all
        result2 = sync.push()
        assert result2["pushed"] == 0
        assert result2["skipped"] == result["pushed"]

    def test_remote_sync_pull(self, repo):
        from flanes.remote import InMemoryBackend, RemoteSyncManager

        backend = InMemoryBackend()
        sync = RemoteSyncManager(repo.store, backend, repo.flanes_dir / "cache")

        # Push first
        sync.push()

        # Pull (should skip all since local already has everything)
        result = sync.pull()
        assert result["skipped"] > 0
        assert result["pulled"] == 0

    def test_remote_status(self, repo):
        from flanes.remote import InMemoryBackend, RemoteSyncManager

        backend = InMemoryBackend()
        sync = RemoteSyncManager(repo.store, backend, repo.flanes_dir / "cache")

        status = sync.status()
        assert len(status["local_only"]) > 0
        assert len(status["remote_only"]) == 0
        assert len(status["synced"]) == 0

        # After push
        sync.push()
        status = sync.status()
        assert len(status["local_only"]) == 0
        assert len(status["synced"]) > 0

    def test_s3_import_error(self):
        """S3Backend should raise ImportError with helpful message if boto3 missing."""
        # We can't easily test this without mocking, so just verify the class exists
        # The actual import error would happen when instantiating without boto3

    def test_gcs_import_error(self):
        """GCSBackend should raise ImportError with helpful message."""

    def test_remote_cli_no_config(self, repo_dir):
        """Remote commands should error when no remote is configured."""
        rc, out, _ = run_fla("--json", "remote", "status", cwd=repo_dir)
        assert rc == 0  # Exits 0 but reports error
        data = json.loads(out)
        assert "error" in data

    def test_create_backend_memory(self):
        from flanes.remote import create_backend

        backend = create_backend({"remote_storage": {"type": "memory"}})
        backend.upload("test", b"data")
        assert backend.download("test") == b"data"

    def test_create_backend_unknown(self):
        from flanes.remote import create_backend

        with pytest.raises(ValueError, match="Unknown remote storage type"):
            create_backend({"remote_storage": {"type": "ftp"}})
