from __future__ import annotations

import importlib

import keyring
import pytest
from keyring.backend import KeyringBackend
from typer.testing import CliRunner

from cli.config import CONFIG_DIR_ENV, CLIConfig, ConfigStore


class InMemoryKeyring(KeyringBackend):
    """Simple keyring backend for CLI tests."""

    priority = 1

    def __init__(self) -> None:
        self._storage: dict[tuple[str, str], str] = {}

    def get_password(self, service: str, username: str) -> str | None:
        return self._storage.get((service, username))

    def set_password(self, service: str, username: str, password: str) -> None:
        self._storage[(service, username)] = password

    def delete_password(self, service: str, username: str) -> None:
        self._storage.pop((service, username), None)


def _reload_cli_module():
    import cli.main as cli_main

    return importlib.reload(cli_main)


@pytest.mark.parametrize("subcommand", ["list", "ls"])
def test_projects_list_with_mock_backend(tmp_path, monkeypatch, subcommand):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["projects", subcommand])

    assert result.exit_code == 0
    assert "core" in result.stdout


def test_projects_list_alias_matches_list_output(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True))

    runner = CliRunner()
    result_list = runner.invoke(cli_main.app, ["projects", "list"])
    result_ls = runner.invoke(cli_main.app, ["projects", "ls"])

    assert result_list.exit_code == 0
    assert result_ls.exit_code == 0
    assert result_list.stdout == result_ls.stdout


def test_projects_list_requires_auth(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    keyring.set_keyring(InMemoryKeyring())
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(api_base_url="http://context.local"))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["projects", "list"])

    assert result.exit_code == 1
    assert "Not signed in. Run 'ctxme auth login' to authenticate." in str(result.exception)


def test_set_project_mock_mode_bypasses_checks(tmp_path, monkeypatch):
    """Mock mode bypasses auth and validation checks."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True))

    runner = CliRunner()
    # Any project name works in mock mode - no validation
    result = runner.invoke(cli_main.app, ["set-project", "any-project-name"])

    assert result.exit_code == 0
    config = ConfigStore().load()
    assert config.default_project == "any-project-name"


def test_set_project_requires_auth(tmp_path, monkeypatch):
    """set-project requires authentication in live mode."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    keyring.set_keyring(InMemoryKeyring())
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(api_base_url="http://context.local"))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["set-project", "some-project"])

    assert result.exit_code == 1
    assert "Not signed in. Run 'ctxme auth login' to authenticate." in str(result.exception)


def test_set_project_project_not_found(tmp_path, monkeypatch):
    """set-project fails with helpful message when project doesn't exist."""
    from unittest.mock import MagicMock, patch

    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(api_base_url="http://context.local"))

    # Mock require_auth to pass
    monkeypatch.setattr("cli.commands.config.require_auth", lambda config: None)

    # Mock authenticated_client to raise NotFoundError
    try:
        from shared import NotFoundError
    except ImportError:
        from cli._vendor.shared import NotFoundError

    mock_client = MagicMock()
    mock_client.get_project.side_effect = NotFoundError("Project not found")
    mock_client.__enter__ = MagicMock(return_value=mock_client)
    mock_client.__exit__ = MagicMock(return_value=False)

    with patch("cli.commands.config.authenticated_client", return_value=mock_client):
        runner = CliRunner()
        result = runner.invoke(cli_main.app, ["set-project", "nonexistent"])

    assert result.exit_code == 1
    assert "Project 'nonexistent' not found" in str(result.exception)
    assert "Run 'ctxme projects list' to see available projects" in str(result.exception)


