#!/usr/bin/env python3
"""
Teaching project viewer — serves the project tree as a mobile-readable,
markdown-rendered website on the tailnet.

Run:
    python3 viewer/serve.py

Override host/port via env:
    VIEWER_HOST=0.0.0.0 VIEWER_PORT=8889 python3 viewer/serve.py
"""
import html
import mimetypes
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import quote, unquote

from markdown_it import MarkdownIt
from pygments import highlight as pyg_highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.util import ClassNotFound

ROOT = Path(__file__).resolve().parent.parent
HOST = os.environ.get("VIEWER_HOST", "100.83.250.41")
PORT = int(os.environ.get("VIEWER_PORT", "8889"))

SKIP_DIRS = {".git", "__pycache__", "node_modules", ".qdrant", ".venv", "venv", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
SKIP_FILES = {".DS_Store"}
SKIP_EXTS = {".pyc", ".pyo", ".webm", ".mov", ".mp4"}

# Pygments syntax highlighting for fenced code blocks in markdown
try:
    _pyg_formatter = HtmlFormatter(style="github-light", cssclass="highlight", nobackground=True)
except Exception:
    _pyg_formatter = HtmlFormatter(style="default", cssclass="highlight", nobackground=True)
PYGMENTS_CSS = _pyg_formatter.get_style_defs(".highlight")


def _highlight_code(code: str, lang: str, attrs: str) -> str:
    try:
        if lang:
            lexer = get_lexer_by_name(lang, stripall=True)
        else:
            lexer = guess_lexer(code)
    except (ClassNotFound, ValueError):
        return ""  # fall through to markdown-it default
    return pyg_highlight(code, lexer, _pyg_formatter)


md_renderer = MarkdownIt(
    "commonmark",
    {"html": False, "linkify": True, "typographer": True, "highlight": _highlight_code},
).enable(["table", "strikethrough"])

GOOGLE_FONTS = (
    '<link rel="preconnect" href="https://fonts.googleapis.com">'
    '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
    '<link href="https://fonts.googleapis.com/css2?'
    'family=EB+Garamond:ital,wght@0,400;0,500;1,400&'
    'family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">'
)

CSS = """
:root {
  --color-parchment: #eeeee7;
  --color-midnight-ink: #000;
  --color-internet-blue: #0c50ff;
  --font-heading: 'EB Garamond', Garamond, "Times New Roman", serif;
  --font-body: ui-serif, Georgia, "Times New Roman", serif;
  --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  --tracking-mono: 0.05em;
  --section-gap: 40px;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--color-parchment); color: var(--color-midnight-ink); }
body {
  font-family: var(--font-body);
  font-size: 17px;
  line-height: 1.55;
  padding: 24px 20px 80px;
  -webkit-text-size-adjust: 100%;
}
.wrap { max-width: 760px; margin: 0 auto; }
header.crumbs {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: var(--tracking-mono);
  margin-bottom: var(--section-gap);
  word-break: break-all;
}
header.crumbs a { color: var(--color-midnight-ink); text-decoration: none; border-bottom: 1px solid currentColor; }
header.crumbs a:hover, header.crumbs a:active { color: var(--color-internet-blue); border-bottom-color: var(--color-internet-blue); }
header.crumbs .sep { padding: 0 6px; opacity: 0.4; }
header.crumbs strong { font-weight: 500; }
ul.listing { list-style: none; padding: 0; margin: 0; }
ul.listing li {
  font-family: var(--font-mono);
  font-size: 14px;
  letter-spacing: var(--tracking-mono);
  border-bottom: 1px solid #00000018;
}
ul.listing li a {
  display: block;
  padding: 14px 6px;
  color: var(--color-midnight-ink);
  text-decoration: none;
}
ul.listing li a:hover, ul.listing li a:active { color: var(--color-internet-blue); }
ul.listing li.dir a { font-weight: 500; }
ul.listing li .meta { font-size: 11px; opacity: 0.5; margin-left: 10px; letter-spacing: 0; }
ul.listing li.dir a::before { content: "▸ "; opacity: 0.6; }
ul.listing li.file a::before { content: "  "; }
article h1, article h2, article h3, article h4, article h5, article h6 {
  font-family: var(--font-heading);
  font-weight: 400;
  line-height: 1.2;
  margin-top: var(--section-gap);
  margin-bottom: 14px;
}
article h1 { font-size: 36px; margin-top: 0; }
article h2 { font-size: 28px; }
article h3 { font-size: 22px; }
article h4 { font-size: 19px; }
article h5, article h6 { font-size: 17px; font-style: italic; }
article p, article li { font-family: var(--font-body); }
article a { color: var(--color-midnight-ink); border-bottom: 1px solid currentColor; text-decoration: none; }
article a:hover, article a:active { color: var(--color-internet-blue); border-bottom-color: var(--color-internet-blue); }
article blockquote {
  margin: 28px 0;
  padding: 4px 0 4px 18px;
  border-left: 2px solid var(--color-midnight-ink);
  font-style: italic;
}
article blockquote p { margin: 8px 0; }
article code {
  font-family: var(--font-mono);
  font-size: 0.88em;
  background: #00000010;
  padding: 1px 5px;
  letter-spacing: 0;
}
article pre {
  background: #00000010;
  padding: 14px;
  overflow-x: auto;
  font-size: 13px;
  line-height: 1.45;
}
article pre code { background: transparent; padding: 0; font-size: inherit; }
article table { border-collapse: collapse; width: 100%; margin: 24px 0; font-size: 14px; }
article th, article td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #00000020; vertical-align: top; }
article th { font-family: var(--font-mono); font-weight: 500; letter-spacing: var(--tracking-mono); font-size: 11px; text-transform: uppercase; }
article hr { border: 0; border-top: 1px solid #00000040; margin: var(--section-gap) 0; }
article ul, article ol { padding-left: 24px; }
article img { max-width: 100%; height: auto; }
pre.raw {
  background: transparent;
  font-family: var(--font-mono);
  font-size: 13px;
  white-space: pre-wrap;
  word-break: break-word;
  letter-spacing: 0;
  margin: 0;
}
@media (max-width: 600px) {
  body { padding: 16px 14px 60px; font-size: 16px; }
  article h1 { font-size: 28px; }
  article h2 { font-size: 24px; }
  article h3 { font-size: 20px; }
  ul.listing li a { padding: 16px 4px; }
}
"""


def is_hidden(name: str) -> bool:
    if name in SKIP_FILES or name in SKIP_DIRS:
        return True
    if name.startswith("."):
        return name not in {".gitignore", ".gitattributes", ".env.example"}
    return False


def format_size(n: int) -> str:
    if n < 1024:
        return f"{n}B"
    if n < 1024 * 1024:
        return f"{n / 1024:.0f}K"
    if n < 1024 * 1024 * 1024:
        return f"{n / (1024 * 1024):.1f}M"
    return f"{n / (1024 * 1024 * 1024):.1f}G"


def crumbs_html(rel_path: Path, file_name: str | None = None) -> str:
    parts = ['<a href="/">teaching</a>']
    accum = ""
    pieces = [] if str(rel_path) == "." else list(rel_path.parts)
    for piece in pieces:
        accum = f"{accum}/{piece}" if accum else f"/{piece}"
        parts.append(f'<a href="{quote(accum)}/">{html.escape(piece)}</a>')
    if file_name is not None:
        parts.append(f'<strong>{html.escape(file_name)}</strong>')
    return '<header class="crumbs">' + '<span class="sep">/</span>'.join(parts) + '</header>'


def listing_html(target: Path) -> str:
    rows = []
    try:
        entries = sorted(target.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
    except PermissionError:
        return '<p><em>permission denied</em></p>'
    for p in entries:
        if is_hidden(p.name):
            continue
        rel = p.relative_to(ROOT)
        if p.is_dir():
            url = "/" + quote(str(rel)) + "/"
            try:
                count = sum(1 for x in p.iterdir() if not is_hidden(x.name))
            except PermissionError:
                count = 0
            meta = f'<span class="meta">{count}</span>'
            rows.append(f'<li class="dir"><a href="{url}">{html.escape(p.name)}/{meta}</a></li>')
        else:
            if p.suffix.lower() in SKIP_EXTS:
                continue
            url = "/" + quote(str(rel))
            try:
                meta = f'<span class="meta">{format_size(p.stat().st_size)}</span>'
            except OSError:
                meta = ''
            rows.append(f'<li class="file"><a href="{url}">{html.escape(p.name)}{meta}</a></li>')
    if not rows:
        return '<p><em>(empty)</em></p>'
    return '<ul class="listing">' + "\n".join(rows) + '</ul>'


def page_html(title: str, body: str, crumbs: str, with_highlight: bool = False) -> bytes:
    extra_css = f'<style>{PYGMENTS_CSS}</style>' if with_highlight else ''
    return (
        f'<!doctype html>\n<html lang="en"><head>'
        f'<meta charset="utf-8">'
        f'<meta name="viewport" content="width=device-width,initial-scale=1">'
        f'<title>{html.escape(title)}</title>'
        f'{GOOGLE_FONTS}'
        f'<style>{CSS}</style>'
        f'{extra_css}'
        f'</head><body><div class="wrap">{crumbs}{body}</div></body></html>'
    ).encode("utf-8")


def find_hymn() -> Path | None:
    """Locate the most-recently-modified web/hymn.html in the project tree."""
    candidates = []
    for dirpath, dirnames, filenames in os.walk(ROOT):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
        if Path(dirpath).name == "web" and "hymn.html" in filenames:
            candidates.append(Path(dirpath) / "hymn.html")
    if not candidates:
        return None
    return max(candidates, key=lambda p: p.stat().st_mtime)


class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        # quieter logs — just method + path + status
        print(f"{self.command} {self.path} → {args[1] if len(args) > 1 else '?'}")

    def do_GET(self):
        try:
            self._handle()
        except FileNotFoundError:
            self.send_error(404, "not found")
        except PermissionError:
            self.send_error(403, "forbidden")

    def _handle(self):
        raw = unquote(self.path).split("?", 1)[0].split("#", 1)[0]

        # /hymn.html alias → 302 to the actual web/hymn.html (so relative
        # asset URLs in the page resolve correctly at the file's real location)
        if raw == "/hymn.html":
            target = find_hymn()
            if target is None:
                self.send_error(404, "no web/hymn.html in project")
                return
            rel = target.relative_to(ROOT)
            self.send_response(302)
            self.send_header("Location", "/" + quote(str(rel)))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            return

        rel = raw.lstrip("/")
        target = (ROOT / rel).resolve()

        # path traversal guard
        if target != ROOT and ROOT not in target.parents:
            self.send_error(403, "outside root")
            return
        if not target.exists():
            self.send_error(404, "not found")
            return

        if target.is_dir():
            self._serve_dir(target)
        else:
            self._serve_file(target)

    def _serve_dir(self, target: Path):
        rel = target.relative_to(ROOT)
        title = "teaching" if str(rel) == "." else f"teaching / {rel}"
        crumbs = crumbs_html(rel) if str(rel) != "." else '<header class="crumbs"><strong>teaching/</strong></header>'
        body = listing_html(target)
        self._respond(200, "text/html; charset=utf-8", page_html(title, body, crumbs))

    def _serve_file(self, target: Path):
        ext = target.suffix.lower()
        if ext in SKIP_EXTS:
            self.send_error(403, "skipped extension")
            return
        rel = target.relative_to(ROOT)

        if ext == ".md":
            text = target.read_text(encoding="utf-8", errors="replace")
            body = '<article>' + md_renderer.render(text) + '</article>'
            crumbs = crumbs_html(rel.parent, file_name=target.name)
            self._respond(200, "text/html; charset=utf-8", page_html(target.name, body, crumbs, with_highlight=True))
            return

        # HTML files render as actual pages so hymn.html and friends work.
        if ext in {".html", ".htm"}:
            data = target.read_bytes()
            self._respond(200, "text/html; charset=utf-8", data)
            return

        # Everything else: serve raw with the proper MIME. Browsers will render
        # text/* inline as plain text, parse text/css as CSS, etc. Audio gets
        # Range support so iOS Safari can stream/seek.
        mime, _ = mimetypes.guess_type(str(target))
        mime = mime or "application/octet-stream"
        self._serve_raw(target, mime)

    def _serve_raw(self, target: Path, mime: str):
        size = target.stat().st_size
        rng = self.headers.get("Range", "")
        if rng.startswith("bytes="):
            try:
                a, b = rng[6:].split("-", 1)
                start = int(a) if a else 0
                end = int(b) if b else size - 1
                end = min(end, size - 1)
                if start <= end:
                    with target.open("rb") as f:
                        f.seek(start)
                        chunk = f.read(end - start + 1)
                    self.send_response(206)
                    self.send_header("Content-Type", mime)
                    self.send_header("Content-Length", str(len(chunk)))
                    self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
                    self.send_header("Accept-Ranges", "bytes")
                    self.send_header("Cache-Control", "no-store")
                    self.end_headers()
                    self.wfile.write(chunk)
                    return
            except (ValueError, OSError):
                pass
        data = target.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", mime)
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Accept-Ranges", "bytes")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(data)

    def _respond(self, status: int, content_type: str, body: bytes):
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)


def main():
    print(f"viewer: serving {ROOT}")
    print(f"viewer: http://{HOST}:{PORT}/")
    httpd = ThreadingHTTPServer((HOST, PORT), Handler)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\nviewer: stopped")
        httpd.server_close()


if __name__ == "__main__":
    main()
