"""OpenAlmanac MCP Server — search, read, create, and update articles in the open knowledge base."""

import asyncio
import os
import webbrowser
from pathlib import Path
from typing import Annotated, Literal
from urllib.parse import parse_qs, urlparse

import httpx
from fastmcp import FastMCP
from pydantic import BaseModel, Field

API_BASE = "https://api.openalmanac.org"
API_KEY_PATH = Path.home() / ".openalmanac" / "api_key"
CONNECT_URL_BASE = "https://openalmanac.org/contribute/connect"
LOGIN_TIMEOUT_SECONDS = 120

mcp = FastMCP(
    "OpenAlmanac",
    instructions=(
        "OpenAlmanac is an open knowledge base — a Wikipedia anyone can read from and write to "
        "through an API. Articles are structured markdown with [N] citation markers mapped to sources.\n\n"
        "Workflow: login (once) → search_articles (check if it exists) → search_web + "
        "read_webpage (gather sources) → create_article or update_article (write with citations).\n\n"
        "Reading and searching articles is open. Writing requires an API key (from login). "
        "Login registers an agent linked to your human user, so contributions are attributed to both."
    ),
)


# ── Pydantic models ─────────────────────────────────────────────────────────


class Source(BaseModel):
    url: str = Field(description="Source URL (must start with http:// or https://)")
    title: str = Field(description="Source title")
    accessed_date: str = Field(description="Date accessed in YYYY-MM-DD format")


class InfoboxDetail(BaseModel):
    key: str
    value: str


class InfoboxHeader(BaseModel):
    image_url: str | None = Field(default=None, description="URL to a representative image")
    subtitle: str | None = Field(default=None, description="Short tagline, e.g. 'Mathematician & Computer Scientist'")
    details: list[InfoboxDetail] = Field(default=[], description="Key-value pairs for quick facts")
    links: list[str] = Field(default=[], description="External reference URLs")


class TimelineItem(BaseModel):
    primary: str = Field(description="Main event description (required)")
    secondary: str | None = None
    period: str | None = Field(default=None, description="Time period, e.g. '1936'")
    location: str | None = None
    description: list[str] | None = Field(default=None, description="List of detail strings")
    link: str | None = None


class ListItem(BaseModel):
    title: str = Field(description="Item title (required)")
    subtitle: str | None = None
    year: str | None = None
    description: list[str] | None = Field(default=None, description="List of detail strings")
    link: str | None = None


class GridItem(BaseModel):
    title: str = Field(description="Item title (required)")
    image_url: str | None = None
    year: str | None = None
    description: str | None = Field(default=None, description="Plain string (not a list)")
    link: str | None = None
    type: str | None = None


class TableItems(BaseModel):
    headers: list[str] = Field(description="Column headers (non-empty)")
    rows: list[dict] = Field(description="List of {cells: [str]} dicts matching headers")


class TimelineSection(BaseModel):
    type: Literal["timeline"] = "timeline"
    title: str
    items: list[TimelineItem]


class ListSection(BaseModel):
    type: Literal["list"] = "list"
    title: str
    items: list[ListItem]


class TagsSection(BaseModel):
    type: Literal["tags"] = "tags"
    title: str
    items: list[str] = Field(description="Flat list of tag strings")


class GridSection(BaseModel):
    type: Literal["grid"] = "grid"
    title: str
    items: list[GridItem]


class TableSection(BaseModel):
    type: Literal["table"] = "table"
    title: str
    items: TableItems


class KeyValueSection(BaseModel):
    type: Literal["key_value"] = "key_value"
    title: str
    items: list[InfoboxDetail]


InfoboxSection = Annotated[
    TimelineSection | ListSection | TagsSection | GridSection | TableSection | KeyValueSection,
    Field(discriminator="type"),
]


class Infobox(BaseModel):
    header: InfoboxHeader
    sections: list[InfoboxSection] = []


# ── Auth helpers ─────────────────────────────────────────────────────────────


def _get_api_key() -> str | None:
    """Read API key from env var, then from disk. Returns None if neither exists."""
    key = os.environ.get("OPENALMANAC_API_KEY")
    if key:
        return key
    if API_KEY_PATH.is_file():
        return API_KEY_PATH.read_text().strip()
    return None


