"""Unit tests for navari_cli.cli module.

Tests CLI commands without a running server, using either:
- Direct invocation (version, config, whoami, login validation, logout)
- Mocked API client (conversations, files, runs, chat, error handling)
"""

import contextlib
import json
from unittest.mock import AsyncMock, patch

import httpx
import pytest
from navari_cli.cli import app
from navari_cli.config import NavariConfig
from typer.testing import CliRunner

runner = CliRunner()


# ============================================================================
# Mock helpers
# ============================================================================


@contextlib.asynccontextmanager
async def _mock_client(conversations=None, files=None, runs=None, messages=None, profile=None):
    """Mock _authenticated_client that returns canned data."""
    client = AsyncMock()
    client.list_conversations = AsyncMock(return_value=conversations or [])
    client.create_conversation = AsyncMock(return_value={"id": "test-id", "title": "Test"})
    client.get_conversation = AsyncMock(
        return_value={"id": "test-id", "title": "Test", "created_at": "2025-01-01", "status": "idle"}
    )
    client.delete_conversation = AsyncMock()
    client.get_messages = AsyncMock(return_value=messages or [])
    client.list_files = AsyncMock(return_value=files or [])
    client.upload_file = AsyncMock(return_value={"id": "f1", "filename": "test.txt"})
    client.get_file_metadata = AsyncMock(return_value={"id": "f1", "filename": "test.txt"})
    client.download_file = AsyncMock(return_value=b"content")
    client.delete_file = AsyncMock()
    client.download_workspace = AsyncMock(return_value=b"PK\x03\x04zipdata")
    client.list_runs = AsyncMock(return_value=runs or [])
    client.get_run_status = AsyncMock(return_value={"conversation_id": "c1", "state": "idle"})
    client.cancel_run = AsyncMock()
    client.get_user_profile = AsyncMock(return_value=profile or {"user": {"username": "test@test.com"}})
    yield client, "https://mock.server", "test@test.com"


MOCK = "navari_cli.cli._authenticated_client"


def _chat_patches(client_mock=None):
    """Common patches for chat tests (chat uses _resolve_server/_make_client, not _authenticated_client)."""
    if client_mock is None:
        client_mock = AsyncMock()
        client_mock.list_conversations = AsyncMock(return_value=[])
        client_mock.create_conversation = AsyncMock(
            side_effect=lambda title="New Conversation", **kw: {"id": "c1", "title": title}
        )
        client_mock.upload_file = AsyncMock(return_value={"id": "f1", "file_id": "f1"})
    # Ensure auto-rename methods return dicts (avoids unawaited coroutine warnings)
    client_mock.get_conversation = AsyncMock(return_value={"id": "c1", "title": "Existing"})
    client_mock.update_conversation = AsyncMock(return_value={"id": "c1", "title": "renamed"})
    mock_cm = AsyncMock(__aenter__=AsyncMock(return_value=client_mock), __aexit__=AsyncMock(return_value=False))
    return (
        patch("navari_cli.cli._resolve_server", return_value="https://mock.server"),
        patch("navari_cli.cli._resolve_token", new_callable=AsyncMock, return_value=("tok", "user")),
        patch("navari_cli.cli._make_client", return_value=mock_cm),
    )


# ============================================================================
# Sample data
# ============================================================================

SAMPLE_CONVS = [
    {"id": "c1", "title": "Conv 1", "created_at": "2025-01-01T00:00:00", "status": "idle"},
    {"id": "c2", "title": "Conv 2", "created_at": "2025-01-02T00:00:00", "status": "running"},
]

SAMPLE_FILES = [
    {"id": "f1", "filename": "report.pdf", "size": 1024, "content_type": "application/pdf"},
]

SAMPLE_RUNS = [
    {"conversation_id": "c1", "state": "running"},
    {"conversation_id": "c2", "state": "idle"},
]

SAMPLE_MESSAGES = [
    {"event_kind": "processing_started", "query": "hello"},
    {
        "event_kind": "function_tool_call",
        "part": {"tool_name": "send_message_to_user", "args": '{"message": "Hi there"}'},
    },
    {"event_kind": "function_tool_call", "part": {"tool_name": "web_search", "args": '{"query": "test"}'}},
    {
        "event_kind": "function_tool_call",
        "part": {
            "tool_name": "write_to_do_file",
            "args": json.dumps({"to_do_path": "/workspace/todo.md", "content": "# Tasks\n- [x] Done\n- [ ] Pending"}),
        },
    },
    {"event_kind": "function_tool_call", "part": {"tool_name": "ask_user", "args": '{"question": "What next?"}'}},
    {"event_kind": "function_tool_result", "result": {"tool_name": "ask_user", "content": "Continue"}},
    {"event_kind": "agent_result", "summary": "All done"},
]


# ============================================================================
# Commands that need no server and no mocking
# ============================================================================


class TestVersionCommand:
    def test_version_output(self):
        result = runner.invoke(app, ["version"])
        assert result.exit_code == 0
        assert "Navari CLI v" in result.output


