"""Integration tests: SessionStore + real adapters through the CLI.

These tests wire real adapter instances (with temp DBs/files) through
SessionStore and the send-message CLI, verifying the full stack works
end-to-end without mocks for the data layer.
"""

from __future__ import annotations

import json
import os
import sqlite3
from datetime import datetime
from pathlib import Path
from unittest.mock import patch

import pytest
from typer.testing import CliRunner

from copilot_session import CopilotConfig, CopilotSource
from devin_session import DevinConfig, DevinSource
from message_delivery import GitError
from opencode_session import OpenCodeConfig, OpenCodeSource
from send_message.cli import app
from session_store import SessionStore, SourceRegistry

# ─── Fixtures ─────────────────────────────────────────────────────────────


@pytest.fixture
def devin_db(tmp_path: Path) -> Path:
    """Create a temp Devin SQLite DB with test data."""
    db_path = tmp_path / "devin.db"
    conn = sqlite3.connect(str(db_path))
    conn.executescript("""
        CREATE TABLE sessions (
            id TEXT PRIMARY KEY, title TEXT, working_directory TEXT,
            last_activity_at TEXT, created_at TEXT,
            model TEXT, backend_type TEXT, agent_mode TEXT, metadata TEXT
        );
        CREATE TABLE message_nodes (
            row_id INTEGER, session_id TEXT, parent_node_id INTEGER,
            chat_message TEXT, created_at INTEGER
        );
    """)
    conn.execute(
        "INSERT INTO sessions (id, title, working_directory, last_activity_at, "
        "created_at, model, backend_type, agent_mode, metadata) "
        "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (
            "devin-sess-1",
            "Devin Test Session",
            str(tmp_path),
            "2026-07-14T10:00:00Z",
            "2026-07-14T08:00:00Z",
            "glm-5-2",
            "cloud",
            "interactive",
            None,
        ),
    )
    for i in range(1, 7):
        msg = json.dumps(
            {
                "role": "assistant" if i % 2 == 0 else "user",
                "content": f"Devin message {i} " + "x" * 60,
                "message_id": f"devin-msg-{i}",
                "created_at": f"2026-07-14T08:0{i}:00Z",
            }
        )
        conn.execute(
            "INSERT INTO message_nodes (row_id, session_id, parent_node_id, "
            "chat_message, created_at) "
            "VALUES (?, ?, ?, ?, ?)",
            (i, "devin-sess-1", None, msg, 1720936800 + i * 60),
        )
    conn.commit()
    conn.close()
    return db_path


@pytest.fixture
def opencode_db(tmp_path: Path) -> Path:
    """Create a temp OpenCode SQLite DB with test data."""
    db_path = tmp_path / "opencode.db"
    conn = sqlite3.connect(str(db_path))
    conn.executescript("""
        CREATE TABLE session (
            id TEXT PRIMARY KEY, title TEXT, directory TEXT,
            time_created TEXT, time_updated TEXT,
            model TEXT, slug TEXT, cost REAL,
            tokens_input INTEGER, tokens_output INTEGER,
            tokens_reasoning INTEGER, metadata TEXT
        );
        CREATE TABLE message (
            id TEXT PRIMARY KEY, session_id TEXT, data TEXT, time_created TEXT
        );
        CREATE TABLE part (
            id TEXT PRIMARY KEY, message_id TEXT, data TEXT, time_created TEXT
        );
    """)
    conn.execute(
        "INSERT INTO session (id, title, directory, time_created, time_updated, "
        "model, slug, cost, tokens_input, tokens_output, tokens_reasoning, metadata) "
        "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (
            "oc-sess-1",
            "OpenCode Test Session",
            str(tmp_path),
            "2026-07-14T08:00:00Z",
            "2026-07-14T11:00:00Z",
            json.dumps({"id": "deepseek-v4-flash"}),
            "test-slug",
            None,
            None,
            None,
            None,
            None,
        ),
    )
    # Message with one text part
    conn.execute(
        "INSERT INTO message (id, session_id, data, time_created) VALUES (?, ?, ?, ?)",
        (
            "oc-msg-1",
            "oc-sess-1",
            json.dumps({"role": "user", "parentID": None, "parts": ["oc-part-1"]}),
            "2026-07-14T08:01:00Z",
        ),
    )
    conn.execute(
        "INSERT INTO part (id, message_id, data, time_created) VALUES (?, ?, ?, ?)",
        (
            "oc-part-1",
            "oc-msg-1",
            json.dumps({"type": "text", "text": "OpenCode user message " + "y" * 60}),
            "2026-07-14T08:01:00Z",
        ),
    )
    # Assistant message
    conn.execute(
        "INSERT INTO message (id, session_id, data, time_created) VALUES (?, ?, ?, ?)",
        (
            "oc-msg-2",
            "oc-sess-1",
            json.dumps({"role": "assistant", "parentID": "oc-msg-1", "parts": ["oc-part-2"]}),
            "2026-07-14T08:02:00Z",
        ),
    )
    conn.execute(
        "INSERT INTO part (id, message_id, data, time_created) VALUES (?, ?, ?, ?)",
        (
            "oc-part-2",
            "oc-msg-2",
            json.dumps({"type": "text", "text": "OpenCode assistant response " + "z" * 60}),
            "2026-07-14T08:02:00Z",
        ),
    )
    conn.commit()
    conn.close()
    return db_path