def _require_api_key() -> str:
    key = _get_api_key()
    if not key:
        raise ValueError(
            "No API key found. Call login first or set the "
            "OPENALMANAC_API_KEY environment variable."
        )
    return key


def _build_connect_url(port: int) -> str:
    return f"{CONNECT_URL_BASE}?callback_port={port}"


def _write_private_text(path: Path, value: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    file_descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(file_descriptor, "w", encoding="utf-8") as file:
        file.write(value)
    path.chmod(0o600)


def _build_callback_response(status: str, body: str) -> bytes:
    html = (
        "<!doctype html>"
        "<html><head><meta charset='utf-8'><title>OpenAlmanac</title></head>"
        f"<body style=\"font-family: sans-serif; padding: 2rem; line-height: 1.5;\">{body}</body></html>"
    )
    return (
        f"HTTP/1.1 {status}\r\n"
        "Content-Type: text/html; charset=utf-8\r\n"
        f"Content-Length: {len(html.encode('utf-8'))}\r\n"
        "Connection: close\r\n"
        "\r\n"
        f"{html}"
    ).encode("utf-8")


async def _request(
    method: str,
    path: str,
    auth: bool = False,
    **kwargs,
) -> httpx.Response:
    headers = kwargs.pop("headers", {})
    if auth:
        headers["Authorization"] = f"Bearer {_require_api_key()}"

    async with httpx.AsyncClient(base_url=API_BASE, timeout=30) as client:
        resp = await client.request(method, path, headers=headers, **kwargs)
        resp.raise_for_status()
        return resp


# ── Setup ────────────────────────────────────────────────────────────────────


@mcp.tool()
async def login() -> str:
    """Log in via browser to register an agent and get an API key. This is the required
    first step before creating or updating articles. Only needs to be called once.

    If you already have a valid API key, this returns immediately without opening a browser."""

    # Check if we already have a valid key
    existing_key = _get_api_key()
    if existing_key:
        try:
            async with httpx.AsyncClient(base_url=API_BASE, timeout=10) as client:
                resp = await client.get(
                    "/api/agents/me",
                    headers={"Authorization": f"Bearer {existing_key}"},
                )
                if resp.status_code == 200:
                    agent_name = resp.json().get("name", "unknown")
                    return f"Already logged in as {agent_name}."
        except Exception:
            pass

    callback_token: asyncio.Future[str] = asyncio.get_running_loop().create_future()

    async def handle_callback(
        reader: asyncio.StreamReader,
        writer: asyncio.StreamWriter,
    ) -> None:
        try:
            request_line = await reader.readline()
            while True:
                header_line = await reader.readline()
                if not header_line or header_line in {b"\r\n", b"\n"}:
                    break

            parts = request_line.decode("utf-8", errors="ignore").strip().split()
            if len(parts) < 2 or parts[0] != "GET":
                writer.write(
                    _build_callback_response(
                        "405 Method Not Allowed",
                        "<h1>Method not allowed</h1><p>Use the OpenAlmanac connect flow.</p>",
                    )
                )
                await writer.drain()
                return

            parsed = urlparse(parts[1])
            token = parse_qs(parsed.query).get("token", [None])[0]
            if parsed.path != "/callback":
                writer.write(
                    _build_callback_response(
                        "404 Not Found",
                        "<h1>Not found</h1><p>This callback URL is only used for OpenAlmanac login.</p>",
                    )
                )
                await writer.drain()
                return
            if not token or not token.startswith("oa_"):
                writer.write(
                    _build_callback_response(
                        "400 Bad Request",
                        "<h1>Invalid token</h1><p>Please return to OpenAlmanac and try again.</p>",
                    )
                )
                await writer.drain()
                return

            if not callback_token.done():
                callback_token.set_result(token)

            writer.write(
                _build_callback_response(
                    "200 OK",
                    "<h1>Connected</h1><p>Your OpenAlmanac agent is registered. You can close this tab.</p>",
                )
            )
            await writer.drain()
        finally:
            writer.close()
            await writer.wait_closed()

    server = await asyncio.start_server(handle_callback, "127.0.0.1", 0)
    try:
        port = server.sockets[0].getsockname()[1]
        connect_url = _build_connect_url(port)
        browser_opened = False
        try:
            browser_opened = webbrowser.open(connect_url)
        except Exception:
            browser_opened = False

        try:
            token = await asyncio.wait_for(callback_token, timeout=LOGIN_TIMEOUT_SECONDS)
        except TimeoutError as exc:
            browser_note = ""
            if not browser_opened:
                browser_note = " The browser did not open automatically."
            raise ValueError(
                f"Login timed out after {LOGIN_TIMEOUT_SECONDS} seconds.{browser_note} "
                f"Open this URL manually and try again: {connect_url}"
            ) from exc

        _write_private_text(API_KEY_PATH, token)

        return "Logged in. Your agent is registered and contributions will be attributed to your account."
    finally:
        server.close()
        await server.wait_closed()


@mcp.tool()
async def logout() -> str:
    """Remove the saved API key from disk."""

    removed = False
    if API_KEY_PATH.is_file():
        API_KEY_PATH.unlink()
        removed = True

    message = "Logged out. The saved API key was removed."
    if not removed:
        message = "No saved API key was found."

    if os.environ.get("OPENALMANAC_API_KEY"):
        message += (
            " OPENALMANAC_API_KEY is set, so it still provides the active "
            "key for MCP requests."
        )
    return message


# ── Read (no auth) ───────────────────────────────────────────────────────────


@mcp.tool()
async def search_articles(query: str, limit: int = 10, page: int = 1) -> dict:
    """Search existing OpenAlmanac articles. Use this to check if an article already exists
    before creating one. No authentication needed.

    Args:
        query: Search terms.
        limit: Max results (1-100, default 10).
        page: Page number, 1-indexed (default 1).
    """
    try:
        resp = await _request(
            "GET", "/api/search", params={"query": query, "limit": limit, "page": page}
        )
        return resp.json()
    except httpx.HTTPStatusError as e:
        raise ValueError(f"Search failed: {e.response.text}") from e


@mcp.tool()
async def list_articles(sort: str = "recent", limit: int = 50, offset: int = 0) -> dict:
    """Browse all OpenAlmanac articles. No authentication needed.

    Args:
        sort: Sort order — 'recent' or 'title' (default 'recent').
        limit: Max results (1-200, default 50).
        offset: Pagination offset (default 0).
    """
    try:
        resp = await _request(
            "GET", "/api/articles", params={"sort": sort, "limit": limit, "offset": offset}
        )
        return resp.json()
    except httpx.HTTPStatusError as e:
        raise ValueError(f"Failed to list articles: {e.response.text}") from e


@mcp.tool()
async def get_article(slug: str, format: str = "json") -> dict | str:
    """Get a single OpenAlmanac article by its slug. No authentication needed.

    Args:
        slug: Article identifier (kebab-case, e.g. 'machine-learning').
        format: 'json' for full article with metadata, 'md' for raw markdown with YAML frontmatter.
    """
    try:
        resp = await _request("GET", f"/api/articles/{slug}", params={"format": format})
        if format == "md":
            return resp.text
        return resp.json()
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            raise ValueError(
                f"Article '{slug}' not found. Use search_articles to find the correct slug."
            ) from e
        raise ValueError(f"Failed to get article: {e.response.text}") from e


# ── Research (auth required) ─────────────────────────────────────────────────


@mcp.tool()
async def search_web(query: str, limit: int = 10) -> dict:
    """Search the web for sources to cite in articles. Use this to find references before
    writing. Requires API key. Rate limit: 10/min.

    Args:
        query: Search terms.
        limit: Max results (1-20, default 10).
    """
    try:
        resp = await _request(
            "GET", "/api/research/search", auth=True, params={"query": query, "limit": limit}
        )
        return resp.json()
    except httpx.HTTPStatusError as e:
        raise ValueError(f"Research search failed: {e.response.text}") from e


@mcp.tool()
async def read_webpage(url: str, max_length: int = 20000) -> dict:
    """Fetch a webpage and return its content as markdown. Use this to read sources found via
    search_web before citing them in articles. Supports web pages, PDFs, and YouTube videos.
    Requires API key. Rate limit: 5/min.

    Args:
        url: URL to read.
        max_length: Max characters to return (default 20000). Use higher values for long-form sources.
    """
    try:
        resp = await _request("GET", "/api/research/read", auth=True, params={"url": url})
        data = resp.json()
        content = data.get("content", "")
        if content and len(content) > max_length:
            data["content"] = content[:max_length] + f"\n\n[Truncated — {len(content)} total chars. Increase max_length to read more.]"
        return data
    except httpx.HTTPStatusError as e:
        raise ValueError(f"Research read failed: {e.response.text}") from e


# ── Write (auth required) ────────────────────────────────────────────────────


@mcp.tool()
async def create_article(
    slug: str,
    title: str,
    content_md: str,
    sources: list[Source],
    summary: str = "",
    category: str = "uncategorized",
    tags: list[str] = [],
    infobox: Infobox | None = None,
    relationships: list[str] = [],
) -> dict:
    """Create a new article. Search existing articles first to avoid duplicates.
    Research your topic with search_web and read_webpage before writing.

    Every substantive paragraph in content_md must have at least one [N] citation
    marker (1-indexed). Markers must be sequential with no gaps. Every source must
    be referenced and every reference must have a source. Requires API key.

    Args:
        slug: Unique kebab-case identifier (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$
        title: Article title (max 500 chars).
        content_md: Markdown body with [1], [2] etc. citation markers.
        sources: List of sources — each citation [N] maps to sources[N-1].
        summary: Brief summary of the article.
        category: Category name (default 'uncategorized').
        tags: List of tags.
        infobox: Structured metadata sidebar. Include image_url in header when possible.
        relationships: Slugs of related articles.
    """
    body: dict = {
        "article_id": slug,
        "title": title,
        "content": content_md,
        "summary": summary,
        "category": category,
        "tags": tags,
        "sources": [s.model_dump() for s in sources],
        "relationships": relationships,
    }
    if infobox is not None:
        body["infobox"] = infobox.model_dump()

    try:
        resp = await _request(
            "POST",
            "/api/articles",
            auth=True,
            json=body,
        )
        return resp.json()
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 409:
            raise ValueError(
                f"Article '{slug}' already exists. Use update_article to modify it."
            ) from e
        raise ValueError(f"Failed to create article: {e.response.text}") from e


@mcp.tool()
async def update_article(
    slug: str,
    change_summary: str = "Updated article",
    title: str | None = None,
    content_md: str | None = None,
    summary: str | None = None,
    category: str | None = None,
    tags: list[str] | None = None,
    sources: list[Source] | None = None,
    infobox: Infobox | None = None,
    relationships: list[str] | None = None,
) -> dict:
    """Update an existing article. Only provided fields are changed — omitted fields
    stay as-is. If content_md is updated, sources should also be updated to keep
    citations valid. Requires API key.

    Args:
        slug: Article identifier to update.
        change_summary: Description of what changed.
        title: New title.
        content_md: New markdown content with [N] citations.
        summary: New summary.
        category: New category.
        tags: New tags list.
        sources: New sources list.
        infobox: New infobox metadata.
        relationships: New related article slugs.
    """
    body: dict = {"change_summary": change_summary}
    if title is not None:
        body["title"] = title
    if content_md is not None:
        body["content"] = content_md
    if summary is not None:
        body["summary"] = summary
    if category is not None:
        body["category"] = category
    if tags is not None:
        body["tags"] = tags
    if sources is not None:
        body["sources"] = [s.model_dump() for s in sources]
    if infobox is not None:
        body["infobox"] = infobox.model_dump()
    if relationships is not None:
        body["relationships"] = relationships

    try:
        resp = await _request(
            "PUT",
            f"/api/articles/{slug}",
            auth=True,
            json=body,
        )
        return resp.json()
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            raise ValueError(
                f"Article '{slug}' not found. Use search_articles to find the correct slug."
            ) from e
        raise ValueError(f"Failed to update article: {e.response.text}") from e


def main():
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
