"""Tests for vr pull command."""

import base64
import io
import json
import zipfile

import pytest
from pathlib import Path
from unittest.mock import patch, Mock

from typer.testing import CliRunner

from vr_cli.cli import app
from vr_cli.commands.pull import extract_code
from vr_cli.utils.agent_lock import AGENT_LOCK_FILE


runner = CliRunner()


# ============================================================================
# Helpers
# ============================================================================

def make_base64_zip(files: dict[str, str]) -> str:
    """Create a base64-encoded ZIP from a dict of {filename: content}."""
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        for name, content in files.items():
            zf.writestr(name, content)
    return base64.b64encode(buf.getvalue()).decode("utf-8")


def make_base64_zip_with_dir(files: dict[str, str], dirs: list[str]) -> str:
    """Create a base64-encoded ZIP that includes explicit directory entries."""
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        for d in dirs:
            # Add directory entry (trailing slash marks it as a directory)
            info = zipfile.ZipInfo(d if d.endswith("/") else d + "/")
            zf.writestr(info, "")
        for name, content in files.items():
            zf.writestr(name, content)
    return base64.b64encode(buf.getvalue()).decode("utf-8")


# ============================================================================
# TestExtractCodeZip
# ============================================================================

class TestExtractCode:
    """Tests for extract_code helper function."""

    def test_extract_files_from_valid_zip(self, temp_dir):
        """Should extract files and return their relative paths."""
        files = {
            "handler.py": "async def handler(event): pass",
            "utils/helpers.py": "def helper(): pass",
        }
        b64 = make_base64_zip(files)

        result = extract_code(b64, str(temp_dir))

        assert sorted(result) == sorted(["handler.py", "utils/helpers.py"])
        assert (temp_dir / "handler.py").read_text() == "async def handler(event): pass"
        assert (temp_dir / "utils" / "helpers.py").read_text() == "def helper(): pass"

    def test_extract_skips_directories(self, temp_dir):
        """Should skip directory entries in the ZIP."""
        b64 = make_base64_zip_with_dir(
            files={"src/main.py": "print('hi')"},
            dirs=["src/"],
        )

        result = extract_code(b64, str(temp_dir))

        # Only file entries should be returned, not directory entries
        assert result == ["src/main.py"]
        assert (temp_dir / "src" / "main.py").exists()

    def test_extract_empty_zip_falls_through_to_plaintext(self, temp_dir):
        """Empty ZIP (no local file headers) is treated as plain text."""
        b64 = make_base64_zip({})

        result = extract_code(b64, str(temp_dir))

        # Empty ZIP lacks PK\x03\x04 header, so extract_code treats it as
        # plain text and writes it to handler.py
        assert result == ["handler.py"]


# ============================================================================
# TestPullExistingProject (Flow A)
# ============================================================================