class TestGlobalOptions:
    """Test global option handling."""

    def test_no_color_flag(self):
        result = runner.invoke(app, ["--no-color", "version"])
        assert result.exit_code == 0
        assert "Navari CLI v" in result.output

    def test_debug_flag(self):
        result = runner.invoke(app, ["--debug", "version"])
        assert result.exit_code == 0


# ============================================================================
# Commands that need _tmp_config but no server
# ============================================================================


class TestConfigCommand:
    @pytest.fixture(autouse=True)
    def _use_tmp_config(self, tmp_path, monkeypatch):
        monkeypatch.setattr(NavariConfig, "CONFIG_DIR", tmp_path)
        monkeypatch.setattr(NavariConfig, "CREDENTIALS_FILE", tmp_path / "credentials.json")
        monkeypatch.setattr(NavariConfig, "CONFIG_FILE", tmp_path / "config.json")
        monkeypatch.setattr(NavariConfig, "MSAL_CACHE_FILE", tmp_path / "msal_cache.json")

    def test_config_show_empty(self):
        result = runner.invoke(app, ["config"])
        assert result.exit_code == 0
        assert "Config directory" in result.output
        assert "not set" in result.output
        assert "No saved servers" in result.output

    def test_config_show_with_server(self):
        NavariConfig.save_credentials("https://s.com", "tok", "user")
        NavariConfig.set_last_used_server("https://s.com")
        result = runner.invoke(app, ["config"])
        assert result.exit_code == 0
        assert "https://s.com" in result.output

    def test_config_switch_server(self):
        NavariConfig.save_credentials("https://a.com", "t1", "u1")
        NavariConfig.save_credentials("https://b.com", "t2", "u2")
        NavariConfig.set_last_used_server("https://a.com")

        result = runner.invoke(app, ["config", "https://b.com"])
        assert result.exit_code == 0
        assert "Switched to" in result.output
        assert NavariConfig.get_last_used_server() == "https://b.com"

    def test_config_switch_unknown_server(self):
        result = runner.invoke(app, ["config", "https://unknown.com"])
        assert result.exit_code == 1
        assert "No credentials" in result.output


class TestWhoamiCommand:
    @pytest.fixture(autouse=True)
    def _use_tmp_config(self, tmp_path, monkeypatch):
        monkeypatch.setattr(NavariConfig, "CONFIG_DIR", tmp_path)
        monkeypatch.setattr(NavariConfig, "CREDENTIALS_FILE", tmp_path / "credentials.json")
        monkeypatch.setattr(NavariConfig, "CONFIG_FILE", tmp_path / "config.json")

    def test_whoami_with_credentials(self):
        NavariConfig.save_credentials("https://s.com", "tok", "user@test.com")
        NavariConfig.set_last_used_server("https://s.com")
        result = runner.invoke(app, ["whoami"])
        assert result.exit_code == 0
        assert "Server:" in result.output
        assert "User:" in result.output
        assert "user@test.com" in result.output

    def test_whoami_no_server(self):
        result = runner.invoke(app, ["whoami"])
        assert result.exit_code == 1
        assert "No server configured" in result.output

    def test_whoami_not_logged_in(self):
        NavariConfig.set_last_used_server("https://s.com")
        result = runner.invoke(app, ["whoami"])
        assert result.exit_code == 1
        assert "Not logged in" in result.output


class TestLoginValidation:
    """Test login argument validation (no server needed)."""

    @pytest.fixture(autouse=True)
    def _use_tmp(self, _tmp_config):
        pass

    def test_mutually_exclusive_options(self):
        result = runner.invoke(app, ["login", "https://s.com", "--device-code", "--token", "abc"])
        assert result.exit_code == 1
        assert "only one of" in result.output.lower()

    def test_username_without_password(self):
        result = runner.invoke(app, ["login", "https://s.com", "--username", "user@test.com"])
        assert result.exit_code == 1
        assert "Both --username and --password" in result.output

    def test_password_without_username(self):
        result = runner.invoke(app, ["login", "https://s.com", "--password", "secret"])
        assert result.exit_code == 1
        assert "Both --username and --password" in result.output

    def test_no_server_url(self):
        result = runner.invoke(app, ["login"])
        assert result.exit_code == 1
        assert "Server URL required" in result.output


class TestLogoutCommand:
    @pytest.fixture(autouse=True)
    def _use_tmp(self, _tmp_config):
        pass

    def test_logout_specific_server(self):
        NavariConfig.save_credentials("https://a.com", "t1", "u1")
        NavariConfig.save_credentials("https://b.com", "t2", "u2")
        NavariConfig.set_last_used_server("https://a.com")

        result = runner.invoke(app, ["logout", "https://a.com"])
        assert result.exit_code == 0
        assert "Logged out from https://a.com" in result.output
        assert NavariConfig.get_last_used_server() is None
        assert "https://b.com" in NavariConfig.list_saved_servers()

    def test_logout_all(self):
        NavariConfig.save_credentials("https://a.com", "t1", "u1")
        NavariConfig.save_credentials("https://b.com", "t2", "u2")
        NavariConfig.set_last_used_server("https://a.com")

        result = runner.invoke(app, ["logout"])
        assert result.exit_code == 0
        assert "Logged out from all servers" in result.output
        assert NavariConfig.get_last_used_server() is None
        assert NavariConfig.list_saved_servers() == []

    def test_logout_nonexistent_is_ok(self):
        result = runner.invoke(app, ["logout", "https://nonexistent.com"])
        assert result.exit_code == 0


