#!/usr/bin/env python3
"""Local docs explorer — read every markdown doc in this repo via a clean web UI.

Usage::

    python tools/docs_explorer/serve.py            # opens http://127.0.0.1:8765
    python tools/docs_explorer/serve.py --port 9000
    python tools/docs_explorer/serve.py --no-open  # don't auto-open browser

What this is:
  - A maintainer-side / engineering-manager-side reader for every
    markdown file in this repo: PUBLIC_API.md, the standards, the
    recipes, the internal docs, CHANGELOG, FEEDBACK, the example
    READMEs, etc.
  - Sidebar grouped by category, full-text search, cross-doc link
    resolution, table-of-contents per page.

What this is NOT:
  - Shipped to PyPI. The wheel's ``packages = ["src/mgf"]`` rule
    excludes everything outside ``src/``, so this directory never
    lands in a release artifact.
  - A runnable-example harness (deferred).
  - An auto-generated API explorer (deferred).

Stdlib only — no extra deps. Binds 127.0.0.1 strictly (never
0.0.0.0). Markdown rendered client-side via marked.js (CDN); syntax
highlighting via prism.js (CDN). Works offline once the CDN assets
are cached by the browser.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
STATIC_DIR = Path(__file__).resolve().parent / "static"

# ----------------------------------------------------------------------
# Doc indexing
# ----------------------------------------------------------------------

# Globs scanned for markdown files. Order matters for sidebar grouping
# only — the categorize() function does the actual binning.
SCAN_GLOBS = (
    "*.md",                        # root: README, STARTHERE, PUBLIC_API, etc.
    "docs/**/*.md",                # all under docs/
    "examples/**/*.md",            # example READMEs
    "templates/**/*.md",           # cookiecutter template README
    "example_app/**/*.md",         # second-consumer demo
    "tools/**/*.md",               # this tool's README + sibling tools
)

# Categories — order here determines sidebar group order.
CATEGORY_ORDER = (
    "Public API",
    "Start here",
    "Standards",
    "Library reference",
    "Recipes",
    "Examples",
    "In progress",
    "Internal / Maintainer",
    "Project meta",
    "Claude session rules",
    "Templates",
    "Tools",
    "Other",
)


def categorize(rel_path: str) -> str:
    """Bin a markdown file path into a sidebar category."""
    p = rel_path.replace("\\", "/")
    if p == "PUBLIC_API.md":
        return "Public API"
    if p == "STARTHERE.md":
        return "Start here"
    if p == "README.md":
        return "Project meta"
    if p in ("CHANGELOG.md", "FEEDBACK.md", "SECURITY.md", "CONTRIBUTING.md"):
        return "Project meta"
    if p.startswith("docs/audience/"):
        return "By audience"
    if p.startswith("docs/standards/"):
        return "Standards"
    if p.startswith("docs/recipes/"):
        return "Recipes"
    if p.startswith("docs/reference/"):
        return "Library reference"
    if p.startswith("docs/design/"):
        return "Design papers"
    if p.startswith("docs/qa/"):
        return "QA / testing"
    if p.startswith("docs/inprogress/"):
        return "In progress"
    if p.startswith("docs/release/"):
        return "Release engineering"
    if p.startswith("docs/ops/"):
        return "Operations"
    if p.startswith("docs/cutover/"):
        return "Cutover guides"
    if p == "docs/tutorial.md":
        return "Tutorial"
    if p.startswith("docs/internal/"):
        return "Internal / Maintainer"
    if p.startswith("docs/claude/"):
        return "Claude session rules"
    if p.startswith("examples/"):
        return "Examples"
    if p.startswith("templates/"):
        return "Templates"
    if p.startswith("tools/"):
        return "Tools"
    if p.startswith("example_app/"):
        return "Examples"
    return "Other"


def extract_title(text: str, fallback: str) -> str:
    """Pull the first-H1 from a markdown file; fall back to filename."""
    for line in text.splitlines():
        line = line.strip()
        if line.startswith("# "):
            return line[2:].strip()
    return fallback


_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)


def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
    """Strip a YAML-style ``---...---`` block from the top of a markdown
    document and parse simple ``key: value`` lines into a dict. Returns
    ``(metadata, content_without_frontmatter)``.

    Not a full YAML parser — handles simple scalars, bracketed lists
    (``[a, b, c]``), and surrounding single/double quotes. Anything more
    exotic is left as a string. Without this, ``marked.js`` renders the
    ``---`` as a thematic break and the YAML lines as a paragraph, which
    shows up as ugly ``title: foo`` text at the top of every doc + every
    printed page.
    """
    m = _FRONTMATTER_RE.match(content)
    if not m:
        return {}, content
    body = m.group(1) or ""
    stripped = content[m.end():].lstrip("\n")
    metadata: dict[str, Any] = {}
    for line in body.splitlines():
        line = line.rstrip()
        if not line or line.lstrip().startswith("#"):
            continue
        if ":" not in line:
            continue
        key, _, value = line.partition(":")
        key = key.strip()
        value = value.strip()
        if (value.startswith('"') and value.endswith('"')) or (
            value.startswith("'") and value.endswith("'")
        ):
            value = value[1:-1]
        if value.startswith("[") and value.endswith("]"):
            items = [
                v.strip().strip('"').strip("'")
                for v in value[1:-1].split(",")
                if v.strip()
            ]
            metadata[key] = items
        else:
            metadata[key] = value
    return metadata, stripped


def sort_key(rel_path: str) -> tuple[Any, ...]:
    """Sort within a category — README/index first, then numeric prefix, then alpha."""
    name = Path(rel_path).name.lower()
    if name in ("readme.md", "index.md"):
        return (0, name)
    # numeric prefix like "01_lifecycle.md"
    m = re.match(r"^(\d+)[_-]", name)
    if m:
        return (1, int(m.group(1)), name)
    return (2, name)


def scan_docs() -> list[dict[str, Any]]:
    """Return [{path, title, category}] for every indexed markdown file."""
    seen: set[Path] = set()
    docs: list[dict[str, Any]] = []
    for pattern in SCAN_GLOBS:
        for path in REPO_ROOT.glob(pattern):
            # Skip anything inside known cache / build / vendor dirs.
            parts = set(path.parts)
            if parts & {
                "__pycache__", ".venv", "dist", ".git", "node_modules",
                ".pytest_cache", ".ruff_cache", ".mypy_cache",
                ".import_linter_cache", "build", ".tox",
            }:
                continue
            if not path.is_file():
                continue
            if path in seen:
                continue
            seen.add(path)
            rel = path.relative_to(REPO_ROOT).as_posix()
            try:
                text = path.read_text(encoding="utf-8")
            except (OSError, UnicodeDecodeError):
                continue
            docs.append(
                {
                    "path": rel,
                    "title": extract_title(text, path.stem),
                    "category": categorize(rel),
                }
            )
    docs.sort(key=lambda d: (CATEGORY_ORDER.index(d["category"]) if d["category"] in CATEGORY_ORDER else 99, sort_key(d["path"])))
    return docs


def grouped_index(docs: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
    """Group docs by category, preserving CATEGORY_ORDER."""
    groups: dict[str, list[dict[str, Any]]] = {}
    for d in docs:
        groups.setdefault(d["category"], []).append(d)
    # Reorder dict to match CATEGORY_ORDER (Python 3.7+ preserves insertion order).
    ordered: dict[str, list[dict[str, Any]]] = {}
    for cat in CATEGORY_ORDER:
        if cat in groups:
            ordered[cat] = groups[cat]
    return ordered


# ----------------------------------------------------------------------
# Search
# ----------------------------------------------------------------------

SNIPPET_PAD = 80          # chars of context on each side of a match
MAX_SNIPPETS_PER_DOC = 4  # cap snippets per doc so a 50-hit doc doesn't drown others
MAX_DOCS_PER_SEARCH = 25  # cap how many distinct docs we surface


def _make_snippet(text: str, idx: int, query_len: int) -> str:
    """Extract a ±SNIPPET_PAD-char snippet around match position."""
    start = max(0, idx - SNIPPET_PAD)
    end = min(len(text), idx + query_len + SNIPPET_PAD)
    snippet = text[start:end].replace("\n", " ").strip()
    if start > 0:
        snippet = "…" + snippet
    if end < len(text):
        snippet = snippet + "…"
    return snippet


def search(query: str, docs: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Case-insensitive substring search across all indexed markdown.

    Returns one entry per matching doc (max ``MAX_DOCS_PER_SEARCH``)
    with up to ``MAX_SNIPPETS_PER_DOC`` snippets per doc and the
    total match count for that doc.
    """
    query = query.strip()
    if not query:
        return []
    needle = query.lower()
    nlen = len(needle)
    results: list[dict[str, Any]] = []
    for d in docs:
        path = REPO_ROOT / d["path"]
        try:
            text = path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue
        lower = text.lower()
        # Find ALL occurrences (case-insensitive). Walking by index is
        # cheap; a pathologically large doc still finishes in <100ms.
        match_positions: list[int] = []
        i = 0
        while True:
            j = lower.find(needle, i)
            if j == -1:
                break
            match_positions.append(j)
            i = j + nlen
        if not match_positions:
            continue
        snippets = [
            _make_snippet(text, pos, nlen)
            for pos in match_positions[:MAX_SNIPPETS_PER_DOC]
        ]
        results.append(
            {
                "path": d["path"],
                "title": d["title"],
                "category": d["category"],
                "match_count": len(match_positions),
                "snippets": snippets,
                "snippet": snippets[0],
                "match_at": match_positions[0],
            }
        )
        if len(results) >= MAX_DOCS_PER_SEARCH:
            break
    # Sort by match-count desc so the densest hits surface first;
    # ties broken alphabetically by path for stability.
    results.sort(key=lambda r: (-r["match_count"], r["path"]))
    return results