class TestPullExistingProject:
    """Tests for pulling into an existing voicerun project."""

    def _base_patches(self):
        """Common patches for Flow A tests."""
        return (
            patch("vr_cli.commands.pull.console"),
            patch("vr_cli.commands.pull.print_info"),
            patch("vr_cli.commands.pull.print_success"),
            patch("vr_cli.commands.pull.print_error"),
            patch("vr_cli.commands.pull.confirm"),
            patch("vr_cli.commands.pull.make_request"),
            patch("vr_cli.commands.pull.calculate_project_checksum"),
        )

    def test_pull_existing_project_success(self, voicerun_project_with_lock, monkeypatch):
        """Should pull code, extract files, and update lock."""
        monkeypatch.chdir(voicerun_project_with_lock)
        b64 = make_base64_zip({"handler.py": "new code"})

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.print_error"), \
             patch("vr_cli.commands.pull.confirm") as mock_confirm, \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_confirm.return_value = True
            mock_request.return_value = {
                "code": b64,
                "functionId": "new-function-id",
            }
            mock_checksum.return_value = "abc123"

            result = runner.invoke(app, ["pull"])

        assert result.exit_code == 0
        # Verify API was called with correct payload
        mock_request.assert_called_once()
        call_kwargs = mock_request.call_args[1]
        assert call_kwargs["json"]["agentId"] == "test-agent-id-123"
        assert call_kwargs["json"]["functionId"] == "test-function-id-456"
        # Verify file was extracted
        assert (voicerun_project_with_lock / "handler.py").read_text() == "new code"

    def test_pull_existing_project_with_function_id(self, voicerun_project_with_lock, monkeypatch):
        """Should include functionId in API payload when present in lock."""
        monkeypatch.chdir(voicerun_project_with_lock)
        b64 = make_base64_zip({"handler.py": "code"})

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.confirm") as mock_confirm, \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_confirm.return_value = True
            mock_request.return_value = {"code": b64, "functionId": "resp-fn-id"}
            mock_checksum.return_value = "chk"

            runner.invoke(app, ["pull"])

        call_kwargs = mock_request.call_args[1]
        assert call_kwargs["json"]["functionId"] == "test-function-id-456"

    def test_pull_existing_project_no_function_id(self, voicerun_project, monkeypatch):
        """Should omit functionId from payload when not in lock."""
        # Write a lock file without functionId
        lock_path = voicerun_project / ".voicerun" / "agent.lock"
        lock_path.write_text(json.dumps({"id": "agent-only-id"}))
        monkeypatch.chdir(voicerun_project)
        b64 = make_base64_zip({"handler.py": "code"})

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.confirm") as mock_confirm, \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_confirm.return_value = True
            mock_request.return_value = {"code": b64}
            mock_checksum.return_value = "chk"

            runner.invoke(app, ["pull"])

        call_kwargs = mock_request.call_args[1]
        assert "functionId" not in call_kwargs["json"]

    def test_pull_invalid_agent_lock(self, voicerun_project, monkeypatch):
        """Should error when agent.lock is missing or has no id."""
        # Write a lock with no id
        lock_path = voicerun_project / ".voicerun" / "agent.lock"
        lock_path.write_text(json.dumps({"functionId": "orphan"}))
        monkeypatch.chdir(voicerun_project)

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_error") as mock_error, \
             patch("vr_cli.commands.pull.print_info"):
            result = runner.invoke(app, ["pull"])

        assert result.exit_code != 0
        mock_error.assert_called_once()

    def test_pull_aborted_by_user(self, voicerun_project_with_lock, monkeypatch):
        """Should abort when user declines confirmation."""
        monkeypatch.chdir(voicerun_project_with_lock)

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.confirm") as mock_confirm, \
             patch("vr_cli.commands.pull.make_request") as mock_request:

            mock_confirm.return_value = False

            result = runner.invoke(app, ["pull"])

        assert result.exit_code == 0
        mock_request.assert_not_called()

    def test_pull_skip_confirmation_with_yes(self, voicerun_project_with_lock, monkeypatch):
        """Should skip confirmation prompt with --yes flag."""
        monkeypatch.chdir(voicerun_project_with_lock)
        b64 = make_base64_zip({"handler.py": "code"})

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.confirm") as mock_confirm, \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_request.return_value = {"code": b64, "functionId": "fn-id"}
            mock_checksum.return_value = "chk"

            result = runner.invoke(app, ["pull", "--yes"])

        assert result.exit_code == 0
        mock_confirm.assert_not_called()

    def test_pull_api_failure(self, voicerun_project_with_lock, monkeypatch):
        """Should error when make_request returns None."""
        monkeypatch.chdir(voicerun_project_with_lock)

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_error"), \
             patch("vr_cli.commands.pull.confirm") as mock_confirm, \
             patch("vr_cli.commands.pull.make_request") as mock_request:

            mock_confirm.return_value = True
            mock_request.return_value = None

            result = runner.invoke(app, ["pull"])

        assert result.exit_code != 0

    def test_pull_no_code_in_response(self, voicerun_project_with_lock, monkeypatch):
        """Should error when server returns response without code field."""
        monkeypatch.chdir(voicerun_project_with_lock)

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_error"), \
             patch("vr_cli.commands.pull.confirm") as mock_confirm, \
             patch("vr_cli.commands.pull.make_request") as mock_request:

            mock_confirm.return_value = True
            mock_request.return_value = {"agentId": "id-123"}  # no "code"

            result = runner.invoke(app, ["pull"])

        assert result.exit_code != 0

    def test_pull_updates_agent_lock(self, voicerun_project_with_lock, monkeypatch):
        """Should update lock file with response data and checksum."""
        monkeypatch.chdir(voicerun_project_with_lock)
        b64 = make_base64_zip({"handler.py": "updated"})

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.confirm") as mock_confirm, \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_confirm.return_value = True
            mock_request.return_value = {
                "code": b64,
                "functionId": "updated-fn-id",
            }
            mock_checksum.return_value = "new-checksum-999"

            runner.invoke(app, ["pull"])

        lock_path = voicerun_project_with_lock / ".voicerun" / AGENT_LOCK_FILE
        lock_data = json.loads(lock_path.read_text())
        assert lock_data["id"] == "test-agent-id-123"
        assert lock_data["functionId"] == "updated-fn-id"
        assert lock_data["checksum"] == "new-checksum-999"


# ============================================================================
# TestPullBootstrapNewDirectory (Flow B)
# ============================================================================

