"""MCP server for project-hub.

Exposes workspace tools and resources over the MCP protocol (stdio).
In dormant mode only the ``init`` tool is functional; all other tools
return an error message asking the user to initialise first.
"""

from __future__ import annotations

import asyncio
import json
import logging
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from pathlib import Path

import yaml
from fastmcp import Context, FastMCP
from mcp.server.session import ServerSession

from ..core.cache import CacheMigrationRequired
from ..core.config import Config
from ..core.discovery import discover_repos
from ..core.workspace import Workspace, WorkspaceState, detect_state
from ..git.ops import (
    check_git_version, git_status as _git_status,
    ahead_behind_ref as _ahead_behind_ref, pull_safe_one, pull_safe_all, GitVersionError,
)
from ..gitlab.auth import auth_status as _auth_status, resolve_token

# All output to stderr — stdout is reserved for the MCP protocol.
logging.basicConfig(
    stream=sys.stderr, level=logging.INFO, format="%(levelname)s: %(message)s"
)
log = logging.getLogger("project-hub")

NOT_INITIALIZED = (
    "project-hub is not initialized in this directory. Use the init tool first."
)


# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------


@dataclass
class AppContext:
    """Shared state passed through the MCP lifespan to tools and resources."""
    workspace: Workspace | None = None
    webui_port: int | None = None
    session: ServerSession | None = None
    _refresh_task: asyncio.Task | None = field(default=None, repr=False)


async def _strip_tools(server: FastMCP, *, keep: set[str] | None = None) -> None:
    """Remove tools from the server, optionally keeping a subset."""
    keep = keep or set()
    names = [t.name for t in await server.local_provider.list_tools() if t.name not in keep]
    for name in names:
        server.local_provider.remove_tool(name)


async def _strip_resources(server: FastMCP) -> None:
    """Remove all resources from the server."""
    for r in await server.local_provider.list_resources():
        server.local_provider.remove_resource(str(r.uri))


@asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
    """Manage workspace lifecycle: detect state, start workspace, launch web UI."""
    try:
        check_git_version()
    except GitVersionError as e:
        log.error("Git version too old: %s", e)
        yield AppContext()
        return

    state = detect_state(Path.cwd())

    global _app_context
    if state in (WorkspaceState.DISABLED_OPT_OUT, WorkspaceState.DISABLED_SUB_REPO):
        log.info("Disabled (%s) — removing all tools and resources.", state.value)
        await _strip_tools(server)
        await _strip_resources(server)
        _app_context = AppContext()
        yield _app_context
        return

    if state == WorkspaceState.DORMANT:
        log.info("Dormant mode — keeping only the init tool.")
        await _strip_tools(server, keep={"init"})
        await _strip_resources(server)
        _app_context = AppContext()
        yield _app_context
        return

    ws = Workspace(Path.cwd())
    try:
        await ws.start()
    except CacheMigrationRequired:
        log.error(
            "Cache database needs migration. Run the CLI with --drop-current-cache first: "
            "uv run project-hub --drop-current-cache"
        )
        await _strip_tools(server)
        await _strip_resources(server)
        _app_context = AppContext()
        yield _app_context
        return
    log.info("Active mode — %d repos discovered.", len(ws.repos))

    # Active workspace: init is redundant, remove it.
    server.local_provider.remove_tool("init")

    # Set instructions now that we know the repos.
    repo_names = sorted(r.name for r in ws.repos)
    notes_path = ws.root / ".project-hub" / "workspace.md"
    notes_content = notes_path.read_text().strip() if notes_path.exists() else ""

    base = (
        "project-hub is a multi-repo workspace manager. "
        f"This workspace contains {len(ws.repos)} repos: "
        f"{', '.join(repo_names)}. "
        "You have tools to query git status, merge requests, pipelines, "
        "and vulnerabilities across all of them. "
        "Repos are identified by their directory name."
    )

    if notes_content:
        server.instructions = (
            f"{base}\n\n"
            "The following are workspace-scoped notes written by the user. "
            "They provide transversal context across repos — aliases, "
            "cross-repo relationships, conventions. Use them to navigate "
            "the workspace without asking the user for paths.\n\n"
            + notes_content
        )
    else:
        server.instructions = (
            f"{base}\n\n"
            "## ⚠️ Workspace not initialized\n\n"
            f".project-hub/workspace.md is empty — you have zero context about "
            f"the {len(ws.repos)} repos in this workspace. Any answer about these "
            "repos is unreliable guesswork.\n\n"
            "**You MUST propose workspace setup BEFORE answering the user's "
            "question** — unless the request is clearly urgent (production "
            "incident, broken pipeline, time-sensitive fix). In that case, "
            "help first, then propose setup at the end.\n\n"
            "For non-urgent requests, say something like: "
            '"Before I answer — the project-hub workspace isn\'t set up yet. '
            "It's a one-time thing that gives me context on all "
            f'{len(ws.repos)} repos. Want to do that first?"'
        )

    ctx = AppContext(workspace=ws)
    _app_context = ctx

    # Only sync pre-yield on first-ever init (empty cache).
    # On subsequent launches, the refresh loop handles it post-yield
    # so that new events are visible to the agent immediately.
    if await ws.cache.get_metadata("last_refresh") is None:
        await ws.refresh_once()

    # Start webui.
    try:
        from ..web.app import create_app, start_webui  # type: ignore[import-untyped]

        app = create_app(**ws.web_app_kwargs(), mcp_mode=True)
        webui_server, port = await start_webui(app, ws.config.http_port)
        ctx.webui_port = port
        log.info("Web UI started on port %d", port)
        import subprocess
        subprocess.Popen(
            ["python3", "-c", f"import webbrowser; webbrowser.open('http://localhost:{port}')"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
    except ImportError:
        log.info("Web UI module not available — skipping.")
        webui_server = None
    except Exception:
        log.warning("Failed to start web UI — continuing without it.", exc_info=True)
        webui_server = None

    async def on_new_events() -> None:
        if ctx.session is not None:
            try:
                await ctx.session.send_resource_updated("workspace://alerts")
            except Exception:
                log.debug("Failed to send resource update notification.", exc_info=True)

    refresh_task = asyncio.create_task(ws.refresh_loop(notify_callback=on_new_events))
    ctx._refresh_task = refresh_task

    try:
        yield ctx
    finally:
        refresh_task.cancel()
        try:
            await refresh_task
        except asyncio.CancelledError:
            pass
        if webui_server is not None:
            webui_server.should_exit = True
        await ws.stop()


# ---------------------------------------------------------------------------
# Server instance
# ---------------------------------------------------------------------------


mcp = FastMCP("project-hub", lifespan=app_lifespan)

# Module-level reference for resources (which can't take Context).
_app_context: AppContext | None = None


def _ctx(ctx: Context) -> AppContext:
    """Extract AppContext from a tool/resource Context and capture the session."""
    global _app_context
    app: AppContext = ctx.request_context.lifespan_context
    if app.session is None:
        app.session = ctx.session
    _app_context = app
    return app


def _ws(ctx: Context) -> Workspace | None:
    return _ctx(ctx).workspace


def _compact(data: object) -> str:
    """Minified JSON — saves tokens in MCP responses."""
    return json.dumps(data, separators=(",", ":"))


def _repo_status_entry(r) -> dict:
    """Build a git status dict for a single repo, including default-branch divergence."""
    try:
        st = _git_status(Path(r.path))
        entry: dict = {
            "repo": r.name,
            "branch": st.branch,
            "ahead": st.ahead,
            "behind": st.behind,
            "dirty": st.dirty,
            "stale": st.stale,
        }
        if r.default_branch and st.branch and st.branch != r.default_branch:
            da, db = _ahead_behind_ref(Path(r.path), f"origin/{r.default_branch}")
            if da or db:
                entry["default_branch"] = r.default_branch
                entry["default_ahead"] = da
                entry["default_behind"] = db
        return entry
    except Exception as exc:
        return {"repo": r.name, "error": str(exc)}



# ---------------------------------------------------------------------------
# Tools — dormant mode
# ---------------------------------------------------------------------------


@mcp.tool()
async def init(ctx: Context) -> str:
    """Initialize a project-hub workspace in the current directory.

    project-hub is a multi-repo workspace manager. Call this tool if the user
    is working from a parent directory that contains multiple git repos and
    wants to manage them together (cross-repo status, merge requests,
    pipelines, vulnerabilities).

    Do NOT call this if the current directory is itself a git repo or contains
    fewer than 2 git repos — it is designed for multi-repo workspaces only.
    """
    cwd = Path.cwd()

    if (cwd / ".project-hub").is_dir():
        return "project-hub is already initialized in this directory."

    if (cwd / ".git").exists():
        return (
            "This directory is itself a git repo. project-hub is designed for "
            "multi-repo workspaces (a parent directory containing several git repos)."
        )

    git_dirs = [d for d in cwd.iterdir() if d.is_dir() and (d / ".git").exists()]
    if len(git_dirs) < 2:
        return (
            f"Found {len(git_dirs)} git repo(s) in this directory. "
            "project-hub is designed for workspaces with multiple git repos."
        )

    hub = cwd / ".project-hub"
    hub.mkdir()

    # Generate default config with detected forges.
    repos = discover_repos(cwd, Config())
    hosts: set[str] = set()
    for r in repos:
        if r.forge_host:
            hosts.add(r.forge_host)

    config_data: dict = {}
    if hosts:
        config_data["forges"] = {h: {"type": "gitlab"} for h in sorted(hosts)}

    (hub / "config.yaml").write_text(
        yaml.dump(config_data, default_flow_style=False) if config_data else ""
    )
    (hub / "workspace.md").write_text("")

    repo_names = sorted(r.name for r in repos)
    summary = (
        f"Initialized project-hub in {cwd}.\n"
        f"Discovered {len(repos)} repos: {', '.join(repo_names)}.\n"
        f"Forge hosts: {', '.join(sorted(hosts)) or 'none detected'}.\n"
        f"Config written to .project-hub/config.yaml — edit it to customize.\n"
        f"\nworkspace.md is empty — ask the user about their workspace context "
        f"(repo aliases, cross-repo dependencies, domain ownership) and write "
        f"it to .project-hub/workspace.md."
    )

    # Signal that the tool list should be refreshed (new tools are now available).
    try:
        await ctx.session.send_tool_list_changed()
    except Exception:
        pass

    return summary


# ---------------------------------------------------------------------------
# Tools — active mode
# ---------------------------------------------------------------------------


@mcp.tool()
async def list_repos(ctx: Context) -> str:
    """List all repos in the workspace with their forge information."""
    ws = _ws(ctx)
    if ws is None:
        return NOT_INITIALIZED
    return _compact([r.to_dict() for r in ws.repos])


@mcp.tool()
async def repo_status(ctx: Context, repo: str = "") -> str:
    """Git status for one or all repos (branch, ahead/behind remote and default branch, dirty, stale)."""
    ws = _ws(ctx)
    if ws is None:
        return NOT_INITIALIZED

    targets = ws.repos
    if repo:
        targets = [r for r in ws.repos if r.name == repo]
        if not targets:
            return f"Repo '{repo}' not found. Use list_repos to see available repos."

    results = [_repo_status_entry(r) for r in targets]
    return _compact(results)


@mcp.tool()
async def fetch_all(ctx: Context) -> str:
    """Trigger a full refresh (git fetch + forge data). Returns immediately if already running."""
    ws = _ws(ctx)
    if ws is None:
        return NOT_INITIALIZED

    if ws._refresh_running:
        return "A refresh is already in progress."

    app = _ctx(ctx)

    async def on_new_events() -> None:
        if app.session is not None:
            try:
                await app.session.send_resource_updated("workspace://alerts")
            except Exception:
                pass

    await ws.refresh_once(notify_callback=on_new_events)
    return "Refresh complete."


@mcp.tool()
async def pull_safe(ctx: Context, repo: str = "", strategy: str = "") -> str:
    """Pull one or all repos safely (skip dirty, skip conflicts).

    Args:
        repo: Name of a specific repo, or empty for all.
        strategy: 'merge' or 'rebase'. Defaults to workspace config.
    """
    ws = _ws(ctx)
    if ws is None:
        return NOT_INITIALIZED

    strat = strategy or ws.config.pull_strategy

    if repo:
        targets = [r for r in ws.repos if r.name == repo]
        if not targets:
            return f"Repo '{repo}' not found."
        results = [await pull_safe_one(Path(targets[0].path), strategy=strat)]
    else:
        results = await pull_safe_all([Path(r.path) for r in ws.repos], strategy=strat)

    return _compact([
        {"repo": r.repo, "status": str(r.status), "message": r.message}
        for r in results
    ])


@mcp.tool()
async def list_mrs(
    ctx: Context, repo: str = "", state: str = "opened", author: str = ""
) -> str:
    """List merge requests from the cache.

    Args:
        repo: Filter by repo name (empty = all).
        state: MR state filter (default: opened).
        author: Filter by author username.
    """
    ws = _ws(ctx)
    if ws is None:
        return NOT_INITIALIZED

    mrs = await ws.cache.get_mrs(repo or None)
    if state:
        mrs = [m for m in mrs if m.state == state]
    if author:
        mrs = [m for m in mrs if m.author == author]

    data = [
        {
            "repo": m.repo_name,
            "iid": m.iid,
            "title": m.title,
            "author": m.author,
            "state": m.state,
            "source_branch": m.source_branch,
            "target_branch": m.target_branch,
            "web_url": m.web_url,
            "notes": m.user_notes_count,
            "approved": m.approved,
        }
        for m in mrs
    ]
    return _compact(data)


@mcp.tool()
async def list_pipelines(
    ctx: Context, repo: str = "", status: str = "", ref: str = ""
) -> str:
    """List pipelines from the cache.

    Args:
        repo: Filter by repo name (empty = all).
        status: Filter by pipeline status (e.g. 'failed', 'success').
        ref: Filter by git ref / branch.
    """
    ws = _ws(ctx)
    if ws is None:
        return NOT_INITIALIZED

    pipelines = await ws.cache.get_pipelines(repo or None)
    if status:
        pipelines = [p for p in pipelines if p.status == status]
    if ref:
        pipelines = [p for p in pipelines if p.ref == ref]

    data = [
        {
            "repo": p.repo_name,
            "id": p.id,
            "status": p.status,
            "ref": p.ref,
            "web_url": p.web_url,
            "created_at": p.created_at,
        }
        for p in pipelines
    ]
    return _compact(data)


@mcp.tool()
async def get_pipeline_jobs(ctx: Context, repo: str, pipeline_id: int) -> str:
    """Get jobs for a specific pipeline.

    Args:
        repo: Repo name.
        pipeline_id: Pipeline ID.
    """
    ws = _ws(ctx)
    if ws is None:
        return NOT_INITIALIZED

    targets = [r for r in ws.repos if r.name == repo]
    if not targets:
        return f"Repo '{repo}' not found."

    r = targets[0]
    if not r.has_forge or r.forge_host not in ws._gitlab_clients:
        return f"No forge client available for '{repo}'."

    client = ws._gitlab_clients[r.forge_host]
    try:
        jobs = await asyncio.to_thread(
            client.get_pipeline_jobs, r.project_path, pipeline_id
        )
    except Exception as exc:
        return f"Error fetching jobs: {exc}"

    return _compact([j.to_dict() for j in jobs])


@mcp.tool()
async def get_vulnerabilities(
    ctx: Context, repo: str = "", severity: str = "", state: str = "", all: bool = False
) -> str:
    """List vulnerabilities from the cache.

    By default returns only high/critical + detected vulns (the actionable ones).
    Pass all=true to get everything, or use severity/state to override individually.

    Args:
        repo: Filter by repo name (empty = all).
        severity: Filter by severity level (e.g. 'critical', 'high'). Default: high+critical.
        state: Filter by state (e.g. 'detected', 'dismissed'). Default: detected.
        all: If true, return all vulns regardless of severity/state defaults.
    """
    ws = _ws(ctx)
    if ws is None:
        return NOT_INITIALIZED

    vulns = await ws.cache.get_vulns(repo or None)

    # Apply severity filter (default: high+critical unless all=True)
    if severity:
        vulns = [v for v in vulns if v.severity.lower() == severity.lower()]
    elif not all:
        vulns = [v for v in vulns if v.severity in ("high", "critical")]

    # Apply state filter (default: detected unless all=True)
    if state:
        vulns = [v for v in vulns if v.state.lower() == state.lower()]
    elif not all:
        vulns = [v for v in vulns if v.state == "detected"]

    return _compact([v.to_dict() for v in vulns])


@mcp.tool()
async def acknowledge_alerts(ctx: Context, event_ids: str = "") -> str:
    """Mark events as seen.

    Args:
        event_ids: Comma-separated event IDs, or empty to mark all as seen.
    """
    ws = _ws(ctx)
    if ws is None:
        return NOT_INITIALIZED

    if event_ids:
        try:
            ids = [int(x.strip()) for x in event_ids.split(",") if x.strip()]
        except ValueError:
            return "Invalid event_ids — expected comma-separated integers."
        await ws.cache.mark_events_seen(ids)
        return f"Marked {len(ids)} event(s) as seen."
    else:
        await ws.cache.mark_all_events_seen()
        return "All events marked as seen."


@mcp.tool()
async def auth_status(ctx: Context) -> str:
    """Return authentication state per forge instance."""
    _ctx(ctx)  # capture session
    infos = _auth_status()
    if not infos:
        return _compact({"message": "No forge credentials configured."})
    return _compact([{"instance": a.instance, "authenticated": a.authenticated} for a in infos])


@mcp.tool()
async def auth_login(ctx: Context, instance: str) -> str:
    """Guide the user through forge authentication.

    MCP cannot prompt interactively — returns instructions for the user.

    Args:
        instance: Forge host (e.g. 'gitlab.com').
    """
    _ctx(ctx)  # capture session
    existing = resolve_token(instance)
    if existing:
        return (
            f"A token for {instance} is already configured. "
            "To replace it, create a new personal access token with 'api' scope "
            f"and run: project-hub auth login {instance}"
        )
    return (
        f"To authenticate with {instance}:\n"
        f"1. Go to https://{instance}/-/user_settings/personal_access_tokens\n"
        f"2. Create a token with 'read_api' scope (or 'api' for full access)\n"
        f"3. Run: project-hub auth login {instance}\n"
        f"   and paste the token when prompted."
    )


@mcp.tool()
async def configure_forge(ctx: Context, host: str, type: str) -> str:
    """Add or update a forge configuration in the workspace config.

    Args:
        host: Forge hostname (e.g. 'gitlab.example.com').
        type: Forge type ('gitlab').
    """
    ws = _ws(ctx)
    if ws is None:
        return NOT_INITIALIZED

    if type not in ("gitlab",):
        return f"Unsupported forge type '{type}'. Currently only 'gitlab' is supported."

    config_path = ws.root / ".project-hub" / "config.yaml"
    try:
        raw = yaml.safe_load(config_path.read_text()) or {}
    except Exception:
        raw = {}

    forges = raw.setdefault("forges", {})
    forges[host] = {"type": type}
    tmp = config_path.with_suffix(".tmp")
    tmp.write_text(yaml.dump(raw, default_flow_style=False))
    tmp.rename(config_path)

    return f"Forge '{host}' (type={type}) added to config. Restart the MCP server to pick up changes."


# ---------------------------------------------------------------------------
# Resources
# ---------------------------------------------------------------------------


@mcp.resource("workspace://topology")
def topology() -> str:
    """Repos with remotes, branches, and forge info."""
    if _app_context is None or _app_context.workspace is None:
        return json.dumps({"error": NOT_INITIALIZED})
    ws = _app_context.workspace

    data = []
    for r in ws.repos:
        entry = r.to_dict()
        try:
            st = _git_status(Path(r.path))
            entry["branch"] = st.branch
        except Exception:
            entry["branch"] = None
        data.append(entry)

    return json.dumps(data)


@mcp.resource("workspace://status")
def status() -> str:
    """Git status per repo."""
    if _app_context is None or _app_context.workspace is None:
        return json.dumps({"error": NOT_INITIALIZED})
    ws = _app_context.workspace

    results = [_repo_status_entry(r) for r in ws.repos]
    return json.dumps(results)


@mcp.resource("workspace://alerts")
def alerts() -> str:
    """Unseen events."""
    if _app_context is None or _app_context.workspace is None:
        return json.dumps({"error": NOT_INITIALIZED})

    # Resources are sync in FastMCP — use sync cache access
    import sqlite3
    db_path = _app_context.workspace.root / ".project-hub" / "cache.db"
    if not db_path.exists():
        return json.dumps([])
    conn = sqlite3.connect(str(db_path))
    try:
        rows = conn.execute(
            "SELECT id, type, repo_name, summary, created_at FROM events WHERE seen = 0 ORDER BY created_at DESC"
        ).fetchall()
        data = [
            {"id": r[0], "type": r[1], "repo": r[2], "summary": r[3], "created_at": r[4]}
            for r in rows
        ]
        return json.dumps(data)
    finally:
        conn.close()


@mcp.resource("workspace://notes")
def notes() -> str:
    """Contents of .project-hub/workspace.md."""
    if _app_context is None or _app_context.workspace is None:
        return ""
    notes_path = _app_context.workspace.root / ".project-hub" / "workspace.md"
    if not notes_path.exists():
        return ""
    content = notes_path.read_text().strip()
    if not content:
        return (
            "workspace.md is empty. Ask the user about their workspace context "
            "(repo aliases, cross-repo dependencies, domain ownership) and write "
            "the result to .project-hub/workspace.md so it persists across sessions."
        )
    return (
        "Workspace-scoped notes (transversal context across all repos in this "
        "workspace). Use this to resolve repo aliases, understand cross-repo "
        "relationships, and navigate the workspace without asking the user for "
        "paths.\n\n"
        + content
    )


@mcp.resource("workspace://dashboard")
def dashboard_url() -> str:
    """The web UI URL."""
    if _app_context is None:
        return "Web UI is not running."
    if _app_context.webui_port:
        return f"http://localhost:{_app_context.webui_port}"
    return "Web UI is not running."
