"""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
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, load_config, save_config
from ..core.discovery import discover_repos
from ..core.workspace import Workspace, WorkspaceState, detect_state
from ..git.ops import (
    check_git_version, pull_safe_one, pull_safe_all, GitVersionError,
)
from ..forge import auth_status as _auth_status, migration_warning as _migration_warning, resolve_token

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))


async def _yield_inactive(server: FastMCP, *, keep_tools: set[str] | None = None):
    """Strip tools/resources for inactive states (disabled, dormant, migration error)."""
    await _strip_tools(server, keep=keep_tools)
    await _strip_resources(server)


def _build_instructions(ws: Workspace) -> str:
    """Build MCP instructions string from workspace state."""
    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:
        return (
            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
        )
    return (
        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?"'
    )


async def _start_webui_safe(ws, ctx):
    """Start the web UI, returning the server or None on failure."""
    try:
        import webbrowser
        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)
        await asyncio.to_thread(webbrowser.open, f"http://localhost:{port}")
        return webui_server
    except ImportError:
        log.info("Web UI module not available — skipping.")
        return None
    except Exception:
        log.warning("Failed to start web UI — continuing without it.", exc_info=True)
        return None


@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 _yield_inactive(server)
        _app_context = AppContext()
        yield _app_context
        return

    if state == WorkspaceState.DORMANT:
        log.info("Dormant mode — keeping only the init tool.")
        await _yield_inactive(server, keep_tools={"init"})
        _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 _yield_inactive(server)
        _app_context = AppContext()
        yield _app_context
        return
    log.info("Active mode — %d repos discovered.", len(ws.repos))

    server.local_provider.remove_tool("init")
    server.instructions = _build_instructions(ws)

    ctx = AppContext(workspace=ws)
    _app_context = ctx

    if await ws.cache.get_metadata("last_refresh") is None:
        await ws.refresh_once()

    webui_server = await _start_webui_safe(ws, ctx)

    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 _with_migration_hint(result: str) -> str:
    """Append a keyring migration hint to a tool response if applicable."""
    warning = _migration_warning()
    if warning:
        return result + f"\n\n[warning] {warning}"
    return result


def _repo_status_entry(r, git_statuses, default_divergence) -> dict:
    """Build a git status dict for a single repo from cached data."""
    st = git_statuses.get(r.name)
    if st is None:
        return {"repo": r.name, "error": "Git status not yet available (refresh pending)."}
    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 = default_divergence.get(r.name, (0, 0))
        if da or db:
            entry["default_branch"] = r.default_branch
            entry["default_ahead"] = da
            entry["default_behind"] = db
    return entry



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


def _check_init_preconditions(cwd: Path) -> str | None:
    """Return an error message if init can't proceed, or None if OK."""
    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."
        )
    return None


@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()

    error = _check_init_preconditions(cwd)
    if error:
        return error

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

    repos = discover_repos(cwd, Config())
    hosts = {r.forge_host for r in repos if r.forge_host}

    def _detect_forge_type(h):
        return "github" if "github" in h else "gitlab"

    config_data: dict = {}
    if hosts:
        config_data["forges"] = {h: {"type": _detect_forge_type(h)} 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."
    )

    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, ws._git_statuses, ws._default_divergence) 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 _with_migration_hint(_compact(data))


def _issue_has_label(issue, label_lower: str) -> bool:
    return any(label_lower in l.lower() for l in (issue.labels or []))


def _filter_issues(issues, state, author, label):
    """Apply state/author/label filters to an issue list."""
    if state:
        issues = [i for i in issues if i.state == state]
    if author:
        issues = [i for i in issues if i.author == author]
    if label:
        label_lower = label.lower()
        issues = [i for i in issues if _issue_has_label(i, label_lower)]
    return issues


def _issue_to_dict(i) -> dict:
    return {
        "repo": i.repo_name,
        "iid": i.iid,
        "title": i.title,
        "author": i.author,
        "state": i.state,
        "web_url": i.web_url,
        "notes": i.user_notes_count,
        "labels": i.labels or [],
        "assignees": i.assignees or [],
        "confidential": i.confidential,
    }


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

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

    issues = await ws.cache.get_issues(repo or None)
    issues = _filter_issues(issues, state, author, label)
    return _with_migration_hint(_compact([_issue_to_dict(i) for i in issues]))


@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 _with_migration_hint(_compact(data))


@mcp.tool()
async def get_pipeline_jobs(ctx: Context, repo: str, pipeline_id: int | str) -> 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._forge_clients:
        return f"No forge client available for '{repo}'."

    client = ws._forge_clients[r.forge_host]
    try:
        jobs = await asyncio.to_thread(
            client.get_pipeline_jobs, r.project_path, pipeline_id
        )
    except Exception as exc:
        log.warning("Error fetching pipeline jobs for %s/%s: %s", repo, pipeline_id, exc)
        return "Error fetching pipeline jobs. Check server logs for details."

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