@pytest.fixture
def copilot_dir(tmp_path: Path) -> Path:
    """Create a temp Copilot session-state directory with test data."""
    session_dir = tmp_path / "copilot-sessions"
    session_dir.mkdir()
    jsonl_path = session_dir / "copilot-sess-1.jsonl"
    lines = [
        json.dumps(
            {
                "role": "user",
                "content": "Copilot user question " + "a" * 60,
                "message_id": "cp-msg-1",
                "created_at": "2026-07-14T09:00:00Z",
            }
        ),
        json.dumps(
            {
                "role": "assistant",
                "content": "Copilot assistant answer " + "b" * 60,
                "message_id": "cp-msg-2",
                "created_at": "2026-07-14T09:01:00Z",
            }
        ),
    ]
    jsonl_path.write_text("\n".join(lines) + "\n")
    # Set mtime to 2026-07-14 09:01:00 to control detect_tool ordering
    import time

    old_time = time.mktime(datetime(2026, 7, 14, 9, 1, 0).timetuple())
    os.utime(jsonl_path, (old_time, old_time))
    return session_dir


@pytest.fixture
def store(devin_db: Path, opencode_db: Path, copilot_dir: Path) -> SessionStore:
    """Create a SessionStore with real adapters pointing at temp data."""
    registry = SourceRegistry()
    registry.register(DevinSource(DevinConfig(db_path=devin_db)))
    registry.register(OpenCodeSource(OpenCodeConfig(db_path=opencode_db)))
    registry.register(CopilotSource(CopilotConfig(session_dir=copilot_dir)))
    return SessionStore(registry=registry)


@pytest.fixture
def patched_store(store: SessionStore):
    """Patch get_store() in all command modules to return our test store."""
    with (
        patch("send_message.commands._helpers.get_store", return_value=store),
        patch("send_message.commands.list_sessions.get_store", return_value=store),
    ):
        yield store


# ─── Integration: SessionStore + adapters ─────────────────────────────────


class TestSessionStoreIntegration:
    def test_list_sessions_all_tools(self, store: SessionStore):
        sessions = store.list_sessions()
        tools = {s.tool for s in sessions}
        assert "devin" in tools
        assert "opencode" in tools
        assert "copilot" in tools

    def test_list_sessions_per_tool(self, store: SessionStore):
        devin_sessions = store.list_sessions(tool="devin")
        assert len(devin_sessions) == 1
        assert devin_sessions[0].session_id == "devin-sess-1"
        assert devin_sessions[0].message_count == 6

    def test_get_session_devin(self, store: SessionStore):
        session = store.get_session("devin-sess-1", tool="devin")
        assert session.title == "Devin Test Session"
        assert len(session.messages) == 6

    def test_get_session_opencode(self, store: SessionStore):
        session = store.get_session("oc-sess-1", tool="opencode")
        assert session.title == "OpenCode Test Session"
        assert len(session.messages) == 2
        # Check text parts are concatenated
        assistant_msgs = [m for m in session.messages if m.role == "assistant"]
        assert len(assistant_msgs) == 1
        assert "OpenCode assistant response" in assistant_msgs[0].content

    def test_get_session_copilot(self, store: SessionStore):
        session = store.get_session("copilot-sess-1", tool="copilot")
        assert len(session.messages) == 2
        assert session.messages[0].role == "user"
        assert session.messages[1].role == "assistant"

    def test_get_session_searches_all_sources(self, store: SessionStore):
        session = store.get_session("devin-sess-1")
        assert session.tool == "devin"

    def test_detect_tool(self, store: SessionStore):
        # opencode has the most recent activity (11:00 vs 10:00 vs 09:01)
        tool = store.detect_tool()
        assert tool == "opencode"

    def test_select_message_latest_devin(self, store: SessionStore):
        msg = store.select_message("latest", session_id="devin-sess-1", tool="devin")
        assert msg.role == "assistant"
        assert "Devin message" in msg.content

    def test_select_message_search(self, store: SessionStore):
        msg = store.select_message("search:assistant", session_id="copilot-sess-1", tool="copilot")
        assert "Copilot assistant" in msg.content