class TestNoServerErrors:
    """Test that commands fail cleanly when no server is configured."""

    @pytest.fixture(autouse=True)
    def _use_tmp(self, _tmp_config):
        pass

    def test_conv_list_no_server(self):
        result = runner.invoke(app, ["conversations", "list"])
        assert result.exit_code == 1
        assert "No server configured" in result.output

    def test_files_list_no_server(self):
        result = runner.invoke(app, ["files", "list"])
        assert result.exit_code == 1

    def test_runs_list_no_server(self):
        result = runner.invoke(app, ["runs", "list"])
        assert result.exit_code == 1

    def test_chat_no_server(self):
        result = runner.invoke(app, ["chat", "--new", "-m", "hello"])
        assert result.exit_code == 1


# ============================================================================
# Mocked command tests — conversations
# ============================================================================


class TestConversations:
    def test_list_plain(self):
        with patch(MOCK, return_value=_mock_client(conversations=SAMPLE_CONVS)):
            result = runner.invoke(app, ["conversations", "list"])
        assert result.exit_code == 0
        assert "c1" in result.output
        assert "Conv 1" in result.output

    def test_list_json(self):
        with patch(MOCK, return_value=_mock_client(conversations=SAMPLE_CONVS)):
            result = runner.invoke(app, ["conversations", "list", "--format", "json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert len(data) == 2

    def test_list_table(self):
        with patch(MOCK, return_value=_mock_client(conversations=SAMPLE_CONVS)):
            result = runner.invoke(app, ["conversations", "list", "--format", "table"])
        assert result.exit_code == 0
        assert "Conversations" in result.output
        assert "Conv 1" in result.output

    def test_create(self):
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["conversations", "create", "--title", "New"])
        assert result.exit_code == 0
        assert "Created conversation: Test (test-id)" in result.output

    def test_create_json(self):
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["conversations", "create", "--title", "New", "--format", "json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["id"] == "test-id"

    def test_get(self):
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["conversations", "get", "test-id"])
        assert result.exit_code == 0
        assert "test-id" in result.output

    def test_get_json(self):
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["conversations", "get", "test-id", "--format", "json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["id"] == "test-id"

    def test_delete(self):
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["conversations", "delete", "test-id", "--yes"])
        assert result.exit_code == 0
        assert "Deleted" in result.output

    def test_messages_plain(self):
        with patch(MOCK, return_value=_mock_client(messages=SAMPLE_MESSAGES)):
            result = runner.invoke(app, ["conversations", "messages", "test-id"])
        assert result.exit_code == 0
        assert "hello" in result.output
        assert "Hi there" in result.output
        assert 'use tool "web_search"' in result.output
        assert 'use tool "ask_user"' in result.output
        assert "What next?" in result.output
        assert "All done" in result.output
        assert "Tasks" in result.output
        assert "done" in result.output

    def test_messages_json(self):
        with patch(MOCK, return_value=_mock_client(messages=SAMPLE_MESSAGES)):
            result = runner.invoke(app, ["conversations", "messages", "test-id", "--format", "json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert len(data) == len(SAMPLE_MESSAGES)

    def test_messages_empty(self):
        with patch(MOCK, return_value=_mock_client(messages=[])):
            result = runner.invoke(app, ["conversations", "messages", "test-id"])
        assert result.exit_code == 0
        assert "No messages" in result.output

    def test_messages_dict_args(self):
        msgs = [
            {
                "event_kind": "function_tool_call",
                "part": {"tool_name": "send_message_to_user", "args": {"message": "Direct"}},
            }
        ]
        with patch(MOCK, return_value=_mock_client(messages=msgs)):
            result = runner.invoke(app, ["conversations", "messages", "test-id"])
        assert result.exit_code == 0
        assert "Direct" in result.output

    def test_messages_malformed_args(self):
        """Test function_tool_call with unparseable JSON args."""
        msgs = [{"event_kind": "function_tool_call", "part": {"tool_name": "unknown_tool", "args": "not json{"}}]
        with patch(MOCK, return_value=_mock_client(messages=msgs)):
            result = runner.invoke(app, ["conversations", "messages", "test-id"])
        assert result.exit_code == 0
        assert 'use tool "unknown_tool"' in result.output

    def test_messages_empty_args(self):
        """Test function_tool_call with empty args string."""
        msgs = [{"event_kind": "function_tool_call", "part": {"tool_name": "some_tool", "args": ""}}]
        with patch(MOCK, return_value=_mock_client(messages=msgs)):
            result = runner.invoke(app, ["conversations", "messages", "test-id"])
        assert result.exit_code == 0
        assert 'use tool "some_tool"' in result.output

    def test_messages_ask_with_examples(self):
        msgs = [
            {
                "event_kind": "function_tool_call",
                "part": {"tool_name": "ask_user_with_examples", "args": '{"question": "Pick", "examples": ["A", "B"]}'},
            },
            {"event_kind": "function_tool_result", "result": {"tool_name": "ask_user_with_examples", "content": "A"}},
        ]
        with patch(MOCK, return_value=_mock_client(messages=msgs)):
            result = runner.invoke(app, ["conversations", "messages", "test-id"])
        assert result.exit_code == 0
        assert "Pick" in result.output
        assert "1. A" in result.output
        assert "2. B" in result.output


# ============================================================================
# Mocked command tests — files
# ============================================================================


class TestFiles:
    def test_list_plain(self):
        with patch(MOCK, return_value=_mock_client(files=SAMPLE_FILES)):
            result = runner.invoke(app, ["files", "list", "--conversation", "c1"])
        assert result.exit_code == 0
        assert "report.pdf" in result.output

    def test_list_table(self):
        with patch(MOCK, return_value=_mock_client(files=SAMPLE_FILES)):
            result = runner.invoke(app, ["files", "list", "--conversation", "c1", "--format", "table"])
        assert result.exit_code == 0
        assert "Files" in result.output

    def test_list_json(self):
        with patch(MOCK, return_value=_mock_client(files=SAMPLE_FILES)):
            result = runner.invoke(app, ["files", "list", "--conversation", "c1", "--format", "json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data[0]["filename"] == "report.pdf"

    def test_list_empty(self):
        with patch(MOCK, return_value=_mock_client(files=[])):
            result = runner.invoke(app, ["files", "list", "--conversation", "c1"])
        assert result.exit_code == 0
        assert "No files" in result.output

    def test_upload(self, tmp_path):
        f = tmp_path / "test.txt"
        f.write_text("hello")
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["files", "upload", str(f), "--conversation", "c1"])
        assert result.exit_code == 0
        assert "Uploaded" in result.output

    def test_upload_json(self, tmp_path):
        f = tmp_path / "test.txt"
        f.write_text("hello")
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["files", "upload", str(f), "--conversation", "c1", "--format", "json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["id"] == "f1"

    def test_download(self, tmp_path):
        out = tmp_path / "out.txt"
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["files", "download", "f1", "--output", str(out)])
        assert result.exit_code == 0
        assert "Downloaded" in result.output
        assert out.read_bytes() == b"content"

    def test_delete(self):
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["files", "delete", "f1", "--yes"])
        assert result.exit_code == 0
        assert "Deleted" in result.output

    def test_workspace(self, tmp_path):
        out = tmp_path / "ws.zip"
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["files", "workspace", "c1", "--output", str(out)])
        assert result.exit_code == 0
        assert out.exists()


# ============================================================================
# Mocked command tests — runs
# ============================================================================


class TestRuns:
    def test_list_plain(self):
        with patch(MOCK, return_value=_mock_client(runs=SAMPLE_RUNS)):
            result = runner.invoke(app, ["runs", "list"])
        assert result.exit_code == 0
        assert "c1" in result.output
        assert "running" in result.output

    def test_list_table(self):
        with patch(MOCK, return_value=_mock_client(runs=SAMPLE_RUNS)):
            result = runner.invoke(app, ["runs", "list", "--format", "table"])
        assert result.exit_code == 0
        assert "Runs" in result.output

    def test_list_json(self):
        with patch(MOCK, return_value=_mock_client(runs=SAMPLE_RUNS)):
            result = runner.invoke(app, ["runs", "list", "--format", "json"])
        assert result.exit_code == 0

    def test_list_empty(self):
        with patch(MOCK, return_value=_mock_client(runs=[])):
            result = runner.invoke(app, ["runs", "list"])
        assert result.exit_code == 0
        assert "No runs" in result.output

    def test_status(self):
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["runs", "status", "c1"])
        assert result.exit_code == 0
        assert "c1" in result.output

    def test_status_json(self):
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["runs", "status", "c1", "--format", "json"])
        assert result.exit_code == 0

    def test_cancel(self):
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["runs", "cancel", "c1"])
        assert result.exit_code == 0
        assert "Cancelled" in result.output