@mcp.tool()
async def get_job_log(
    ctx: Context,
    repo: str,
    job_id: int | str,
    tail: int | None = None,
    start_line: int | None = None,
    end_line: int | None = None,
) -> str:
    """Get the log output of a CI job.

    By default returns the last 50 lines. Use start_line/end_line for a
    specific range (1-based, inclusive), or tail=N for the last N lines.

    Args:
        repo: Repo name.
        job_id: Job ID (from get_pipeline_jobs).
        tail: Number of lines from the end (default 50). Ignored when start_line is set.
        start_line: First line to return (1-based, inclusive).
        end_line: Last line to return (1-based, inclusive). Defaults to end of log.
    """
    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._forge_clients:
        return f"No forge client available for '{repo}'."

    client = ws._forge_clients[r.forge_host]
    try:
        raw = await asyncio.to_thread(client.get_job_log, r.project_path, job_id)
    except Exception as exc:
        log.warning("Error fetching job log for %s/%s: %s", repo, job_id, exc)
        return "Error fetching job log. Check server logs for details."

    if not raw:
        return "No log available for this job."

    lines = raw.splitlines()
    total = len(lines)

    if start_line is not None:
        s = max(1, start_line) - 1
        e = end_line if end_line is not None else total
        selected = lines[s:e]
        header = f"Lines {s + 1}-{min(e, total)} of {total}"
    else:
        n = tail if tail is not None else 50
        selected = lines[-n:] if n < total else lines
        shown_from = max(total - n, 0) + 1
        header = f"Lines {shown_from}-{total} of {total}"

    return f"[{header}]\n" + "\n".join(selected)


def _filter_vulns_severity(vulns, severity, show_all):
    if severity:
        return [v for v in vulns if v.severity.lower() == severity.lower()]
    if not show_all:
        return [v for v in vulns if v.severity in ("high", "critical")]
    return vulns


def _filter_vulns_state(vulns, state, show_all):
    if state:
        return [v for v in vulns if v.state.lower() == state.lower()]
    if not show_all:
        return [v for v in vulns if v.state == "detected"]
    return vulns


def _filter_vulns(vulns, severity, state, show_all):
    """Apply severity and state filters to a vulnerability list."""
    vulns = _filter_vulns_severity(vulns, severity, show_all)
    return _filter_vulns_state(vulns, state, show_all)


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

    By default returns only high/critical + detected vulns (the actionable ones).
    Pass show_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.
        show_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)
    vulns = _filter_vulns(vulns, severity, state, show_all)
    return _with_migration_hint(_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."

    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."})
    result = _compact([{"instance": a.instance, "authenticated": a.authenticated, "backend": a.backend} for a in infos])
    return _with_migration_hint(result)


@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
    forge_type = "github" if "github" in instance else "gitlab"
    existing = resolve_token(instance, forge_type=forge_type)
    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}"
        )
    if "github" in instance:
        url = "https://github.com/settings/tokens"
        scope_hint = "'repo' scope"
    else:
        url = f"https://{instance}/-/user_settings/personal_access_tokens"
        scope_hint = "'read_api' scope (or 'api' for full access)"
    return (
        f"To authenticate with {instance}:\n"
        f"1. Go to {url}\n"
        f"2. Create a token with {scope_hint}\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, forge_type: str) -> str:
    """Add or update a forge configuration in the workspace config.

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

    if forge_type not in ("gitlab", "github"):
        return f"Unsupported forge type '{forge_type}'. Supported: 'gitlab', 'github'."

    config_path = ws.root / ".project-hub" / "config.yaml"
    config = load_config(config_path)
    config.forges[host] = {"type": forge_type}
    save_config(config_path, config)

    await ws.rescan()

    return f"Forge '{host}' (type={forge_type}) added to config and workspace rescanned."


@mcp.tool()
async def rescan(ctx: Context) -> str:
    """Re-scan the workspace directory for new or removed git repos.

    Use after cloning a new repo into the workspace or deleting one.
    Returns the list of added/removed repos so you can update workspace.md.
    """
    ws = _ws(ctx)
    if ws is None:
        return NOT_INITIALIZED

    old_names = {r.name for r in ws.repos}
    await ws.rescan()
    new_names = {r.name for r in ws.repos}

    added = sorted(new_names - old_names)
    removed = sorted(old_names - new_names)

    if not added and not removed:
        return f"No changes — still {len(ws.repos)} repos."

    parts: list[str] = []
    if added:
        parts.append(f"New repos: {', '.join(added)}")
    if removed:
        parts.append(f"Removed repos: {', '.join(removed)}")
    parts.append(f"Total: {len(ws.repos)} repos.")

    if added:
        parts.append(
            "Update .project-hub/workspace.md with context for the new repo(s) "
            "(purpose, domain, cross-repo dependencies)."
        )

    return " ".join(parts)


# ---------------------------------------------------------------------------
# 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()
        cached = ws._git_statuses.get(r.name)
        entry["branch"] = cached.branch if cached else 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, ws._git_statuses, ws._default_divergence) 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})

    events = _app_context.workspace.cache.get_unseen_events_sync()
    return json.dumps([e.to_dict() for e in events])


@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."