class TestPullBootstrapNewDirectory:
    """Tests for bootstrapping a new directory from an agent ID."""

    def test_pull_bootstrap_success(self, temp_dir, monkeypatch):
        """Should pull code into a new directory using agent ID."""
        monkeypatch.chdir(temp_dir)
        b64 = make_base64_zip({"handler.py": "bootstrap code"})

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.AgentRepository") as MockRepo, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_request.return_value = {
                "code": b64,
                "agentId": "resp-agent-id",
                "functionId": "resp-fn-id",
            }
            mock_repo_instance = Mock()
            MockRepo.return_value = mock_repo_instance
            mock_agent = Mock()
            mock_agent.name = "My Agent"
            mock_agent.description = "A test agent"
            mock_repo_instance.get_by_id.return_value = mock_agent
            mock_checksum.return_value = "chk"

            result = runner.invoke(app, ["pull", "some-agent-id"])

        assert result.exit_code == 0
        target = temp_dir / "My Agent"
        assert (target / "handler.py").read_text() == "bootstrap code"
        # Verify .voicerun/agent.lock was created
        lock_path = target / ".voicerun" / AGENT_LOCK_FILE
        assert lock_path.exists()
        lock_data = json.loads(lock_path.read_text())
        assert lock_data["id"] == "resp-agent-id"
        assert lock_data["functionId"] == "resp-fn-id"

    def test_pull_bootstrap_no_agent_id(self, temp_dir, monkeypatch):
        """Should error when no agent ID provided and not in project."""
        monkeypatch.chdir(temp_dir)

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_error"), \
             patch("vr_cli.commands.pull.print_info"):
            result = runner.invoke(app, ["pull"])

        assert result.exit_code != 0

    def test_pull_bootstrap_custom_output(self, temp_dir, monkeypatch):
        """Should use --output flag for target directory."""
        monkeypatch.chdir(temp_dir)
        b64 = make_base64_zip({"handler.py": "output code"})
        custom_dir = str(temp_dir / "custom-output")

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_request.return_value = {
                "code": b64,
                "agentId": "agent-id",
                "functionId": "fn-id",
            }
            mock_checksum.return_value = "chk"

            result = runner.invoke(app, ["pull", "agent-id", "--output", custom_dir])

        assert result.exit_code == 0
        assert (Path(custom_dir) / "handler.py").read_text() == "output code"

    def test_pull_bootstrap_uses_agent_name(self, temp_dir, monkeypatch):
        """Should use agent name from API for directory name."""
        monkeypatch.chdir(temp_dir)
        b64 = make_base64_zip({"main.py": "code"})

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.AgentRepository") as MockRepo, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_request.return_value = {"code": b64, "agentId": "aid", "functionId": "fid"}
            mock_repo_instance = Mock()
            MockRepo.return_value = mock_repo_instance
            mock_agent = Mock()
            mock_agent.name = "Cool Agent"
            mock_agent.description = ""
            mock_repo_instance.get_by_id.return_value = mock_agent
            mock_checksum.return_value = "chk"

            result = runner.invoke(app, ["pull", "some-id"])

        assert result.exit_code == 0
        assert (temp_dir / "Cool Agent").is_dir()

    def test_pull_bootstrap_agent_no_name(self, temp_dir, monkeypatch):
        """Should fall back to agent_id[:8] when agent has no name."""
        monkeypatch.chdir(temp_dir)
        b64 = make_base64_zip({"main.py": "code"})
        agent_id = "abcdef1234567890"

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.AgentRepository") as MockRepo, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_request.return_value = {"code": b64, "agentId": agent_id, "functionId": "fid"}
            mock_repo_instance = Mock()
            MockRepo.return_value = mock_repo_instance
            mock_agent = Mock()
            mock_agent.name = None
            mock_agent.description = None
            mock_repo_instance.get_by_id.return_value = mock_agent
            mock_checksum.return_value = "chk"

            result = runner.invoke(app, ["pull", agent_id])

        assert result.exit_code == 0
        assert (temp_dir / agent_id[:8]).is_dir()

    def test_pull_bootstrap_agent_not_found(self, temp_dir, monkeypatch):
        """Should fall back to agent_id[:8] when agent lookup returns None."""
        monkeypatch.chdir(temp_dir)
        b64 = make_base64_zip({"main.py": "code"})
        agent_id = "abcdef1234567890"

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.AgentRepository") as MockRepo, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_request.return_value = {"code": b64, "agentId": agent_id, "functionId": "fid"}
            mock_repo_instance = Mock()
            MockRepo.return_value = mock_repo_instance
            mock_repo_instance.get_by_id.return_value = None  # agent not found
            mock_checksum.return_value = "chk"

            result = runner.invoke(app, ["pull", agent_id])

        assert result.exit_code == 0
        assert (temp_dir / agent_id[:8]).is_dir()

    def test_pull_bootstrap_existing_dir_overwrite_confirmed(self, temp_dir, monkeypatch):
        """Should overwrite existing directory when user confirms."""
        monkeypatch.chdir(temp_dir)
        # Create existing directory with a file
        existing = temp_dir / "custom-dir"
        existing.mkdir()
        (existing / "old-file.txt").write_text("old content")

        b64 = make_base64_zip({"handler.py": "new code"})

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.confirm") as mock_confirm, \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_confirm.return_value = True
            mock_request.return_value = {"code": b64, "agentId": "aid", "functionId": "fid"}
            mock_checksum.return_value = "chk"

            result = runner.invoke(app, ["pull", "aid", "--output", str(existing)])

        assert result.exit_code == 0
        assert (existing / "handler.py").read_text() == "new code"

    def test_pull_bootstrap_existing_dir_overwrite_declined(self, temp_dir, monkeypatch):
        """Should abort when user declines overwrite of existing directory."""
        monkeypatch.chdir(temp_dir)
        existing = temp_dir / "custom-dir"
        existing.mkdir()
        (existing / "old-file.txt").write_text("old")

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_error"), \
             patch("vr_cli.commands.pull.confirm") as mock_confirm, \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.calculate_project_checksum"):

            mock_confirm.return_value = False
            mock_request.return_value = {"code": "dW51c2Vk", "agentId": "aid", "functionId": "fid"}

            result = runner.invoke(app, ["pull", "aid", "--output", str(existing)])

        assert result.exit_code == 0

    def test_pull_bootstrap_existing_dir_yes_flag(self, temp_dir, monkeypatch):
        """Should skip overwrite prompt with --yes flag."""
        monkeypatch.chdir(temp_dir)
        existing = temp_dir / "custom-dir"
        existing.mkdir()
        (existing / "old-file.txt").write_text("old")

        b64 = make_base64_zip({"handler.py": "new"})

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.confirm") as mock_confirm, \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_request.return_value = {"code": b64, "agentId": "aid", "functionId": "fid"}
            mock_checksum.return_value = "chk"

            result = runner.invoke(app, ["pull", "aid", "--output", str(existing), "--yes"])

        assert result.exit_code == 0
        mock_confirm.assert_not_called()

    def test_pull_bootstrap_api_failure(self, temp_dir, monkeypatch):
        """Should error when make_request returns None."""
        monkeypatch.chdir(temp_dir)

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_error"), \
             patch("vr_cli.commands.pull.make_request") as mock_request:

            mock_request.return_value = None

            result = runner.invoke(app, ["pull", "some-agent-id"])

        assert result.exit_code != 0

    def test_pull_bootstrap_no_code(self, temp_dir, monkeypatch):
        """Should error when server returns response without code."""
        monkeypatch.chdir(temp_dir)

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_error"), \
             patch("vr_cli.commands.pull.make_request") as mock_request:

            mock_request.return_value = {"agentId": "aid"}  # no "code"

            result = runner.invoke(app, ["pull", "some-agent-id"])

        assert result.exit_code != 0

    def test_pull_bootstrap_creates_voicerun_dir(self, temp_dir, monkeypatch):
        """Should create .voicerun/agent.lock in the new directory."""
        monkeypatch.chdir(temp_dir)
        b64 = make_base64_zip({"handler.py": "code"})

        with patch("vr_cli.commands.pull.console"), \
             patch("vr_cli.commands.pull.print_info"), \
             patch("vr_cli.commands.pull.print_success"), \
             patch("vr_cli.commands.pull.make_request") as mock_request, \
             patch("vr_cli.commands.pull.calculate_project_checksum") as mock_checksum:

            mock_request.return_value = {
                "code": b64,
                "agentId": "agent-xyz",
                "functionId": "fn-xyz",
            }
            mock_checksum.return_value = "dir-checksum"

            result = runner.invoke(app, ["pull", "agent-xyz", "--output", str(temp_dir / "new-proj")])

        assert result.exit_code == 0
        new_proj = temp_dir / "new-proj"
        voicerun_dir = new_proj / ".voicerun"
        assert voicerun_dir.is_dir()

        lock_path = voicerun_dir / AGENT_LOCK_FILE
        assert lock_path.exists()
        lock_data = json.loads(lock_path.read_text())
        assert lock_data["id"] == "agent-xyz"
        assert lock_data["functionId"] == "fn-xyz"
        assert lock_data["checksum"] == "dir-checksum"
