"""Tests for the session-hub sync CLI command."""

from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch

from typer.testing import CliRunner

from session_hub_cli.cli import app
from session_hub_core import ConnectionError, DatabaseConfig, SyncResult
from session_store import SessionInfo

runner = CliRunner()


def make_info(tool: str, session_id: str = "sess-abc", title: str = "Test Session") -> SessionInfo:
    return SessionInfo(
        tool=tool,
        session_id=session_id,
        title=title,
        message_count=29,
        last_activity=datetime(2026, 8, 1, 10, 0, 0, tzinfo=UTC),
        working_directory="/tmp/test",
    )


def make_engine(results: list[SyncResult] | None = None) -> MagicMock:
    engine = MagicMock()
    engine.host = "test-host"
    engine.sync_tool = AsyncMock(
        side_effect=results if results is not None else [SyncResult(host="test-host")]
    )
    engine.close = AsyncMock()
    return engine


def make_store(infos: list[SessionInfo]) -> MagicMock:
    store = MagicMock()
    store.list_sessions.return_value = infos
    return store


class TestSyncDryRun:
    @patch("session_hub_cli.commands.sync.SyncEngine")
    @patch("session_hub_cli.commands.sync.SessionStore")
    @patch(
        "session_hub_cli.commands.sync.get_engine_config",
        return_value=DatabaseConfig(dsn="postgresql+asyncpg://test:test@localhost/test"),
    )
    @patch("session_hub_cli.commands.sync.resolve_hostname", return_value="test-host")
    def test_dry_run_shows_counts_without_writing(
        self,
        mock_hostname,
        mock_config,
        mock_store_cls,
        mock_engine_cls,
    ):
        store = make_store(
            [make_info("devin", session_id="abc-123"), make_info("opencode", session_id="def-456")]
        )
        mock_store_cls.return_value = store

        engine = make_engine(
            [
                SyncResult(host="test-host", sessions_upserted=3, messages_upserted=87),
                SyncResult(host="test-host", sessions_upserted=1, messages_upserted=12),
            ]
        )
        mock_engine_cls.return_value = engine

        result = runner.invoke(app, ["sync", "--dry-run"])

        assert result.exit_code == 0
        assert "Would sync 3 sessions (87 messages) from devin" in result.stdout
        assert "Would sync 1 session (12 messages) from opencode" in result.stdout
        assert "Total: 4 sessions, 99 messages" in result.stdout
        assert [call.args[0] for call in engine.sync_tool.await_args_list] == ["devin", "opencode"]
        for call in engine.sync_tool.await_args_list:
            assert call.kwargs == {"store": store, "dry_run": True}
        engine.close.assert_awaited_once()

    @patch("session_hub_cli.commands.sync.SyncEngine")
    @patch("session_hub_cli.commands.sync.SessionStore")
    @patch(
        "session_hub_cli.commands.sync.get_engine_config",
        return_value=DatabaseConfig(dsn="postgresql+asyncpg://test:test@localhost/test"),
    )
    @patch("session_hub_cli.commands.sync.resolve_hostname", return_value="test-host")
    def test_tool_filters_to_one_tool(
        self,
        mock_hostname,
        mock_config,
        mock_store_cls,
        mock_engine_cls,
    ):
        store = make_store([make_info("devin"), make_info("opencode")])
        mock_store_cls.return_value = store

        engine = make_engine()
        mock_engine_cls.return_value = engine

        result = runner.invoke(app, ["sync", "--tool", "devin", "--dry-run"])

        assert result.exit_code == 0
        engine.sync_tool.assert_awaited_once()
        assert engine.sync_tool.await_args.args[0] == "devin"
        assert "Would sync 0 sessions from devin" in result.stdout

    @patch("session_hub_cli.commands.sync.SyncEngine")
    @patch("session_hub_cli.commands.sync.SessionStore")
    @patch(
        "session_hub_cli.commands.sync.get_engine_config",
        return_value=DatabaseConfig(dsn="postgresql+asyncpg://test:test@localhost/test"),
    )
    def test_host_override(
        self,
        mock_config,
        mock_store_cls,
        mock_engine_cls,
    ):
        store = make_store([])
        mock_store_cls.return_value = store

        engine = make_engine()
        engine.host = "custom-host"
        mock_engine_cls.return_value = engine

        result = runner.invoke(app, ["sync", "--host", "custom-host"])

        assert result.exit_code == 0
        mock_engine_cls.assert_called_once()
        assert mock_engine_cls.call_args.args[1] == "custom-host"
        assert "from custom-host" in result.stdout


