from __future__ import annotations

import asyncio
import hashlib
import time
from pathlib import Path
from unittest.mock import patch

import pytest

from oh_my_linear.official_session import (
    DEFAULT_OFFICIAL_MCP_URL,
    OfficialToolError,
    OfficialMcpSessionManager,
)


@pytest.fixture(autouse=True)
def _clear_official_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
    keys = [
        "LINEAR_OFFICIAL_MCP_TRANSPORT",
        "LINEAR_OFFICIAL_MCP_COMMAND",
        "LINEAR_OFFICIAL_MCP_ARGS",
        "LINEAR_OFFICIAL_MCP_ENV",
        "LINEAR_OFFICIAL_MCP_CWD",
        "LINEAR_OFFICIAL_MCP_URL",
        "LINEAR_OFFICIAL_MCP_HEADERS",
        "LINEAR_OFFICIAL_AUTH_RETRY_COOLDOWN_SECONDS",
        "LINEAR_OFFICIAL_AUTH_RETRY_STATE_FILE",
    ]
    for key in keys:
        monkeypatch.delenv(key, raising=False)
    # Isolate shared auth cooldown state per test to avoid cross-test bleed.
    monkeypatch.setenv(
        "LINEAR_OFFICIAL_AUTH_RETRY_STATE_FILE",
        str(tmp_path / "auth-retry-state.json"),
    )


def test_default_transport_uses_stdio(monkeypatch: pytest.MonkeyPatch):
    manager = OfficialMcpSessionManager()

    health = manager.get_health()
    assert health["transport"] == "stdio"
    assert health["command"] == "npx"
    assert health["args"] == ["-y", "mcp-remote", DEFAULT_OFFICIAL_MCP_URL]