# ============================================================================
# Mocked command tests — login flows
# ============================================================================


class TestLogin:
    @pytest.fixture(autouse=True)
    def _use_tmp(self, _tmp_config):
        pass

    def test_login_with_token(self):
        mock_profile = {"user": {"username": "tok-user@test.com"}}
        with patch(
            "navari_cli.cli._make_client",
            return_value=AsyncMock(
                __aenter__=AsyncMock(return_value=AsyncMock(get_user_profile=AsyncMock(return_value=mock_profile))),
                __aexit__=AsyncMock(return_value=False),
            ),
        ):
            result = runner.invoke(app, ["login", "https://s.com", "--token", "fake-jwt"])
        assert result.exit_code == 0
        assert "Logged in as" in result.output

    def test_login_re_login_to_last_server(self):
        NavariConfig.set_last_used_server("https://saved.com")
        result = runner.invoke(app, ["login"])
        assert result.exit_code == 1
        assert "saved.com" in result.output

    def test_login_with_password(self):
        """Login via --username/--password using mocked auth flows."""
        mock_profile = {"user": {"username": "pw-user@test.com"}}
        auth_config = {"client_id": "c", "authority": "https://auth", "scopes": ["s"]}

        with (
            patch("navari_cli.cli._fetch_auth_config", new_callable=AsyncMock, return_value=auth_config),
            patch("navari_cli.cli.password_flow", new_callable=AsyncMock, return_value=("tok", None)),
            patch("navari_cli.cli.NavariConfig.load_msal_cache"),
            patch("navari_cli.cli.NavariConfig.save_msal_cache"),
            patch(
                "navari_cli.cli._make_client",
                return_value=AsyncMock(
                    __aenter__=AsyncMock(return_value=AsyncMock(get_user_profile=AsyncMock(return_value=mock_profile))),
                    __aexit__=AsyncMock(return_value=False),
                ),
            ),
        ):
            result = runner.invoke(app, ["login", "https://s.com", "-u", "user", "-p", "pass"])
        assert result.exit_code == 0
        assert "Logged in as" in result.output

    def test_login_profile_fetch_fails(self):
        """Login succeeds even if profile fetch fails (falls back to 'authenticated-user')."""
        auth_config = {"client_id": "c", "authority": "https://auth", "scopes": ["s"]}

        with (
            patch("navari_cli.cli._fetch_auth_config", new_callable=AsyncMock, return_value=auth_config),
            patch("navari_cli.cli.password_flow", new_callable=AsyncMock, return_value=("tok", None)),
            patch("navari_cli.cli.NavariConfig.load_msal_cache"),
            patch("navari_cli.cli.NavariConfig.save_msal_cache"),
            patch(
                "navari_cli.cli._make_client",
                return_value=AsyncMock(
                    __aenter__=AsyncMock(
                        return_value=AsyncMock(get_user_profile=AsyncMock(side_effect=Exception("timeout")))
                    ),
                    __aexit__=AsyncMock(return_value=False),
                ),
            ),
        ):
            result = runner.invoke(app, ["login", "https://s.com", "-u", "user", "-p", "pass"])
        assert result.exit_code == 0
        assert "authenticated-user" in result.output

    def test_login_no_verify_stores_setting(self):
        """Login with --no-verify saves verify_ssl=False for the server."""
        mock_profile = {"user": {"username": "user@test.com"}}
        with patch(
            "navari_cli.cli._make_client",
            return_value=AsyncMock(
                __aenter__=AsyncMock(return_value=AsyncMock(get_user_profile=AsyncMock(return_value=mock_profile))),
                __aexit__=AsyncMock(return_value=False),
            ),
        ):
            result = runner.invoke(app, ["login", "https://s.com", "--no-verify", "--token", "jwt"])
        assert result.exit_code == 0
        assert NavariConfig.get_server_option("https://s.com", "verify_ssl") is False

    def test_login_without_no_verify_resets_setting(self):
        """Login without --no-verify resets verify_ssl to True."""
        # First login with --no-verify
        NavariConfig.save_credentials("https://s.com", "old-tok", "user")
        NavariConfig.set_server_option("https://s.com", "verify_ssl", False)

        mock_profile = {"user": {"username": "user@test.com"}}
        with patch(
            "navari_cli.cli._make_client",
            return_value=AsyncMock(
                __aenter__=AsyncMock(return_value=AsyncMock(get_user_profile=AsyncMock(return_value=mock_profile))),
                __aexit__=AsyncMock(return_value=False),
            ),
        ):
            result = runner.invoke(app, ["login", "https://s.com", "--token", "new-jwt"])
        assert result.exit_code == 0
        assert NavariConfig.get_server_option("https://s.com", "verify_ssl") is True

    def test_relogin_preserves_no_verify(self):
        """Plain `navari login` (re-login) preserves existing verify_ssl=False."""
        NavariConfig.save_credentials("https://s.com", "tok", "user")
        NavariConfig.set_server_option("https://s.com", "verify_ssl", False)
        NavariConfig.set_last_used_server("https://s.com")

        mock_profile = {"user": {"username": "user@test.com"}}
        auth_config = {"client_id": "c", "authority": "https://auth", "scopes": ["s"]}
        with (
            patch("navari_cli.cli._fetch_auth_config", new_callable=AsyncMock, return_value=auth_config),
            patch("navari_cli.cli.browser_oauth_flow", new_callable=AsyncMock, return_value=("tok", None)),
            patch("navari_cli.cli.NavariConfig.load_msal_cache"),
            patch("navari_cli.cli.NavariConfig.save_msal_cache"),
            patch(
                "navari_cli.cli._make_client",
                return_value=AsyncMock(
                    __aenter__=AsyncMock(return_value=AsyncMock(get_user_profile=AsyncMock(return_value=mock_profile))),
                    __aexit__=AsyncMock(return_value=False),
                ),
            ),
        ):
            result = runner.invoke(app, ["login"])
        assert result.exit_code == 0
        # verify_ssl should still be False (preserved from previous login)
        assert NavariConfig.get_server_option("https://s.com", "verify_ssl") is False