class TestSyncErrors:
    @patch("session_hub_cli.commands.sync.SyncEngine")
    @patch("session_hub_cli.commands.sync.SessionStore")
    @patch("session_hub_cli.commands.sync.resolve_hostname", return_value="test-host")
    def test_missing_session_hub_dsn(
        self, mock_hostname, mock_store_cls, mock_engine_cls, monkeypatch
    ):
        monkeypatch.delenv("SESSION_HUB_DSN", raising=False)
        result = runner.invoke(app, ["sync"])
        assert result.exit_code != 0
        assert "SESSION_HUB_DSN environment variable is required" in result.stdout
        mock_engine_cls.assert_not_called()

    @patch("session_hub_cli.commands.sync.SyncEngine")
    @patch("session_hub_cli.commands.sync.SessionStore")
    @patch(
        "session_hub_cli.commands.sync.get_engine_config",
        return_value=DatabaseConfig(dsn="postgresql+asyncpg://test:test@localhost/test"),
    )
    @patch("session_hub_cli.commands.sync.resolve_hostname", return_value="test-host")
    def test_partial_failure_exit_code_2(
        self,
        mock_hostname,
        mock_config,
        mock_store_cls,
        mock_engine_cls,
    ):
        store = make_store([make_info("devin"), make_info("opencode")])
        mock_store_cls.return_value = store

        engine = make_engine(
            [
                SyncResult(
                    host="test-host",
                    sessions_upserted=2,
                    messages_upserted=10,
                    errors=["devin/abc: boom"],
                ),
                SyncResult(host="test-host", sessions_upserted=1, messages_upserted=5),
            ]
        )
        mock_engine_cls.return_value = engine

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

        assert result.exit_code == 2
        assert "devin/abc: boom" in result.stdout

    @patch("session_hub_cli.commands.sync.SyncEngine")
    @patch("session_hub_cli.commands.sync.SessionStore")
    @patch(
        "session_hub_cli.commands.sync.get_engine_config",
        return_value=DatabaseConfig(dsn="postgresql+asyncpg://test:test@localhost/test"),
    )
    @patch("session_hub_cli.commands.sync.resolve_hostname", return_value="test-host")
    def test_all_tools_failed_exit_code_3(
        self,
        mock_hostname,
        mock_config,
        mock_store_cls,
        mock_engine_cls,
    ):
        store = make_store([make_info("devin"), make_info("opencode")])
        mock_store_cls.return_value = store

        engine = make_engine(
            [
                SyncResult(host="test-host", errors=["devin: boom"]),
                SyncResult(host="test-host", errors=["opencode: boom"]),
            ]
        )
        mock_engine_cls.return_value = engine

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

        assert result.exit_code == 3

    @patch("session_hub_cli.commands.sync.SyncEngine")
    @patch("session_hub_cli.commands.sync.SessionStore")
    @patch(
        "session_hub_cli.commands.sync.get_engine_config",
        return_value=DatabaseConfig(dsn="postgresql+asyncpg://test:test@localhost/test"),
    )
    @patch("session_hub_cli.commands.sync.resolve_hostname", return_value="test-host")
    def test_connection_error_exit_code_1(
        self,
        mock_hostname,
        mock_config,
        mock_store_cls,
        mock_engine_cls,
    ):
        store = make_store([make_info("devin")])
        mock_store_cls.return_value = store

        engine = MagicMock()
        engine.host = "test-host"
        engine.sync_tool = AsyncMock(side_effect=ConnectionError("connection refused"))
        engine.close = AsyncMock()
        mock_engine_cls.return_value = engine

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

        assert result.exit_code == 1
        assert "Cannot connect to PostgreSQL" in result.stdout

    @patch("session_hub_cli.commands.sync.SyncEngine")
    @patch("session_hub_cli.commands.sync.SessionStore")
    @patch(
        "session_hub_cli.commands.sync.get_engine_config",
        return_value=DatabaseConfig(dsn="postgresql+asyncpg://test:test@localhost/test"),
    )
    @patch("session_hub_cli.commands.sync.resolve_hostname", return_value="test-host")
    def test_interrupt_exit_code_130(
        self,
        mock_hostname,
        mock_config,
        mock_store_cls,
        mock_engine_cls,
    ):
        store = make_store([make_info("devin")])
        mock_store_cls.return_value = store

        engine = MagicMock()
        engine.host = "test-host"
        engine.sync_tool = AsyncMock(side_effect=KeyboardInterrupt)
        engine.close = AsyncMock()
        mock_engine_cls.return_value = engine

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

        assert result.exit_code == 130