def test_stdio_args_support_json_array(monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setenv(
        "LINEAR_OFFICIAL_MCP_ARGS",
        '["-y", "mcp-remote", "https://example.com/mcp", "--foo", "bar"]',
    )
    manager = OfficialMcpSessionManager()

    health = manager.get_health()
    assert health["args"] == [
        "-y",
        "mcp-remote",
        "https://example.com/mcp",
        "--foo",
        "bar",
    ]


def test_stdio_args_support_shell_style_string(monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setenv(
        "LINEAR_OFFICIAL_MCP_ARGS",
        "-y mcp-remote https://example.com/mcp --name 'My Client'",
    )
    manager = OfficialMcpSessionManager()

    health = manager.get_health()
    assert health["args"] == [
        "-y",
        "mcp-remote",
        "https://example.com/mcp",
        "--name",
        "My Client",
    ]


def test_stdio_args_invalid_shell_string_falls_back_to_default(monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setenv("LINEAR_OFFICIAL_MCP_ARGS", "-y mcp-remote 'unterminated")
    manager = OfficialMcpSessionManager()

    health = manager.get_health()
    assert health["args"] == ["-y", "mcp-remote", DEFAULT_OFFICIAL_MCP_URL]


def test_http_transport_keeps_legacy_header_mode(monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setenv("LINEAR_OFFICIAL_MCP_TRANSPORT", "http")
    monkeypatch.setenv("LINEAR_OFFICIAL_MCP_HEADERS", '{"Authorization": "Bearer X"}')

    manager = OfficialMcpSessionManager()
    health = manager.get_health()

    assert health["transport"] == "http"
    assert health["url"] == DEFAULT_OFFICIAL_MCP_URL
    assert health["hasHeaders"] is True


def test_invalid_transport_raises(monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setenv("LINEAR_OFFICIAL_MCP_TRANSPORT", "invalid")
    with pytest.raises(ValueError):
        OfficialMcpSessionManager()


def test_invalid_cooldown_env_falls_back_to_default(monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setenv("LINEAR_OFFICIAL_AUTH_RETRY_COOLDOWN_SECONDS", "five-minutes")
    manager = OfficialMcpSessionManager()
    health = manager.get_health()
    assert health["authRetryCooldownSeconds"] == 300.0


def test_module_alias_runs_main():
    from oh_my_linearmcp import main as alias_main
    from oh_my_linear import main as base_main

    assert alias_main is base_main


class _FakeText:
    type = "text"

    def __init__(self, text: str):
        self.text = text


class _FakeResult:
    def __init__(self, *, is_error: bool = False, text: str = "", tools: list[object] | None = None):
        self.isError = is_error
        self.content = [_FakeText(text)] if text else []
        self.tools = tools or []


class _FakeTool:
    def __init__(self, name: str):
        self.name = name


def _sync_submit(value):
    if asyncio.iscoroutine(value):
        return asyncio.run(value)
    return value


def test_call_tool_preserves_official_tool_error(monkeypatch: pytest.MonkeyPatch):
    manager = OfficialMcpSessionManager()

    class _FakeSession:
        def call_tool(self, name: str, arguments=None):
            return _FakeResult(is_error=True, text="official said no")

    manager._session = _FakeSession()
    monkeypatch.setattr(manager, "_ensure_connected", lambda: None)
    monkeypatch.setattr(manager, "_submit", _sync_submit)

    with pytest.raises(OfficialToolError) as exc_info:
        manager.call_tool("list_issues", {})

    assert exc_info.value.code == "official_tool_error"
    assert "official said no" in exc_info.value.message


def test_list_tools_raises_when_not_connected(monkeypatch: pytest.MonkeyPatch):
    manager = OfficialMcpSessionManager()

    # No session connected
    assert manager._session is None
    with pytest.raises(OfficialToolError) as exc_info:
        manager.list_tools()
    assert exc_info.value.code == "official_not_connected"


def test_list_tools_returns_names_when_connected(monkeypatch: pytest.MonkeyPatch):
    manager = OfficialMcpSessionManager()

    class _FakeSession:
        def list_tools(self):
            return _FakeResult(tools=[_FakeTool("create_issue"), _FakeTool("list_issues")])

    manager._session = _FakeSession()
    monkeypatch.setattr(manager, "_submit", _sync_submit)

    tools = manager.list_tools()
    assert tools == ["create_issue", "list_issues"]


def test_ensure_connected_uses_auth_timeout(monkeypatch: pytest.MonkeyPatch):
    manager = OfficialMcpSessionManager(auth_timeout_seconds=120.0)
    submitted_timeouts = []

    def fake_submit(coro, timeout=None):
        submitted_timeouts.append(timeout)
        if asyncio.iscoroutine(coro):
            coro.close()
        return None

    monkeypatch.setattr(manager, "_ensure_loop", lambda: None)
    monkeypatch.setattr(manager, "_submit", fake_submit)

    manager._ensure_connected()

    assert len(submitted_timeouts) == 1
    assert submitted_timeouts[0] == 130.0  # auth_timeout + 10


def test_call_tool_sets_auth_cooldown_and_skips_repeated_connect_attempts(monkeypatch: pytest.MonkeyPatch):
    manager = OfficialMcpSessionManager(auth_retry_cooldown_seconds=120.0)
    connect_calls = {"count": 0}

    def fake_ensure_connected():
        connect_calls["count"] += 1
        raise TimeoutError("oauth timed out")

    def fake_submit(coro, timeout=None):
        if asyncio.iscoroutine(coro):
            coro.close()
        return None

    monkeypatch.setattr(manager, "_ensure_connected", fake_ensure_connected)
    monkeypatch.setattr(manager, "_submit", fake_submit)

    with pytest.raises(OfficialToolError) as first_exc:
        manager.call_tool("list_issues", {})
    assert first_exc.value.code == "official_auth_required"

    with pytest.raises(OfficialToolError) as second_exc:
        manager.call_tool("list_issues", {})
    assert second_exc.value.code == "official_auth_cooldown"
    assert connect_calls["count"] == 1
    assert manager.get_health()["authRetryRemainingSeconds"] > 0


def test_shared_auth_cooldown_suppresses_connect_across_instances(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
    shared_path = tmp_path / "shared-auth-retry.json"
    monkeypatch.setenv("LINEAR_OFFICIAL_AUTH_RETRY_STATE_FILE", str(shared_path))

    manager1 = OfficialMcpSessionManager(auth_retry_cooldown_seconds=120.0)
    manager2 = OfficialMcpSessionManager(auth_retry_cooldown_seconds=120.0)

    def fake_submit(coro, timeout=None):
        if asyncio.iscoroutine(coro):
            coro.close()
        return None

    def fake_ensure_connected_manager1():
        raise TimeoutError("oauth timed out")

    monkeypatch.setattr(manager1, "_submit", fake_submit)
    monkeypatch.setattr(manager2, "_submit", fake_submit)
    monkeypatch.setattr(manager1, "_ensure_connected", fake_ensure_connected_manager1)

    connect_calls = {"count": 0}

    def fake_ensure_connected_manager2():
        connect_calls["count"] += 1

    monkeypatch.setattr(manager2, "_ensure_connected", fake_ensure_connected_manager2)

    with pytest.raises(OfficialToolError) as first_exc:
        manager1.call_tool("list_issues", {})
    assert first_exc.value.code == "official_auth_required"
    assert shared_path.exists()

    with pytest.raises(OfficialToolError) as second_exc:
        manager2.call_tool("list_issues", {})
    assert second_exc.value.code == "official_auth_cooldown"
    assert connect_calls["count"] == 0


def test_call_tool_timeout_after_connected_does_not_set_auth_cooldown(monkeypatch: pytest.MonkeyPatch):
    manager = OfficialMcpSessionManager(auth_retry_cooldown_seconds=120.0)

    class _FakeSession:
        async def call_tool(self, name: str, arguments=None):
            raise TimeoutError("tool call timed out")

    manager._session = _FakeSession()
    monkeypatch.setattr(manager, "_submit", _sync_submit)
    monkeypatch.setattr(manager, "_ensure_connected", lambda: None)

    with pytest.raises(OfficialToolError) as exc_info:
        manager.call_tool("list_issues", {})
    assert exc_info.value.code == "official_unavailable"

    health = manager.get_health()
    assert health["authRetryRemainingSeconds"] == 0


def test_success_clears_auth_cooldown_state(monkeypatch: pytest.MonkeyPatch):
    manager = OfficialMcpSessionManager(auth_retry_cooldown_seconds=120.0)
    manager._auth_retry_not_before = time.time() + 60
    manager._last_auth_failure_reason = "TimeoutError: oauth timed out"

    class _FakeSession:
        def call_tool(self, name: str, arguments=None):
            return _FakeResult(text='{"ok": true}')

    manager._session = _FakeSession()
    monkeypatch.setattr(manager, "_ensure_connected", lambda: None)
    monkeypatch.setattr(manager, "_submit", _sync_submit)

    result = manager.call_tool("list_issues", {})
    assert result == {"ok": True}

    health = manager.get_health()
    assert health["authRetryRemainingSeconds"] == 0
    assert health["lastAuthFailureReason"] is None


def test_tool_success_keeps_shared_auth_cooldown_state_when_session_already_connected(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
    shared_path = tmp_path / "shared-auth-retry.json"
    monkeypatch.setenv("LINEAR_OFFICIAL_AUTH_RETRY_STATE_FILE", str(shared_path))
    manager = OfficialMcpSessionManager(auth_retry_cooldown_seconds=120.0)
    manager._auth_retry_not_before = time.time() + 60
    manager._last_auth_failure_reason = "TimeoutError: oauth timed out"
    manager._write_shared_auth_retry_state(manager._auth_retry_not_before, manager._last_auth_failure_reason)
    assert shared_path.exists()

    class _FakeSession:
        def call_tool(self, name: str, arguments=None):
            return _FakeResult(text='{"ok": true}')

    manager._session = _FakeSession()
    monkeypatch.setattr(manager, "_ensure_connected", lambda: None)
    monkeypatch.setattr(manager, "_submit", _sync_submit)

    result = manager.call_tool("list_issues", {})
    assert result == {"ok": True}
    assert shared_path.exists()


def test_get_health_redacts_sensitive_values(monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setenv(
        "LINEAR_OFFICIAL_MCP_ARGS",
        "-y mcp-remote https://mcp.linear.app/mcp --header Authorization:Bearer super-secret-token",
    )
    manager = OfficialMcpSessionManager()
    manager._last_error = "RuntimeError: token=super-secret-token"
    manager._last_auth_failure_reason = "Unauthorized: Bearer abc.def.ghi"

    health = manager.get_health()
    args_text = " ".join(health["args"])

    assert "super-secret-token" not in args_text
    assert "abc.def.ghi" not in (health["lastAuthFailureReason"] or "")
    assert "super-secret-token" not in (health["lastError"] or "")


def test_reconnect_clears_cooldown_and_connects(monkeypatch: pytest.MonkeyPatch):
    manager = OfficialMcpSessionManager(auth_retry_cooldown_seconds=120.0)
    manager._auth_retry_not_before = time.time() + 60
    manager._last_auth_failure_reason = "TimeoutError: oauth timed out"
    manager._session = object()

    def fake_submit(coro, timeout=None):
        if asyncio.iscoroutine(coro):
            coro.close()
        manager._session = None
        return None

    def fake_ensure_connected():
        manager._session = object()

    monkeypatch.setattr(manager, "_submit", fake_submit)
    monkeypatch.setattr(manager, "_ensure_connected", fake_ensure_connected)

    result = manager.reconnect(ignore_cooldown=True)
    assert result["connected"] is True
    assert result["authRetryRemainingSeconds"] == 0


def test_reconnect_respects_cooldown_when_ignore_false(monkeypatch: pytest.MonkeyPatch):
    manager = OfficialMcpSessionManager(auth_retry_cooldown_seconds=120.0)
    manager._auth_retry_not_before = time.time() + 30
    manager._last_auth_failure_reason = "TimeoutError: oauth timed out"

    connect_called = {"count": 0}

    def fake_ensure_connected():
        connect_called["count"] += 1

    monkeypatch.setattr(manager, "_ensure_connected", fake_ensure_connected)

    with pytest.raises(OfficialToolError) as exc_info:
        manager.reconnect(ignore_cooldown=False)

    assert exc_info.value.code == "official_auth_cooldown"
    assert connect_called["count"] == 0


def test_reconnect_respects_shared_cooldown_when_ignore_false(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
    shared_path = tmp_path / "shared-auth-retry.json"
    monkeypatch.setenv("LINEAR_OFFICIAL_AUTH_RETRY_STATE_FILE", str(shared_path))
    manager = OfficialMcpSessionManager(auth_retry_cooldown_seconds=120.0)
    manager._write_shared_auth_retry_state(time.time() + 30, "TimeoutError: oauth timed out")

    connect_called = {"count": 0}

    def fake_ensure_connected():
        connect_called["count"] += 1

    monkeypatch.setattr(manager, "_ensure_connected", fake_ensure_connected)

    with pytest.raises(OfficialToolError) as exc_info:
        manager.reconnect(ignore_cooldown=False)

    assert exc_info.value.code == "official_auth_cooldown"
    assert connect_called["count"] == 0


def test_reconnect_ignore_true_clears_shared_cooldown_and_connects(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
    shared_path = tmp_path / "shared-auth-retry.json"
    monkeypatch.setenv("LINEAR_OFFICIAL_AUTH_RETRY_STATE_FILE", str(shared_path))
    manager = OfficialMcpSessionManager(auth_retry_cooldown_seconds=120.0)
    manager._auth_retry_not_before = time.time() + 30
    manager._last_auth_failure_reason = "TimeoutError: oauth timed out"
    manager._write_shared_auth_retry_state(manager._auth_retry_not_before, manager._last_auth_failure_reason)
    manager._session = object()
    assert shared_path.exists()

    def fake_submit(coro, timeout=None):
        if asyncio.iscoroutine(coro):
            coro.close()
        manager._session = None
        return None

    def fake_ensure_connected():
        manager._session = object()

    monkeypatch.setattr(manager, "_submit", fake_submit)
    monkeypatch.setattr(manager, "_ensure_connected", fake_ensure_connected)

    result = manager.reconnect(ignore_cooldown=True)
    assert result["connected"] is True
    assert result["authRetryRemainingSeconds"] == 0
    assert not shared_path.exists()


def test_record_failure_logs_redacted_message(caplog: pytest.LogCaptureFixture):
    manager = OfficialMcpSessionManager()
    caplog.set_level("WARNING", logger="oh_my_linear.official_session")

    manager._record_failure(RuntimeError("token=super-secret-value"))

    assert "super-secret-value" not in caplog.text
    assert "***" in caplog.text


URL = "https://mcp.linear.app/mcp"
URL_HASH = hashlib.md5(URL.encode()).hexdigest()  # noqa: S324


class TestClearTokenCache:
    def test_deletes_matching_token_file(self, tmp_path: Path) -> None:
        cache_dir = tmp_path / ".mcp-auth" / "mcp-remote-0.1.37"
        cache_dir.mkdir(parents=True)
        (cache_dir / f"{URL_HASH}_tokens.json").write_text("test")
        (cache_dir / f"{URL_HASH}_client_info.json").write_text("test")
        (cache_dir / f"{URL_HASH}_code_verifier.txt").write_text("test")
        (cache_dir / "other_hash_tokens.json").write_text("keep")

        mgr = OfficialMcpSessionManager(url=URL, transport="stdio")
        with patch.object(Path, "home", return_value=tmp_path):
            deleted = mgr._clear_token_cache()

        assert deleted == 3
        assert not (cache_dir / f"{URL_HASH}_tokens.json").exists()
        assert not (cache_dir / f"{URL_HASH}_client_info.json").exists()
        assert not (cache_dir / f"{URL_HASH}_code_verifier.txt").exists()
        assert (cache_dir / "other_hash_tokens.json").exists()

    def test_no_error_when_no_files(self, tmp_path: Path) -> None:
        cache_dir = tmp_path / ".mcp-auth" / "mcp-remote-0.1.37"
        cache_dir.mkdir(parents=True)
        mgr = OfficialMcpSessionManager(url=URL, transport="stdio")
        with patch.object(Path, "home", return_value=tmp_path):
            deleted = mgr._clear_token_cache()
        assert deleted == 0

    def test_static_clear_for_url_works_without_instance(self, tmp_path: Path) -> None:
        cache_dir = tmp_path / ".mcp-auth" / "mcp-remote-0.1.37"
        cache_dir.mkdir(parents=True)
        (cache_dir / f"{URL_HASH}_tokens.json").write_text("test")
        (cache_dir / f"{URL_HASH}_client_info.json").write_text("test")
        (cache_dir / f"{URL_HASH}_code_verifier.txt").write_text("test")

        with patch.object(Path, "home", return_value=tmp_path):
            deleted = OfficialMcpSessionManager.clear_token_cache_for_url(URL)

        assert deleted == 3
        assert not (cache_dir / f"{URL_HASH}_tokens.json").exists()
        assert not (cache_dir / f"{URL_HASH}_client_info.json").exists()
        assert not (cache_dir / f"{URL_HASH}_code_verifier.txt").exists()

    def test_static_clear_defaults_to_official_url(self, tmp_path: Path) -> None:
        default_hash = hashlib.md5(DEFAULT_OFFICIAL_MCP_URL.encode()).hexdigest()  # noqa: S324
        cache_dir = tmp_path / ".mcp-auth" / "mcp-remote-0.1.37"
        cache_dir.mkdir(parents=True)
        (cache_dir / f"{default_hash}_tokens.json").write_text("test")

        with patch.object(Path, "home", return_value=tmp_path):
            deleted = OfficialMcpSessionManager.clear_token_cache_for_url()

        assert deleted == 1
        assert not (cache_dir / f"{default_hash}_tokens.json").exists()


class TestReauth:
    def test_reauth_clears_tokens_and_reconnects(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        cache_dir = tmp_path / ".mcp-auth" / "mcp-remote-0.1.37"
        cache_dir.mkdir(parents=True)
        (cache_dir / f"{URL_HASH}_tokens.json").write_text("old-token")
        (cache_dir / f"{URL_HASH}_client_info.json").write_text("old-client-info")
        (cache_dir / f"{URL_HASH}_code_verifier.txt").write_text("old-verifier")

        mgr = OfficialMcpSessionManager(url=URL, transport="stdio")
        mgr._session = object()

        connect_called = {"count": 0}

        def fake_ensure_connected():
            connect_called["count"] += 1
            mgr._session = object()

        def fake_submit(coro, timeout=None):
            if asyncio.iscoroutine(coro):
                coro.close()
            mgr._session = None
            return None

        monkeypatch.setattr(mgr, "_ensure_connected", fake_ensure_connected)
        monkeypatch.setattr(mgr, "_submit", fake_submit)

        with patch.object(Path, "home", return_value=tmp_path):
            result = mgr.reauth()

        assert result["deletedTokenFiles"] == 3
        assert result["connected"] is True
        assert connect_called["count"] == 1
        assert not (cache_dir / f"{URL_HASH}_tokens.json").exists()
        assert not (cache_dir / f"{URL_HASH}_client_info.json").exists()
        assert not (cache_dir / f"{URL_HASH}_code_verifier.txt").exists()

    def test_reauth_clears_shared_auth_cooldown_state(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        shared_path = tmp_path / "shared-auth-retry.json"
        monkeypatch.setenv("LINEAR_OFFICIAL_AUTH_RETRY_STATE_FILE", str(shared_path))
        mgr = OfficialMcpSessionManager(url=URL, transport="stdio")
        mgr._session = object()
        mgr._auth_retry_not_before = time.time() + 60
        mgr._last_auth_failure_reason = "TimeoutError: oauth timed out"
        mgr._write_shared_auth_retry_state(mgr._auth_retry_not_before, mgr._last_auth_failure_reason)
        assert shared_path.exists()

        def fake_submit(coro, timeout=None):
            if asyncio.iscoroutine(coro):
                coro.close()
            mgr._session = None
            return None

        def fake_ensure_connected():
            mgr._session = object()

        monkeypatch.setattr(mgr, "_submit", fake_submit)
        monkeypatch.setattr(mgr, "_ensure_connected", fake_ensure_connected)
        monkeypatch.setattr(mgr, "_clear_token_cache", lambda: 0)

        result = mgr.reauth()

        assert result["deletedTokenFiles"] == 0
        assert result["connected"] is True
        assert mgr._auth_retry_not_before is None
        assert mgr._last_auth_failure_reason is None
        assert not shared_path.exists()