def test_set_project_validates_and_saves(tmp_path, monkeypatch):
    """set-project validates project exists before saving."""
    from unittest.mock import MagicMock, patch

    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(api_base_url="http://context.local"))

    # Mock require_auth to pass
    monkeypatch.setattr("cli.commands.config.require_auth", lambda config: None)

    # Mock authenticated_client to return a successful project
    try:
        from shared import Project
    except ImportError:
        from cli._vendor.shared import Project

    mock_client = MagicMock()
    mock_client.get_project.return_value = (
        Project(key="valid-project", name="Valid Project", description=None),
        "etag123",
    )
    mock_client.__enter__ = MagicMock(return_value=mock_client)
    mock_client.__exit__ = MagicMock(return_value=False)

    with patch("cli.commands.config.authenticated_client", return_value=mock_client):
        runner = CliRunner()
        result = runner.invoke(cli_main.app, ["set-project", "valid-project"])

    assert result.exit_code == 0
    assert "Default project set to" in result.stdout
    assert "valid-project" in result.stdout

    config = ConfigStore().load()
    assert config.default_project == "valid-project"


def test_projects_current_shows_default(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core"))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["projects", "current"])

    assert result.exit_code == 0
    assert "core" in result.stdout


def test_status_without_config_or_auth(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    # Ensure debug mode is off
    monkeypatch.delenv("CTXME_DEBUG", raising=False)
    keyring.set_keyring(InMemoryKeyring())
    cli_main = _reload_cli_module()

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["status"])

    assert result.exit_code == 0
    # Default output hides API and Mode (dev info)
    assert "API:" not in result.stdout
    assert "Mode:" not in result.stdout
    assert "Auth: Not signed in" in result.stdout
    assert "Project: None (use 'set-project' to set)" in result.stdout


def test_status_debug_shows_api_and_mode(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    keyring.set_keyring(InMemoryKeyring())
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(api_base_url="http://test.local"))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["--debug", "status"])

    assert result.exit_code == 0
    # Debug output shows API and Mode
    assert "API: http://test.local" in result.stdout
    assert "Mode: live" in result.stdout
    assert "Auth: Not signed in" in result.stdout


def test_projects_create_with_auto_slugify(tmp_path, monkeypatch):
    """Test that create command auto-generates key from name."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True))

    runner = CliRunner()
    # Answer 'n' to "Set as default project?" prompt
    result = runner.invoke(cli_main.app, ["projects", "create", "My Test Project"], input="n\n")

    assert result.exit_code == 0
    assert "Created project" in result.stdout
    assert "my-test-project" in result.stdout


def test_projects_create_with_explicit_key(tmp_path, monkeypatch):
    """Test that create command uses explicit key when provided."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["projects", "create", "My Project", "--key", "custom-key", "-d", "A description"],
        input="n\n",
    )

    assert result.exit_code == 0
    assert "Created project" in result.stdout
    assert "custom-key" in result.stdout


def test_projects_create_sets_default_when_confirmed(tmp_path, monkeypatch):
    """Test that create sets default project when user confirms in TTY mode."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    # Mock _is_tty to return True so the prompt appears
    monkeypatch.setattr("cli.commands.projects._is_tty", lambda: True)
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True))

    runner = CliRunner()
    # Answer 'y' to "Set as default project?" prompt
    result = runner.invoke(cli_main.app, ["projects", "create", "New Project"], input="y\n")

    assert result.exit_code == 0
    assert "Default project set to" in result.stdout

    config = ConfigStore().load()
    assert config.default_project == "new-project"


def test_projects_create_duplicate_key_fails(tmp_path, monkeypatch):
    """Test that create fails when key already exists."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True))

    runner = CliRunner()
    # 'core' already exists in mock backend
    result = runner.invoke(cli_main.app, ["projects", "create", "Core"], input="n\n")

    assert result.exit_code == 1
    assert "already exists" in str(result.exception)