# ----------------------------------------------------------------------
# Welcome (recent + latest changelog)
# ----------------------------------------------------------------------


def latest_changelog_entry() -> dict[str, str] | None:
    """Extract the most recent ## [version] section from CHANGELOG.md."""
    cl = REPO_ROOT / "CHANGELOG.md"
    if not cl.is_file():
        return None
    text = cl.read_text(encoding="utf-8")
    # Find the first `## [X.Y.Z]` heading after `## [Unreleased]`.
    sections = re.split(r"\n(?=## \[)", text)
    for section in sections:
        first_line = section.lstrip().split("\n", 1)[0]
        # Skip the Unreleased section.
        if first_line.startswith("## [Unreleased]"):
            continue
        m = re.match(r"## \[(\d+\.\d+\.\d+)\](?:\s*—\s*(\d{4}-\d{2}-\d{2}))?", first_line)
        if m:
            version = m.group(1)
            date = m.group(2) or ""
            # Trim section to ~80 lines so the welcome page doesn't drown.
            lines = section.split("\n")
            if len(lines) > 60:
                section = "\n".join(lines[:60]) + "\n\n*…(truncated; open CHANGELOG.md for the full entry)*"
            return {"version": version, "date": date, "content": section.strip()}
    return None


def recent_docs(docs: list[dict[str, Any]], limit: int = 8) -> list[dict[str, Any]]:
    """Return the N most recently-modified docs."""
    enriched = []
    for d in docs:
        path = REPO_ROOT / d["path"]
        try:
            mtime = path.stat().st_mtime
        except OSError:
            continue
        enriched.append({**d, "mtime": mtime})
    enriched.sort(key=lambda r: -r["mtime"])
    return enriched[:limit]