# ─── Integration: CLI with real adapters ──────────────────────────────────


class TestCLIIntegration:
    def test_list_sessions_shows_table(self, patched_store: SessionStore):
        runner = CliRunner()
        result = runner.invoke(app, ["list-sessions"])
        assert result.exit_code == 0
        assert "devin-sess-1" in result.stdout
        assert "oc-sess-1" in result.stdout

    def test_list_sessions_specific_tool(self, patched_store: SessionStore):
        runner = CliRunner()
        result = runner.invoke(app, ["--tool", "devin", "list-sessions"])
        assert result.exit_code == 0
        assert "devin-sess-1" in result.stdout
        assert "Devin Test" in result.stdout

    def test_list_messages_shows_table(self, patched_store: SessionStore):
        runner = CliRunner()
        result = runner.invoke(
            app, ["--tool", "devin", "--session-id", "devin-sess-1", "list-messages", "--all"]
        )
        assert result.exit_code == 0

    def test_preview_shows_message(self, patched_store: SessionStore):
        runner = CliRunner()
        result = runner.invoke(
            app, ["--tool", "devin", "--session-id", "devin-sess-1", "preview", "latest"]
        )
        assert result.exit_code == 0

    def test_preview_head_til_1(self, patched_store: SessionStore):
        runner = CliRunner()
        result = runner.invoke(
            app, ["--tool", "devin", "--session-id", "devin-sess-1", "preview", "HEAD~1"]
        )
        assert result.exit_code == 0

    def test_preview_search(self, patched_store: SessionStore):
        runner = CliRunner()
        result = runner.invoke(
            app,
            ["--tool", "copilot", "--session-id", "copilot-sess-1", "preview", "search:assistant"],
        )
        assert result.exit_code == 0

    def test_send_dry_run_produces_file(self, patched_store: SessionStore, tmp_path: Path):
        """send latest --dry-run should produce a file in a temp location."""
        runner = CliRunner()
        result = runner.invoke(
            app,
            ["--tool", "devin", "--session-id", "devin-sess-1", "send", "latest", "--dry-run"],
        )
        assert result.exit_code == 0
        assert "Saved" in result.stdout
        assert "Dry run" in result.stdout

    def test_session_not_found_shows_available(self, patched_store: SessionStore):
        runner = CliRunner()
        result = runner.invoke(
            app, ["--tool", "devin", "--session-id", "nonexistent", "preview", "latest"]
        )
        assert result.exit_code == 1
        assert "Session not found" in result.stdout

    def test_message_not_found_shows_available(self, patched_store: SessionStore):
        runner = CliRunner()
        result = runner.invoke(
            app,
            ["--tool", "devin", "--session-id", "devin-sess-1", "preview", "search:nonexistent"],
        )
        assert result.exit_code == 1
        assert "Message not found" in result.stdout


# ─── E2E: error scenarios ─────────────────────────────────────────────────