def test_projects_create_invalid_key_fails(tmp_path, monkeypatch):
    """Test that create fails with invalid key format."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["projects", "create", "Test", "--key", "ab"], input="n\n")

    assert result.exit_code == 1
    assert "between 3 and 64 characters" in str(result.exception)


def test_projects_delete_empty_project(tmp_path, monkeypatch):
    """Test that delete succeeds for empty project using patched mock client."""
    from unittest.mock import patch

    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True))

    # Patch the MockBackendClient to have an empty project
    try:
        from shared import MockBackendClient, Project
    except ImportError:
        from cli._vendor.shared import MockBackendClient, Project

    original_init = MockBackendClient.__init__

    def patched_init(self):
        original_init(self)
        self._projects.append(Project(key="empty-project", name="Empty", description=None))

    runner = CliRunner()
    with patch.object(MockBackendClient, "__init__", patched_init):
        result = runner.invoke(cli_main.app, ["projects", "delete", "empty-project"])

    assert result.exit_code == 0
    assert "Deleted project" in result.stdout


def test_projects_delete_non_empty_fails(tmp_path, monkeypatch):
    """Test that delete fails for project with items."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True))

    runner = CliRunner()
    # 'core' has items in mock backend
    result = runner.invoke(cli_main.app, ["projects", "delete", "core"])

    assert result.exit_code == 1
    assert "existing items or files" in str(result.exception)


def test_projects_delete_not_found_fails(tmp_path, monkeypatch):
    """Test that delete fails for non-existent project."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["projects", "delete", "nonexistent"])

    assert result.exit_code == 1
    assert "not found" in str(result.exception)


def test_projects_delete_default_requires_yes_flag(tmp_path, monkeypatch):
    """Test that deleting default project requires --yes in non-TTY."""
    from unittest.mock import patch

    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    # Patch to add an empty project and set it as default
    try:
        from shared import MockBackendClient, Project
    except ImportError:
        from cli._vendor.shared import MockBackendClient, Project

    original_init = MockBackendClient.__init__

    def patched_init(self):
        original_init(self)
        self._projects.append(Project(key="my-default", name="My Default", description=None))

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True, default_project="my-default"))

    runner = CliRunner()
    with patch.object(MockBackendClient, "__init__", patched_init):
        # Try to delete without --yes (non-TTY mode)
        result = runner.invoke(cli_main.app, ["projects", "delete", "my-default"], input="")

    assert result.exit_code == 1
    assert "Use --yes to confirm deletion" in str(result.exception)


def test_projects_delete_default_with_yes_clears_default(tmp_path, monkeypatch):
    """Test that deleting default project with --yes clears the default."""
    from unittest.mock import patch

    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    # Patch to add an empty project and set it as default
    try:
        from shared import MockBackendClient, Project
    except ImportError:
        from cli._vendor.shared import MockBackendClient, Project

    original_init = MockBackendClient.__init__

    def patched_init(self):
        original_init(self)
        self._projects.append(Project(key="my-default", name="My Default", description=None))

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True, default_project="my-default"))

    runner = CliRunner()
    with patch.object(MockBackendClient, "__init__", patched_init):
        # Delete with --yes
        result = runner.invoke(cli_main.app, ["projects", "delete", "my-default", "--yes"])

    assert result.exit_code == 0
    assert "Deleted project" in result.stdout

    config = ConfigStore().load()
    assert config.default_project is None


def test_projects_delete_default_invalid_selection_clears_default(tmp_path, monkeypatch):
    """Test that invalid selection after delete clears default_project."""
    from unittest.mock import patch

    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    # Mock _is_tty to return True so the interactive prompts appear
    monkeypatch.setattr("cli.commands.projects._is_tty", lambda: True)
    cli_main = _reload_cli_module()

    # Patch to add an empty project and set it as default
    try:
        from shared import MockBackendClient, Project
    except ImportError:
        from cli._vendor.shared import MockBackendClient, Project

    original_init = MockBackendClient.__init__

    def patched_init(self):
        original_init(self)
        self._projects.append(Project(key="my-default", name="My Default", description=None))

    store = ConfigStore()
    store.save(CLIConfig(use_mock_client=True, default_project="my-default"))

    runner = CliRunner()
    with patch.object(MockBackendClient, "__init__", patched_init):
        # Delete in TTY mode, confirm deletion, say yes to setting new default,
        # then enter invalid selection
        result = runner.invoke(
            cli_main.app,
            ["projects", "delete", "my-default"],
            input="y\ny\ninvalid-selection\n",
        )

    assert result.exit_code == 0
    assert "Deleted project" in result.stdout
    assert "Invalid selection" in result.stdout

    config = ConfigStore().load()
    assert config.default_project is None
