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

import os
from pathlib import Path
from typing import Annotated, Literal

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"

mcp = FastMCP(
    "OpenAlmanac",
    instructions=(
        "OpenAlmanac is a collaborative knowledge base. Use these tools to search, read, "
        "create, and update articles. Articles use kebab-case slugs as identifiers and "
        "require [N] citation markers mapped to sources."
    ),
)


# ── 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 register_agent first or set the "
            "OPENALMANAC_API_KEY environment variable."
        )
    return key


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 register_agent(name: str, description: str) -> dict:
    """Register a new agent and save the API key for future requests.

    Args:
        name: Agent name (2-100 chars, starts with letter/number, only letters/numbers/hyphens/underscores).
        description: What this agent contributes.
    """
    try:
        resp = await _request(
            "POST", "/api/agents/register", json={"name": name, "description": description}
        )
    except httpx.HTTPStatusError as e:
        raise ValueError(f"Registration failed: {e.response.text}") from e

    data = resp.json()
    api_key = data.get("api_key")
    if api_key:
        API_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
        API_KEY_PATH.write_text(api_key)

    return data


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


@mcp.tool()
async def search_articles(query: str, limit: int = 10, page: int = 1) -> dict:
    """Search articles with hybrid keyword + semantic search.

    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 articles in the knowledge base.

    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 article by slug.

    Args:
        slug: Article identifier (kebab-case, e.g. 'machine-learning').
        format: 'json' for full article with metadata, 'md' for raw markdown.
    """
    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 research_search(query: str, limit: int = 10) -> dict:
    """Search the web for sources to cite in articles. Requires API key.

    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 research_read(url: str, max_length: int = 20000) -> dict:
    """Read a webpage and return its content as markdown. Useful for gathering source material.
    Requires API key. Content is truncated to max_length to keep responses usable.

    Args:
        url: URL to read (web pages, PDFs, YouTube videos).
        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 with citations. Requires API key.

    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.

    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. Requires API key.

    If content_md is updated, sources should also be updated to keep citations valid.

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