class TestLoginFlows:
    """Test different login OAuth flow paths with mocked auth."""

    @pytest.fixture(autouse=True)
    def _use_tmp(self, _tmp_config):
        pass

    def _mock_login(self, flow_patch_name, flow_mock):
        auth_config = {"client_id": "c", "authority": "https://auth", "scopes": ["s"]}
        mock_profile = {"user": {"username": "flow-user@test.com"}}
        return (
            patch("navari_cli.cli._fetch_auth_config", new_callable=AsyncMock, return_value=auth_config),
            patch(f"navari_cli.cli.{flow_patch_name}", new_callable=AsyncMock, return_value=("tok", None)),
            patch("navari_cli.cli.NavariConfig.load_msal_cache"),
            patch("navari_cli.cli.NavariConfig.save_msal_cache"),
            patch(
                "navari_cli.cli._make_client",
                return_value=AsyncMock(
                    __aenter__=AsyncMock(return_value=AsyncMock(get_user_profile=AsyncMock(return_value=mock_profile))),
                    __aexit__=AsyncMock(return_value=False),
                ),
            ),
        )

    def test_login_device_code(self):
        patches = self._mock_login("device_code_flow", AsyncMock(return_value=("tok", None)))
        with patches[0], patches[1], patches[2], patches[3], patches[4]:
            result = runner.invoke(app, ["login", "https://s.com", "--device-code"])
        assert result.exit_code == 0
        assert "Logged in as" in result.output

    def test_login_browser(self):
        patches = self._mock_login("browser_oauth_flow", AsyncMock(return_value=("tok", None)))
        with patches[0], patches[1], patches[2], patches[3], patches[4]:
            result = runner.invoke(app, ["login", "https://s.com"])
        assert result.exit_code == 0
        assert "Logged in as" in result.output

    def test_login_browser_fallback_to_device_code(self):
        """Browser login fails, falls back to device code."""
        auth_config = {"client_id": "c", "authority": "https://auth", "scopes": ["s"]}
        mock_profile = {"user": {"username": "fb-user@test.com"}}
        with (
            patch("navari_cli.cli._fetch_auth_config", new_callable=AsyncMock, return_value=auth_config),
            patch("navari_cli.cli.browser_oauth_flow", new_callable=AsyncMock, side_effect=RuntimeError("no browser")),
            patch("navari_cli.cli.device_code_flow", new_callable=AsyncMock, return_value=("tok", None)),
            patch("navari_cli.cli.NavariConfig.load_msal_cache"),
            patch("navari_cli.cli.NavariConfig.save_msal_cache"),
            patch(
                "navari_cli.cli._make_client",
                return_value=AsyncMock(
                    __aenter__=AsyncMock(return_value=AsyncMock(get_user_profile=AsyncMock(return_value=mock_profile))),
                    __aexit__=AsyncMock(return_value=False),
                ),
            ),
        ):
            result = runner.invoke(app, ["login", "https://s.com"])
        assert result.exit_code == 0
        assert "Browser login failed" in result.output
        assert "Logged in as" in result.output