# ----------------------------------------------------------------------
# HTTP handler
# ----------------------------------------------------------------------


class DocsHandler(BaseHTTPRequestHandler):
    docs_index: list[dict[str, Any]] = []

    def _send_json(self, payload: Any, status: int = 200) -> None:
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        # `no-store` (not `no-cache`) — for a dev tool, "always fetch
        # fresh" is the contract. `no-cache` lets the browser cache and
        # revalidate; without ETag/Last-Modified that revalidation can
        # silently fall back to the cached copy in some browsers,
        # causing the "I edited the file but reload shows the old
        # version" confusion class.
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def _send_static(self, name: str, content_type: str) -> None:
        path = STATIC_DIR / name
        if not path.is_file():
            self.send_error(404, f"Not found: {name}")
            return
        body = path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        # `no-store` (not `no-cache`) — for a dev tool, "always fetch
        # fresh" is the contract. `no-cache` lets the browser cache and
        # revalidate; without ETag/Last-Modified that revalidation can
        # silently fall back to the cached copy in some browsers,
        # causing the "I edited the file but reload shows the old
        # version" confusion class.
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def _send_index_html(self) -> None:
        """Serve index.html with mtime-based cache-busting on style.css + app.js.

        Even with `Cache-Control: no-store`, some browsers (notably Chromium-
        based ones) hold an already-fetched stylesheet in the open tab's
        session memory and don't refetch on a normal reload — only on a hard
        reload (Ctrl-Shift-R). Result: edit style.css, reload, see no change.
        Diagnosed the hard way (the @media print rules in style.css were
        served correctly but the tab kept rendering the old CSS in print
        preview).

        Fix: rewrite the `<link href="/style.css">` and `<script src="/app.js">`
        references in index.html to include `?v=<mtime>` where mtime is the
        file's last-modified epoch second. Whenever style.css or app.js is
        touched on disk, the URL changes, and the browser is forced to
        treat it as a new resource.
        """
        index = STATIC_DIR / "index.html"
        if not index.is_file():
            self.send_error(404, "Not found: index.html")
            return
        html = index.read_text(encoding="utf-8")
        for asset in ("style.css", "app.js"):
            asset_path = STATIC_DIR / asset
            if asset_path.is_file():
                mtime = int(asset_path.stat().st_mtime)
                html = html.replace(f'href="/{asset}"', f'href="/{asset}?v={mtime}"')
                html = html.replace(f'src="/{asset}"', f'src="/{asset}?v={mtime}"')
        body = html.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self) -> None:  # noqa: N802 — stdlib API
        parsed = urlparse(self.path)
        path = parsed.path

        if path == "/" or path == "/index.html":
            self._send_index_html()
            return
        if path == "/style.css":
            self._send_static("style.css", "text/css; charset=utf-8")
            return
        if path == "/app.js":
            self._send_static("app.js", "application/javascript; charset=utf-8")
            return

        if path == "/api/index":
            # Re-scan on every request so docs added/edited mid-session
            # appear without a server restart. Cheap (a few hundred files).
            self.docs_index = scan_docs()
            self._send_json(
                {
                    "groups": grouped_index(self.docs_index),
                    "total": len(self.docs_index),
                }
            )
            return

        if path == "/api/doc":
            qs = parse_qs(parsed.query)
            doc_path = qs.get("path", [""])[0]
            if not doc_path:
                self._send_json({"error": "missing path"}, status=400)
                return
            # Path-traversal guard: reject anything that resolves outside
            # the repo root or contains any "..".
            if ".." in Path(doc_path).parts:
                self._send_json({"error": "path traversal denied"}, status=400)
                return
            full = (REPO_ROOT / doc_path).resolve()
            try:
                full.relative_to(REPO_ROOT)
            except ValueError:
                self._send_json({"error": "path outside repo"}, status=400)
                return
            if not full.is_file():
                self._send_json({"error": "not found"}, status=404)
                return
            try:
                raw = full.read_text(encoding="utf-8")
                stat = full.stat()
            except (OSError, UnicodeDecodeError) as e:
                self._send_json({"error": str(e)}, status=500)
                return
            frontmatter, content = parse_frontmatter(raw)
            self._send_json(
                {
                    "path": doc_path,
                    "title": frontmatter.get("title") or extract_title(content, full.stem),
                    "content": content,
                    "frontmatter": frontmatter,
                    "mtime": stat.st_mtime,
                    "size_bytes": stat.st_size,
                    "line_count": content.count("\n") + 1,
                    "category": categorize(doc_path),
                }
            )
            return

        if path == "/api/search":
            qs = parse_qs(parsed.query)
            query = qs.get("q", [""])[0]
            if not self.docs_index:
                self.docs_index = scan_docs()
            self._send_json({"results": search(query, self.docs_index)})
            return

        if path == "/api/welcome":
            if not self.docs_index:
                self.docs_index = scan_docs()
            self._send_json(
                {
                    "recent": recent_docs(self.docs_index),
                    "changelog": latest_changelog_entry(),
                    "total_docs": len(self.docs_index),
                }
            )
            return

        self.send_error(404, f"Not found: {path}")

    def log_message(self, fmt: str, *args: Any) -> None:
        """Quieter logging — one line per request, no header noise."""
        sys.stderr.write(f"[{self.log_date_time_string()}] {fmt % args}\n")


# ----------------------------------------------------------------------
# Entry point
# ----------------------------------------------------------------------


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    parser.add_argument("--port", type=int, default=8765, help="Port to bind (default: 8765)")
    parser.add_argument("--no-open", action="store_true", help="Don't auto-open the browser")
    args = parser.parse_args()

    addr = ("127.0.0.1", args.port)
    url = f"http://{addr[0]}:{addr[1]}/"

    # Pre-scan once at startup so the first request is fast.
    DocsHandler.docs_index = scan_docs()
    print(f"Indexed {len(DocsHandler.docs_index)} markdown files under {REPO_ROOT}")
    print(f"Serving on {url}  (Ctrl+C to stop)")

    server = HTTPServer(addr, DocsHandler)
    if not args.no_open:
        threading.Timer(0.5, lambda: webbrowser.open(url)).start()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nShutting down.")
        server.server_close()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