class TestE2EErrors:
    def test_missing_send_emails_error(self, patched_store: SessionStore, tmp_path: Path):
        """When send-emails is not on PATH, should give clear error."""
        runner = CliRunner()
        with patch("message_delivery.email.shutil.which", return_value=None):
            result = runner.invoke(
                app,
                ["--tool", "devin", "--session-id", "devin-sess-1", "send", "latest"],
            )
        # The pipeline should fail with an email error
        # (file is saved, commit may happen, but email fails)
        assert result.exit_code != 0 or "Error" in result.output

    def test_no_email_skips_email(self, patched_store: SessionStore, tmp_path: Path):
        """--no-email should skip email sending entirely."""
        runner = CliRunner()
        with patch("message_delivery.email.EmailSender.send") as mock_send:
            result = runner.invoke(
                app,
                [
                    "--tool",
                    "devin",
                    "--session-id",
                    "devin-sess-1",
                    "send",
                    "latest",
                    "--no-email",
                    "--no-commit",
                    "--no-push",
                ],
            )
        assert result.exit_code == 0
        assert "Saved" in result.stdout
        mock_send.assert_not_called()

    def test_no_commit_skips_commit(self, patched_store: SessionStore, tmp_path: Path):
        """--no-commit should skip git commit."""
        runner = CliRunner()
        with patch("message_delivery.git.GitOperations.commit") as mock_commit:
            result = runner.invoke(
                app,
                [
                    "--tool",
                    "devin",
                    "--session-id",
                    "devin-sess-1",
                    "send",
                    "latest",
                    "--no-commit",
                    "--no-email",
                    "--no-push",
                ],
            )
        assert result.exit_code == 0
        mock_commit.assert_not_called()

    def test_no_push_skips_push(self, patched_store: SessionStore, tmp_path: Path):
        """--no-push should skip git push."""
        runner = CliRunner()
        with patch("message_delivery.git.GitOperations.push") as mock_push:
            result = runner.invoke(
                app,
                [
                    "--tool",
                    "devin",
                    "--session-id",
                    "devin-sess-1",
                    "send",
                    "latest",
                    "--no-push",
                    "--no-email",
                    "--no-commit",
                ],
            )
        assert result.exit_code == 0
        mock_push.assert_not_called()

    def test_git_error_handled_gracefully(self, patched_store: SessionStore, tmp_path: Path):
        """GitError from the pipeline should print a friendly message and exit 1."""
        runner = CliRunner()
        with patch(
            "message_delivery.DeliveryPipeline.deliver",
            side_effect=GitError("git not found on PATH"),
        ):
            result = runner.invoke(
                app,
                ["--tool", "devin", "--session-id", "devin-sess-1", "send", "latest"],
            )
        assert result.exit_code == 1
        assert "git not found" in result.output

    def test_dry_run_skips_all_side_effects(self, patched_store: SessionStore, tmp_path: Path):
        """--dry-run should skip commit, email, and push."""
        runner = CliRunner()
        with (
            patch("message_delivery.git.GitOperations.commit") as mock_commit,
            patch("message_delivery.git.GitOperations.push") as mock_push,
            patch("message_delivery.email.EmailSender.send") as mock_send,
        ):
            result = runner.invoke(
                app,
                ["--tool", "devin", "--session-id", "devin-sess-1", "send", "latest", "--dry-run"],
            )
        assert result.exit_code == 0
        assert "Dry run" in result.stdout
        mock_commit.assert_not_called()
        mock_push.assert_not_called()
        mock_send.assert_not_called()


# ─── Env var support ──────────────────────────────────────────────────────


class TestEnvVars:
    def test_send_message_tool_env_var(self, patched_store: SessionStore, monkeypatch):
        """SEND_MESSAGE_TOOL env var should set default tool."""
        monkeypatch.setenv("SEND_MESSAGE_TOOL", "devin")
        runner = CliRunner()
        result = runner.invoke(app, ["--session-id", "devin-sess-1", "preview", "latest"])
        assert result.exit_code == 0

    def test_send_message_session_env_var(self, patched_store: SessionStore, monkeypatch):
        """SEND_MESSAGE_SESSION env var should set default session ID."""
        monkeypatch.setenv("SEND_MESSAGE_SESSION", "devin-sess-1")
        runner = CliRunner()
        result = runner.invoke(app, ["--tool", "devin", "preview", "latest"])
        assert result.exit_code == 0

    def test_cli_option_overrides_env_var(self, patched_store: SessionStore, monkeypatch):
        """CLI --tool should override SEND_MESSAGE_TOOL env var."""
        monkeypatch.setenv("SEND_MESSAGE_TOOL", "opencode")
        runner = CliRunner()
        result = runner.invoke(
            app, ["--tool", "devin", "--session-id", "devin-sess-1", "preview", "latest"]
        )
        assert result.exit_code == 0