# ============================================================================
# Mocked command tests — chat
# ============================================================================


class TestChatMocked:
    """Test chat command with mocked client (non-interactive -m mode)."""

    def test_chat_new_with_message(self):
        patches = _chat_patches()
        with patches[0], patches[1], patches[2], patch("navari_cli.cli.ChatSession") as mock_session_cls:
            instance = AsyncMock()
            instance._send_query = AsyncMock()
            mock_session_cls.return_value = instance
            result = runner.invoke(app, ["chat", "--new", "-m", "hello"])
        assert result.exit_code == 0
        assert "Created conversation: hello (c1)" in result.output

    def test_chat_with_conversation_id(self):
        patches = _chat_patches()
        with patches[0], patches[1], patches[2], patch("navari_cli.cli.ChatSession") as mock_session_cls:
            instance = AsyncMock()
            instance._send_query = AsyncMock()
            mock_session_cls.return_value = instance
            result = runner.invoke(app, ["chat", "existing-conv-id", "-m", "hello"])
        assert result.exit_code == 0
        assert "Joining: Existing (existing-conv-id)" in result.output

    def test_chat_resume_existing(self):
        client = AsyncMock()
        client.list_conversations = AsyncMock(return_value=[{"id": "c1", "title": "Last Conv"}])
        patches = _chat_patches(client)
        with patches[0], patches[1], patches[2], patch("navari_cli.cli.ChatSession") as mock_session_cls:
            instance = AsyncMock()
            instance._send_query = AsyncMock()
            mock_session_cls.return_value = instance
            result = runner.invoke(app, ["chat", "-m", "hello"])
        assert result.exit_code == 0
        assert "Resuming: Last Conv (c1)" in result.output

    def test_chat_no_conversations_creates_new(self):
        """When no conversations exist and no --new, auto-creates."""
        empty_client = AsyncMock()
        empty_client.list_conversations = AsyncMock(return_value=[])
        empty_client.create_conversation = AsyncMock(
            side_effect=lambda title="New Conversation", **kw: {"id": "new-id", "title": title}
        )
        empty_client.get_conversation = AsyncMock(return_value={"id": "new-id", "title": "hello"})
        mock_cm = AsyncMock(__aenter__=AsyncMock(return_value=empty_client), __aexit__=AsyncMock(return_value=False))

        with (
            patch("navari_cli.cli._resolve_server", return_value="https://mock.server"),
            patch("navari_cli.cli._resolve_token", new_callable=AsyncMock, return_value=("tok", "user")),
            patch("navari_cli.cli._make_client", return_value=mock_cm),
            patch("navari_cli.cli.ChatSession") as mock_session_cls,
        ):
            instance = AsyncMock()
            instance._send_query = AsyncMock()
            mock_session_cls.return_value = instance
            result = runner.invoke(app, ["chat", "-m", "hello"])
        assert result.exit_code == 0
        assert "Created conversation: hello (new-id)" in result.output

    def test_chat_list_fails_creates_new(self):
        """When listing conversations fails, auto-creates."""
        err_client = AsyncMock()
        err_client.list_conversations = AsyncMock(side_effect=Exception("db down"))
        err_client.create_conversation = AsyncMock(
            side_effect=lambda title="New Conversation", **kw: {"id": "new-id", "title": title}
        )
        err_client.get_conversation = AsyncMock(return_value={"id": "new-id", "title": "hello"})
        mock_cm = AsyncMock(__aenter__=AsyncMock(return_value=err_client), __aexit__=AsyncMock(return_value=False))

        with (
            patch("navari_cli.cli._resolve_server", return_value="https://mock.server"),
            patch("navari_cli.cli._resolve_token", new_callable=AsyncMock, return_value=("tok", "user")),
            patch("navari_cli.cli._make_client", return_value=mock_cm),
            patch("navari_cli.cli.ChatSession") as mock_session_cls,
        ):
            instance = AsyncMock()
            instance._send_query = AsyncMock()
            mock_session_cls.return_value = instance
            result = runner.invoke(app, ["chat", "-m", "hello"])
        assert result.exit_code == 0
        assert "Created conversation: hello (new-id)" in result.output

    def test_chat_with_file_attachment(self, tmp_path):
        """Chat with -f flag uploads file."""
        test_file = tmp_path / "data.csv"
        test_file.write_text("a,b,c")

        upload_client = AsyncMock()
        upload_client.create_conversation = AsyncMock(return_value={"id": "c1", "title": "Chat"})
        upload_client.upload_file = AsyncMock(return_value={"id": "f1", "file_id": "f1"})
        upload_client.get_conversation = AsyncMock(return_value={"id": "c1", "title": "Chat"})
        mock_cm = AsyncMock(__aenter__=AsyncMock(return_value=upload_client), __aexit__=AsyncMock(return_value=False))

        with (
            patch("navari_cli.cli._resolve_server", return_value="https://mock.server"),
            patch("navari_cli.cli._resolve_token", new_callable=AsyncMock, return_value=("tok", "user")),
            patch("navari_cli.cli._make_client", return_value=mock_cm),
            patch("navari_cli.cli.ChatSession") as mock_session_cls,
        ):
            instance = AsyncMock()
            instance._send_query = AsyncMock()
            mock_session_cls.return_value = instance
            result = runner.invoke(app, ["chat", "--new", "-f", str(test_file), "-m", "analyze"])
        assert result.exit_code == 0
        assert "Attached" in result.output

    def test_chat_with_nonexistent_file(self):
        """Chat with -f flag for nonexistent file fails."""
        create_client = AsyncMock()
        create_client.create_conversation = AsyncMock(return_value={"id": "c1", "title": "Chat"})
        mock_cm = AsyncMock(__aenter__=AsyncMock(return_value=create_client), __aexit__=AsyncMock(return_value=False))

        with (
            patch("navari_cli.cli._resolve_server", return_value="https://mock.server"),
            patch("navari_cli.cli._resolve_token", new_callable=AsyncMock, return_value=("tok", "user")),
            patch("navari_cli.cli._make_client", return_value=mock_cm),
            patch("navari_cli.cli.ChatSession") as mock_session_cls,
        ):
            instance = AsyncMock()
            mock_session_cls.return_value = instance
            result = runner.invoke(app, ["chat", "--new", "-f", "/nonexistent.txt", "-m", "analyze"])
        assert result.exit_code == 1
        assert "File not found" in result.output


