"""
Unified MCP server for Linear local-fast reads + official MCP fallback/writes.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import signal
import tempfile
import threading
import time
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase
from pydantic import ConfigDict

from . import local_handlers
from .official_session import OfficialMcpSessionManager
from .reader import LinearLocalReader
from .router import ToolRouter

logger = logging.getLogger(__name__)

_official: OfficialMcpSessionManager | None = None
_router: ToolRouter | None = None
_dynamic_official_tool_names: set[str] = set()
_local_tool_original_descriptions: dict[str, str] = {}

_RECONNECT_FLAG_PATH = Path(tempfile.gettempdir()) / "oh-my-linear-reconnect.flag"
_RECONNECT_FLAG_MAX_AGE_SECONDS = 10.0
_OFFICIAL_TOOLS_CACHE_PATH = Path.home() / ".oh-my-linear" / "official-tools.json"

# Visible MCP surface composed by this server:
#
#   oh-my-linear
#   |- local read tools          (declared statically below)
#   |- official passthroughs     (discovered once at startup, then proxied)
#   `- call_linear_tool_raw      (explicit escape hatch)
#
# Startup discovery exists only to compose that one visible surface. Runtime
# calls do not re-list downstream tools.


class _PassthroughArgModel(ArgModelBase):
    """Accept any top-level args and preserve them exactly as tool kwargs."""

    model_config = ConfigDict(extra="allow")

    def model_dump_one_level(self) -> dict[str, Any]:
        # Preserve declared fields (if any) and arbitrary extras.
        out = super().model_dump_one_level()
        extras = getattr(self, "__pydantic_extra__", None)
        if isinstance(extras, dict):
            out.update(extras)
        return out


def _make_official_proxy(tool_name: str) -> Callable[..., Any]:

    def _proxy(**kwargs: Any) -> Any:
        if _router is None:
            raise RuntimeError("server not initialized")
        return _router.call_official(tool_name, kwargs)

    _proxy.__name__ = f"official_{tool_name}_proxy"
    # Keep function docstring empty so tool descriptions come from official metadata.
    _proxy.__doc__ = ""
    return _proxy


def _local_read_tool_names() -> set[str]:
    """Return tool names owned by the local IndexedDB read path."""
    return set(local_handlers.LOCAL_READ_HANDLERS.keys())


def _index_official_tools(official_tools: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
    tools_by_name: dict[str, dict[str, Any]] = {}
    for tool in official_tools:
        name = tool.get("name")
        if isinstance(name, str) and name and name not in tools_by_name:
            tools_by_name[name] = tool
    return tools_by_name


def _write_official_tools_cache(official_tools_by_name: dict[str, dict[str, Any]]) -> None:
    try:
        _OFFICIAL_TOOLS_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
        payload = {
            "version": 1,
            "tools": list(official_tools_by_name.values()),
        }
        _OFFICIAL_TOOLS_CACHE_PATH.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8")
    except Exception as exc:
        logger.warning("Could not write official tool cache: %s", exc)


def _load_official_tools_cache() -> dict[str, dict[str, Any]] | None:
    if not _OFFICIAL_TOOLS_CACHE_PATH.exists():
        return None

    try:
        payload = json.loads(_OFFICIAL_TOOLS_CACHE_PATH.read_text(encoding="utf-8"))
    except Exception as exc:
        logger.warning("Could not read official tool cache: %s", exc)
        return None

    tools = payload.get("tools") if isinstance(payload, dict) else None
    if not isinstance(tools, list):
        return None
    return _index_official_tools([tool for tool in tools if isinstance(tool, dict)])


def _write_reconnect_flag() -> None:
    _RECONNECT_FLAG_PATH.write_text(f"{time.time()}\n", encoding="utf-8")


def _consume_recent_reconnect_flag(now: float | None = None) -> bool:
    if not _RECONNECT_FLAG_PATH.exists():
        return False

    current_time = time.time() if now is None else now
    try:
        age_seconds = current_time - _RECONNECT_FLAG_PATH.stat().st_mtime
    except OSError:
        age_seconds = _RECONNECT_FLAG_MAX_AGE_SECONDS + 1

    try:
        _RECONNECT_FLAG_PATH.unlink()
    except OSError:
        pass

    return age_seconds <= _RECONNECT_FLAG_MAX_AGE_SECONDS


def _handle_reconnect_signal() -> None:
    _write_reconnect_flag()
    os._exit(0)


def _register_reconnect_signal_handlers() -> None:
    loop = asyncio.get_running_loop()
    for sig in (signal.SIGINT, signal.SIGTERM):
        try:
            loop.add_signal_handler(sig, _handle_reconnect_signal)
        except NotImplementedError:
            signal.signal(sig, lambda _signum, _frame: _handle_reconnect_signal())


def _connect_official_in_background(official: OfficialMcpSessionManager) -> None:
    try:
        official.connect()
        official_tools = official.list_tools()
        tools_by_name = _index_official_tools(official_tools)
        if tools_by_name:
            _write_official_tools_cache(tools_by_name)
    except Exception as exc:
        logger.warning("Official MCP background connect failed: %s", exc)


def _discover_official_tools(
    official: OfficialMcpSessionManager,
    *,
    allow_live_connect: bool = True,
) -> dict[str, dict[str, Any]] | None:
    """Fetch downstream tool metadata once for startup surface composition."""
    if not allow_live_connect:
        return _load_official_tools_cache()

    try:
        official_tools = official.list_tools()
    except Exception as exc:
        logger.warning("Could not list official tools for startup surface composition: %s", exc)
        return _load_official_tools_cache()

    tools_by_name = _index_official_tools(official_tools)
    if tools_by_name:
        _write_official_tools_cache(tools_by_name)
    return tools_by_name


def _official_tool_description(tool: dict[str, Any]) -> str:
    raw_description = tool.get("description")
    return raw_description if isinstance(raw_description, str) else ""


def _has_syncable_official_description(tool: dict[str, Any]) -> bool:
    raw_description = tool.get("description")
    return isinstance(raw_description, str) and bool(raw_description.strip())


def _restore_local_tool_descriptions() -> None:
    """Restore local tool descriptions to their import-time baseline."""
    for tool_name, original_description in _local_tool_original_descriptions.items():
        registered = mcp._tool_manager._tools.get(tool_name)
        if registered is not None:
            registered.description = original_description


def _sync_local_tool_descriptions(official_tools_by_name: dict[str, dict[str, Any]]) -> None:
    """Canonicalize runtime-visible shared tool descriptions from official MCP.

    Source docstrings remain developer-facing. Shared local tools adopt the
    official MCP description at runtime so search/tool-selection behavior sees
    the same wording regardless of whether the call stays local or falls back.
    """
    for tool_name in sorted(_local_read_tool_names()):
        official_tool = official_tools_by_name.get(tool_name)
        registered = mcp._tool_manager._tools.get(tool_name)
        if official_tool is None or registered is None:
            continue
        if not _has_syncable_official_description(official_tool):
            continue
        registered.description = _official_tool_description(official_tool)


def _should_register_official_tool(
    tool_name: Any,
    *,
    local_read_tools: set[str],
    existing_tools: set[str],
) -> bool:
    """Decide whether a downstream official tool belongs on this server surface."""
    if not isinstance(tool_name, str) or not tool_name:
        return False
    if tool_name in local_read_tools:
        return False
    if tool_name in {"call_linear_tool_raw"}:
        return False
    if tool_name in existing_tools:
        return False
    return True


def _register_dynamic_official_tools(official_tools_by_name: dict[str, dict[str, Any]]) -> list[str]:
    """Compose the visible tool surface as local reads plus non-colliding official tools."""
    if _router is None:
        raise RuntimeError("server not initialized")

    local_read_tools = _local_read_tool_names()
    existing = set(mcp._tool_manager._tools.keys())
    added: list[str] = []

    for tool in official_tools_by_name.values():
        name = tool.get("name")
        if not _should_register_official_tool(
            name,
            local_read_tools=local_read_tools,
            existing_tools=existing,
        ):
            continue

        input_schema = tool.get("inputSchema")
        proxy = _make_official_proxy(name)

        description = _official_tool_description(tool)
        title = tool.get("title") if isinstance(tool.get("title"), str) else None
        mcp.add_tool(
            proxy,
            name=name,
            title=title,
            description=description,
        )

        registered = mcp._tool_manager._tools.get(name)
        if registered is not None:
            # Forward exact user-provided args without local schema coercion.
            registered.fn_metadata.arg_model = _PassthroughArgModel
            # Keep official schema visible to clients (name/description/schema parity).
            if isinstance(input_schema, dict):
                registered.parameters = input_schema

        existing.add(name)
        added.append(name)

    return added


def _compose_official_tool_surface(
    official: OfficialMcpSessionManager,
    *,
    allow_live_connect: bool = True,
) -> list[str]:
    """Discover official tools once, sync shared descriptions, then add passthroughs."""
    _restore_local_tool_descriptions()
    official_tools_by_name = _discover_official_tools(official, allow_live_connect=allow_live_connect)
    if official_tools_by_name is None:
        return []

    _sync_local_tool_descriptions(official_tools_by_name)
    return _register_dynamic_official_tools(official_tools_by_name)


def _unregister_dynamic_official_tools() -> None:
    global _dynamic_official_tool_names

    for name in sorted(_dynamic_official_tool_names):
        if name in mcp._tool_manager._tools:
            try:
                mcp.remove_tool(name)
            except Exception as exc:
                logger.warning("Failed to remove dynamic official tool '%s': %s", name, exc)
    _dynamic_official_tool_names = set()


@asynccontextmanager
async def _lifespan(server: FastMCP) -> AsyncIterator[None]:
    """Create components on startup, clean up on shutdown."""
    global _official, _router, _dynamic_official_tool_names

    reader = LinearLocalReader()
    official = OfficialMcpSessionManager()
    _official = official
    _router = ToolRouter(reader, official)

    # Clear any stale dynamic tools from previous server lifecycles.
    _unregister_dynamic_official_tools()
    _register_reconnect_signal_handlers()

    try:
        reader.refresh_cache()
    except Exception as exc:
        logger.warning("Initial cache load failed, starting degraded: %s", exc)

    reconnect_requested = _consume_recent_reconnect_flag()
    if reconnect_requested:
        deleted = official.clear_auth_cache()
        logger.info("Reconnect requested; cleared %d official auth cache file(s)", deleted)

    registered = _compose_official_tool_surface(
        official,
        allow_live_connect=not reconnect_requested,
    )
    _dynamic_official_tool_names = set(registered)
    if registered:
        logger.info(
            "Registered %d official passthrough tools: %s",
            len(registered),
            ", ".join(sorted(registered)),
        )
    if reconnect_requested:
        threading.Thread(
            target=_connect_official_in_background,
            args=(official,),
            daemon=True,
            name="official-reauth",
        ).start()

    try:
        yield
    finally:
        _unregister_dynamic_official_tools()
        _restore_local_tool_descriptions()
        _official = None
        _router = None
        official.close()


mcp = FastMCP(
    "OhMyLinear (Fast + Official)",
    instructions=(
        "OhMyLinear unified server. "
        "Read operations are served from local Linear.app cache first for speed, "
        "and automatically fall back to official Linear MCP when the local read "
        "path is unsupported or unavailable. "
        "Write operations use official Linear MCP."
    ),
    lifespan=_lifespan,
)


def _read(tool_name: str, **kwargs: Any) -> Any:
    if _router is None:
        raise RuntimeError("server not initialized")
    return _router.call_read(tool_name, kwargs)


@mcp.tool()
def list_issues(
    assignee: str | None = None,
    team: str | None = None,
    state: str | None = None,
    priority: int | None = None,
    project: str | None = None,
    query: str | None = None,
    orderBy: str = "updatedAt",
    limit: int = 50,
) -> dict[str, Any]:
    """List issues with optional filtering and sorting.

    Args:
        assignee: Filter by user name or email.
        team: Filter by team key or name.
        state: Filter by state name or type (e.g., "Todo", "In Progress", "Done"). Case-insensitive.
        priority: Filter by exact numeric priority level.
        project: Filter by project name or ID.
        query: Case-insensitive substring search in issue titles.
        orderBy: Sort field - "updatedAt" (default) or "createdAt". Descending order.
        limit: Max issues to return (default 50). 0 or negative returns all.

    Returns:
        dict with "issues" (list of {identifier, title, priority, state, stateType,
        assignee, dueDate}) and "totalCount".
    """
    return _read(
        "list_issues",
        assignee=assignee,
        team=team,
        state=state,
        priority=priority,
        project=project,
        query=query,
        orderBy=orderBy,
        limit=limit,
    )


@mcp.tool()
def get_issue(id: str) -> dict[str, Any] | None:
    """Retrieve full details of a specific issue by identifier.

    Args:
        id: Issue identifier (e.g., "ENG-123").

    Returns:
        dict with {identifier, title, description, priority, estimate, state,
        stateType, assignee, project, dueDate, createdAt, updatedAt, comments
        (list of {author, body, createdAt}), url} or None if not found.
    """
    return _read("get_issue", id=id)


@mcp.tool()
def list_teams() -> list[dict[str, Any]]:
    """Retrieve all teams from the workspace.

    Returns:
        List of team dicts sorted by key, each with {key, name, issueCount}.
    """
    return _read("list_teams")


@mcp.tool()
def list_projects(team: str | None = None) -> list[dict[str, Any]]:
    """Retrieve projects, optionally filtered by team.

    Args:
        team: Team name or key to filter by. Returns empty list if not found.

    Returns:
        List of project dicts sorted by name, each with {name, state, issueCount,
        startDate, targetDate}.
    """
    return _read("list_projects", team=team)


@mcp.tool()
def get_team(query: str) -> dict[str, Any] | None:
    """Retrieve a team by name or key.

    Args:
        query: Team key (exact, case-sensitive uppercase) or name (substring, case-insensitive).

    Returns:
        dict with {id, key, name, description, issueCount, issuesByState} or None.
    """
    return _read("get_team", query=query)


@mcp.tool()
def get_project(query: str) -> dict[str, Any] | None:
    """Retrieve a project by name or slug ID.

    Args:
        query: Project name (substring, case-insensitive) or slug ID (exact, case-insensitive).

    Returns:
        dict with {id, name, description, state, startDate, targetDate, issueCount,
        issuesByState} or None.
    """
    return _read("get_project", query=query)


@mcp.tool()
def list_users() -> list[dict[str, Any]]:
    """List all workspace users with assigned issue counts.

    Returns:
        List of user dicts, each with {id, name, email, displayName, assignedIssueCount}.
    """
    return _read("list_users")


@mcp.tool()
def get_user(query: str) -> dict[str, Any] | None:
    """Retrieve a user by name or email.

    Args:
        query: User name or email to search for.

    Returns:
        dict with {id, name, email, displayName, assignedIssueCount, issuesByState} or None.
    """
    return _read("get_user", query=query)


@mcp.tool()
def list_issue_statuses(team: str) -> list[dict[str, Any]]:
    """List all issue statuses (workflow states) for a team.

    Args:
        team: Team key, name, or ID.

    Returns:
        List of status dicts, each with {id, name, type, color, position}.
    """
    return _read("list_issue_statuses", team=team)


@mcp.tool()
def get_issue_status(
    team: str,
    name: str | None = None,
    id: str | None = None,
) -> dict[str, Any] | None:
    """Get a single issue status by name or ID within a team.

    Args:
        team: Team key, name, or ID.
        name: Status name to search for. Optional if id is provided.
        id: Status ID to look up. Optional if name is provided.

    Returns:
        dict with {id, name, type, color, position, team} or None.
    """
    return _read("get_issue_status", team=team, name=name, id=id)


@mcp.tool()
def list_comments(issueId: str) -> list[dict[str, Any]]:
    """List all comments for a specific issue.

    Args:
        issueId: Issue identifier (e.g., "ENG-123").

    Returns:
        List of comment dicts, each with {id, author, body, createdAt, updatedAt}.
    """
    return _read("list_comments", issueId=issueId)


@mcp.tool()
def list_issue_labels(team: str | None = None) -> list[dict[str, Any]]:
    """List all issue labels, optionally filtered by team.

    Args:
        team: Team key or name to filter by. Returns all labels if None.

    Returns:
        List of label dicts sorted by name, each with {id, name, color, isGroup}.
    """
    return _read("list_issue_labels", team=team)


@mcp.tool()
def list_initiatives() -> list[dict[str, Any]]:
    """Retrieve all initiatives sorted alphabetically by name.

    Returns:
        List of initiative dicts, each with {id, name, slugId, color, status, owner}.
    """
    return _read("list_initiatives")


@mcp.tool()
def get_initiative(query: str) -> dict[str, Any] | None:
    """Retrieve a single initiative by name or identifier.

    Args:
        query: Initiative name or identifier to search for.

    Returns:
        dict with {id, name, slugId, color, status, owner, teamIds, createdAt,
        updatedAt} or None.
    """
    return _read("get_initiative", query=query)


@mcp.tool()
def list_cycles(teamId: str) -> list[dict[str, Any]]:
    """Retrieve cycles for a team.

    Args:
        teamId: Team ID or name.

    Returns:
        List of cycle dicts, each with {id, number, startsAt, endsAt, completedAt,
        progress ({completed, started, unstarted, total} or None)}.
    """
    return _read("list_cycles", teamId=teamId)


@mcp.tool()
def list_documents(project: str | None = None) -> list[dict[str, Any]]:
    """Retrieve documents, optionally filtered by project.

    Args:
        project: Project ID or name to filter by. Returns all documents if None.

    Returns:
        List of document dicts sorted by updatedAt desc, each with {id, title,
        slugId, project, createdAt, updatedAt}.
    """
    return _read("list_documents", project=project)


@mcp.tool()
def get_document(id: str) -> dict[str, Any] | None:
    """Retrieve a document by ID.

    Args:
        id: Document identifier.

    Returns:
        dict with {id, title, slugId, project, creator, createdAt, updatedAt, url} or None.
    """
    return _read("get_document", id=id)


@mcp.tool()
def list_milestones(project: str) -> list[dict[str, Any]]:
    """List all milestones for a project.

    Args:
        project: Project name or identifier.

    Returns:
        List of milestone dicts, each with {id, name, targetDate, progress
        ({completed, started, unstarted, total})}. Empty list if project not found.
    """
    return _read("list_milestones", project=project)


@mcp.tool()
def get_milestone(project: str, query: str) -> dict[str, Any] | None:
    """Retrieve a specific milestone in a project by name or ID.

    Args:
        project: Project name or identifier.
        query: Milestone name or query string to match.

    Returns:
        dict with {id, name, project, targetDate, sortOrder, progress
        ({completed, started, unstarted, total})} or None.
    """
    return _read("get_milestone", project=project, query=query)


@mcp.tool()
def get_status_updates(
    type: str,
    id: str | None = None,
    project: str | None = None,
    initiative: str | None = None,
    user: str | None = None,
    includeArchived: bool | None = None,
    orderBy: str = "createdAt",
    limit: int = 50,
    cursor: str | None = None,
    createdAt: str | None = None,
    updatedAt: str | None = None,
) -> dict[str, Any] | None:
    """Retrieve status updates with filtering. Local cache supports type='project' only.

    Args:
        type: Update type. Local cache only supports "project"; others fall back to official MCP.
        id: Specific status update ID.
        project: Project name or identifier to filter by.
        initiative: Initiative filter (not supported locally, triggers fallback).
        user: User name or identifier to filter by.
        includeArchived: Include archived (not supported locally).
        orderBy: Sort by "createdAt" (default) or "updatedAt". Descending.
        limit: Max results (default 50). 0 returns all.
        cursor: Pagination cursor (not supported locally).
        createdAt: Filter by creation date (not supported locally).
        updatedAt: Filter by update date (not supported locally).

    Returns:
        dict with "statusUpdates" (list of {id, body, health, author, project,
        createdAt, updatedAt}) and "totalCount", or None.
    """
    return _read(
        "get_status_updates",
        type=type,
        id=id,
        project=project,
        initiative=initiative,
        user=user,
        includeArchived=includeArchived,
        orderBy=orderBy,
        limit=limit,
        cursor=cursor,
        createdAt=createdAt,
        updatedAt=updatedAt,
    )


@mcp.tool()
def list_project_updates(project: str) -> list[dict[str, Any]]:
    """List all status updates for a project.

    Args:
        project: Project name or identifier.

    Returns:
        List of update dicts, each with {id, body, health, author, project,
        createdAt, updatedAt}. Empty list if project not found.
    """
    return _read("list_project_updates", project=project)


@mcp.tool()
def call_linear_tool_raw(name: str, args: dict[str, Any] | None = None) -> Any:
    """
    Call any Linear MCP tool by name without local routing.

    This is the raw passthrough path for break-glass cases or compatibility.
    """
    if _router is None:
        raise RuntimeError("server not initialized")
    return _router.call_official(name, args or {})


def official_call_tool(name: str, args: dict[str, Any] | None = None) -> Any:
    """Deprecated in-process alias for call_linear_tool_raw()."""
    return call_linear_tool_raw(name=name, args=args)


def _capture_original_local_tool_descriptions() -> dict[str, str]:
    descriptions: dict[str, str] = {}
    for tool_name in sorted(_local_read_tool_names()):
        registered = mcp._tool_manager._tools.get(tool_name)
        if registered is not None:
            descriptions[tool_name] = registered.description or ""
    return descriptions


_local_tool_original_descriptions = _capture_original_local_tool_descriptions()


def main() -> None:
    mcp.run()