# ============================================================================
# Error handling
# ============================================================================


class TestCommandErrorHandling:
    """Test error paths in various commands."""

    def test_conv_get_not_found(self):
        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.get_conversation = AsyncMock(side_effect=FileNotFoundError("not found"))
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["conversations", "get", "bad-id"])
        assert result.exit_code == 1
        assert "not found" in result.output

    def test_files_delete_not_found(self):
        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.delete_file = AsyncMock(side_effect=FileNotFoundError("no file"))
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["files", "delete", "bad-id", "--yes"])
        assert result.exit_code == 1
        assert "no file" in result.output

    def test_runs_cancel_not_found(self):
        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.cancel_run = AsyncMock(side_effect=FileNotFoundError("no run"))
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["runs", "cancel", "bad-id"])
        assert result.exit_code == 1
        assert "no run" in result.output

    def test_conv_delete_not_found(self):
        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.delete_conversation = AsyncMock(side_effect=FileNotFoundError("gone"))
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["conversations", "delete", "bad-id", "--yes"])
        assert result.exit_code == 1

    def test_file_upload_nonexistent(self):
        with patch(MOCK, return_value=_mock_client()):
            result = runner.invoke(app, ["files", "upload", "/nonexistent/file.txt", "--conversation", "c1"])
        assert result.exit_code == 1

    def test_runs_status_not_found(self):
        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.get_run_status = AsyncMock(side_effect=FileNotFoundError("no run"))
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["runs", "status", "bad-id"])
        assert result.exit_code == 1
        assert "no run" in result.output

    def test_file_download_not_found(self):
        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.get_file_metadata = AsyncMock(side_effect=FileNotFoundError("no file"))
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["files", "download", "bad-id"])
        assert result.exit_code == 1

    def test_conv_messages_error(self):
        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.get_messages = AsyncMock(side_effect=Exception("db error"))
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["conversations", "messages", "bad-id"])
        assert result.exit_code == 1

    def test_files_list_error(self):
        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.list_files = AsyncMock(side_effect=Exception("connection refused"))
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["files", "list"])
        assert result.exit_code == 1

    def test_runs_list_error(self):
        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.list_runs = AsyncMock(side_effect=Exception("timeout"))
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["runs", "list"])
        assert result.exit_code == 1

    def test_workspace_not_found(self):
        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.download_workspace = AsyncMock(side_effect=FileNotFoundError("no workspace"))
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["files", "workspace", "bad-id"])
        assert result.exit_code == 1

    def _make_http_error(self, status_code, body_dict=None, body_text=None):
        """Create an httpx.HTTPStatusError with a mock response containing content."""
        mock_response = type("Response", (), {"content": b""})()
        if body_dict is not None:
            mock_response.content = json.dumps(body_dict).encode()
        elif body_text is not None:
            mock_response.content = body_text.encode()
        return httpx.HTTPStatusError(
            f"Failed to list conversations: {status_code}",
            request=mock_response,
            response=mock_response,
        )

    def test_http_error_shows_error_field(self):
        """HTTPStatusError with 'error' field in response body shows detail."""

        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.list_conversations = AsyncMock(
                side_effect=self._make_http_error(403, body_dict={"error": "Terms not accepted", "code": 403})
            )
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["conversations", "list"])
        assert result.exit_code == 1
        assert "403" in result.output
        assert "Terms not accepted" in result.output

    def test_http_error_shows_detail_field(self):
        """HTTPStatusError with 'detail' field in response body shows detail."""

        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.list_conversations = AsyncMock(
                side_effect=self._make_http_error(401, body_dict={"detail": "Token expired"})
            )
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["conversations", "list"])
        assert result.exit_code == 1
        assert "401" in result.output
        assert "Token expired" in result.output

    def test_http_error_prefers_error_over_detail(self):
        """When both 'error' and 'detail' are present, 'error' wins."""

        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.list_conversations = AsyncMock(
                side_effect=self._make_http_error(
                    403, body_dict={"error": "Terms not accepted", "detail": "Some other info"}
                )
            )
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["conversations", "list"])
        assert result.exit_code == 1
        assert "Terms not accepted" in result.output
        assert "Some other info" not in result.output

    def test_http_error_skips_non_string_detail(self):
        """Non-string 'detail' (e.g. dict) is ignored gracefully."""

        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.list_conversations = AsyncMock(
                side_effect=self._make_http_error(422, body_dict={"detail": [{"msg": "validation error"}]})
            )
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["conversations", "list"])
        assert result.exit_code == 1
        assert "422" in result.output
        assert "validation error" not in result.output

    def test_http_error_no_json_body(self):
        """HTTPStatusError with non-JSON body still shows the status code."""

        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.list_conversations = AsyncMock(
                side_effect=self._make_http_error(500, body_text="Internal Server Error")
            )
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["conversations", "list"])
        assert result.exit_code == 1
        assert "500" in result.output

    def test_generic_exception(self):
        @contextlib.asynccontextmanager
        async def _err_client():
            client = AsyncMock()
            client.list_conversations = AsyncMock(side_effect=RuntimeError("boom"))
            yield client, "https://mock.server", "test@test.com"

        with patch(MOCK, return_value=_err_client()):
            result = runner.invoke(app, ["conversations", "list"])
        assert result.exit_code == 1
        assert "boom" in result.output
