#!/usr/bin/env python3
"""Local authoring UI for the Org published website."""

from __future__ import annotations

import json
import os
import posixpath
import re
import struct
import subprocess
import sys
import threading
import time
import cgi
from dataclasses import dataclass, field
from datetime import datetime
from email.utils import formatdate
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse


def looks_like_content_root(path: Path) -> bool:
    return (path / "authoring_server.py").exists() and (
        (path / "blogs").exists()
        or (path / "posts").exists()
        or (path / "lima").exists()
    )


def resolve_root() -> Path:
    env_root = os.environ.get("AUTHOR_ROOT")
    if env_root:
        resolved = Path(env_root).expanduser().resolve()
        if looks_like_content_root(resolved):
            return resolved
    candidates = [
        Path.cwd(),
        Path(__file__).resolve().parent,
    ]
    workspace = os.environ.get("GITHUB_WORKSPACE")
    if workspace:
        candidates.insert(0, Path(workspace))
    for base in list(candidates):
        candidates.extend(base.parents)
    seen = set()
    for candidate in candidates:
        resolved = candidate.expanduser().resolve()
        if resolved in seen:
            continue
        seen.add(resolved)
        if looks_like_content_root(resolved):
            return resolved
    return Path(__file__).resolve().parent


ROOT = resolve_root()
BLOGS_DIR = ROOT / "blogs"
POSTS_DIR = ROOT / "posts"
LIMA_DIR = ROOT / "lima"
IMAGE_ASSETS_DIR = ROOT / "assets" / "images"
HZONE_ASSETS_DIR = IMAGE_ASSETS_DIR / "hzone"
EXCLUDED_CONTENT_DIR_NAMES = {
    ".agents",
    ".codex",
    ".git",
    ".packages",
    ".venv",
    "__pycache__",
    "assets",
    "backups",
    "output",
    "tags",
}
GENERATED_ORG_NAMES = {
    "blogs-list.org",
    "books-list.org",
    "posts-list.org",
    "career-list.org",
    "sitemap.org",
    "recently-updated.org",
    "wip.org",
}
GENERATED_CONTENT_NAMES = GENERATED_ORG_NAMES | {"lima-list.org"}
ALLOWED_UPLOAD_EXTENSIONS = {
    ".png",
    ".jpg",
    ".jpeg",
    ".gif",
    ".webp",
    ".svg",
}
MONTH_NAMES = [
    "january",
    "february",
    "march",
    "april",
    "may",
    "june",
    "july",
    "august",
    "september",
    "october",
    "november",
    "december",
]


def slugify(value: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
    return slug or "untitled"


def normalise_tags(value: Any) -> list[str]:
    if isinstance(value, str):
        raw = re.split(r"[,:\s]+", value)
    elif isinstance(value, list):
        raw = [str(item) for item in value]
    else:
        raw = []
    tags = []
    for tag in raw:
        if not tag.strip():
            continue
        clean = slugify(tag)
        if clean and clean not in tags:
            tags.append(clean)
    return tags


def org_date(dt: datetime) -> str:
    return dt.strftime("<%Y-%m-%d %a %H:%M>")


def parse_org_datetime(value: str | None) -> datetime | None:
    if not value:
        return None
    match = re.search(r"<(\d{4})-(\d{2})-(\d{2})(?:\s+\w+)?(?:\s+(\d{2}):(\d{2}))?>", value)
    if not match:
        return None
    year, month, day, hour, minute = match.groups()
    return datetime(
        int(year),
        int(month),
        int(day),
        int(hour or 12),
        int(minute or 0),
    )


def html_escape(value: str) -> str:
    return (
        value.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
    )


@dataclass
class OrgPage:
    path: str
    page_type: str
    title: str
    slug: str
    tags: list[str]
    content: str
    date: str
    comments: bool
    options: str
    wip: str | None = None


@dataclass
class ContentPage:
    path: str
    page_type: str
    title: str
    slug: str
    tags: list[str]
    content: str
    date: str
    comments: bool
    options: str
    format: str
    wip: str | None = None


@dataclass
class BuildJob:
    id: int
    path: str
    title: str
    queued_at: float = field(default_factory=time.time)
    started_at: float | None = None
    finished_at: float | None = None
    ok: bool | None = None
    message: str = "Queued"
    log: str = ""

    @property
    def status(self) -> str:
        if self.finished_at is not None:
            return "done" if self.ok else "failed"
        if self.started_at is not None:
            return "running"
        return "queued"

    def to_dict(self, include_log: bool = False) -> dict[str, Any]:
        data = {
            "id": self.id,
            "path": self.path,
            "title": self.title,
            "queuedAt": self.queued_at,
            "startedAt": self.started_at,
            "finishedAt": self.finished_at,
            "ok": self.ok,
            "status": self.status,
            "message": self.message,
        }
        if include_log:
            data["log"] = self.log[-12000:]
        return data


class BuildQueue:
    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._next_id = 1
        self._pending: list[BuildJob] = []
        self._current: BuildJob | None = None
        self._recent: list[BuildJob] = []
        self._worker: threading.Thread | None = None

    def snapshot(self) -> dict[str, Any]:
        with self._lock:
            current = self._current.to_dict(include_log=True) if self._current else None
            recent = [job.to_dict(include_log=True) for job in self._recent[-10:]]
            pending = [job.to_dict() for job in self._pending]
            latest = current or (recent[-1] if recent else None)
            message = latest["message"] if latest else "No builds have run yet."
            return {
                "running": current is not None,
                "queued": len(pending),
                "message": message,
                "current": current,
                "pending": pending,
                "recent": recent,
                "log": latest.get("log", "") if latest else "",
            }

    def enqueue(self, path: str, title: str) -> BuildJob:
        with self._lock:
            job = BuildJob(self._next_id, path, title)
            self._next_id += 1
            self._pending.append(job)
            if self._worker is None or not self._worker.is_alive():
                self._worker = threading.Thread(target=self._run_worker, daemon=True)
                self._worker.start()
            return job

    def _run_worker(self) -> None:
        while True:
            with self._lock:
                if not self._pending:
                    self._current = None
                    return
                job = self._pending.pop(0)
                job.started_at = time.time()
                job.message = "Publishing site and search index."
                self._current = job
            ok, message, log = run_build_commands()
            with self._lock:
                job.finished_at = time.time()
                job.ok = ok
                job.message = message
                job.log = log
                self._recent.append(job)
                self._recent = self._recent[-20:]
                self._current = None


BUILD_QUEUE = BuildQueue()


def safe_relative_path(path: str) -> Path:
    rel = Path(path)
    if rel.is_absolute() or ".." in rel.parts:
        raise ValueError("Path must stay inside this repository.")
    full = (ROOT / rel).resolve()
    if not full.is_relative_to(ROOT):
        raise ValueError("Path must stay inside this repository.")
    if full.name in GENERATED_CONTENT_NAMES or "sync-conflict" in full.name:
        raise ValueError("Generated and sync-conflict files are not editable here.")
    rel_parts = full.relative_to(ROOT).parts
    if any(part in EXCLUDED_CONTENT_DIR_NAMES for part in rel_parts):
        raise ValueError("This path is outside the editable content folders.")
    if full.suffix == ".org":
        return full
    if full.suffix == ".md" and full.is_relative_to(LIMA_DIR):
        return full
    raise ValueError("Only .org content files and .md files under lima can be edited here.")


def safe_target_path(path: str, slug: str, page_type: str) -> Path:
    candidate = path.strip()
    if not candidate:
        raise ValueError("Path is required.")
    default_ext = ".md" if page_type == "lima" else ".org"
    if candidate.endswith("/"):
        candidate = f"{candidate}{slug}{default_ext}"
    elif not Path(candidate).suffix:
        candidate = f"{candidate}{default_ext}"
    return safe_relative_path(candidate)


def markdown_title(content: str, fallback: str) -> str:
    for line in content.splitlines():
        match = re.match(r"^#{1,6}\s+(.+?)\s*$", line)
        if match:
            return match.group(1).strip()
    return fallback.replace("-", " ").replace("_", " ").title()


def read_markdown_page(path: Path) -> ContentPage:
    content = path.read_text(encoding="utf-8")
    rel = path.relative_to(ROOT).as_posix()
    title = markdown_title(content, path.stem)
    return ContentPage(
        path=rel,
        page_type="lima",
        title=title,
        slug=path.stem,
        tags=[],
        content=content,
        date="",
        comments=True,
        options="",
        format="markdown",
    )


def read_page(path: Path) -> ContentPage:
    if path.suffix == ".md" and path.is_relative_to(LIMA_DIR):
        return read_markdown_page(path)
    text = path.read_text(encoding="utf-8")
    meta: dict[str, str] = {}
    body_lines: list[str] = []
    in_header = True
    for line in text.splitlines():
        if in_header and line.startswith("#+"):
            key, _, value = line[2:].partition(":")
            meta[key.strip().upper()] = value.strip()
        else:
            in_header = False
            body_lines.append(line)
    rel = path.relative_to(ROOT).as_posix()
    if path.is_relative_to(BLOGS_DIR):
        page_type = "blog"
    elif path.is_relative_to(POSTS_DIR):
        page_type = "post"
    else:
        page_type = "page"
    slug = meta.get("SLUG") or path.stem
    tags = normalise_tags(meta.get("FILETAGS", ""))
    return ContentPage(
        path=rel,
        page_type=page_type,
        title=meta.get("TITLE", path.stem),
        slug=slug,
        tags=tags,
        content="\n".join(body_lines).lstrip("\n"),
        date=meta.get("DATE", org_date(datetime.fromtimestamp(path.stat().st_mtime))),
        comments=meta.get("COMMENTS", "t").lower() == "t",
        options=meta.get("OPTIONS", "num:nil"),
        format="org",
        wip=meta.get("WIP"),
    )


def page_to_dict(page: ContentPage) -> dict[str, Any]:
    return {
        "path": page.path,
        "pageType": page.page_type,
        "title": page.title,
        "slug": page.slug,
        "tags": page.tags,
        "content": page.content,
        "date": page.date,
        "comments": page.comments,
        "options": page.options,
        "format": page.format,
        "wip": page.wip or "",
    }


def server_diagnostics() -> dict[str, Any]:
    pages = list_pages()
    try:
        cwd = Path.cwd().as_posix()
    except OSError as exc:
        cwd = f"<unavailable: {exc}>"
    return {
        "root": ROOT.as_posix(),
        "cwd": cwd,
        "executable": sys.executable,
        "pid": os.getpid(),
        "pageCount": len(pages),
        "firstPage": pages[0]["path"] if pages else "",
    }


def list_pages() -> list[dict[str, Any]]:
    pages = []
    org_paths = []
    if ROOT.exists():
        try:
            for path in ROOT.rglob("*.org"):
                try:
                    rel_parts = path.relative_to(ROOT).parts
                except ValueError:
                    continue
                if any(part in EXCLUDED_CONTENT_DIR_NAMES for part in rel_parts):
                    continue
                org_paths.append(path)
        except OSError:
            org_paths = []
    try:
        md_paths = list(LIMA_DIR.rglob("*.md")) if LIMA_DIR.exists() else []
    except OSError:
        md_paths = []
    for path in sorted(org_paths + md_paths):
        if path.name in GENERATED_CONTENT_NAMES or "sync-conflict" in path.name:
            continue
        try:
            page = read_page(path)
            parsed = parse_org_datetime(page.date)
            timestamp = parsed.timestamp() if parsed else path.stat().st_mtime
        except (OSError, UnicodeDecodeError, ValueError):
            continue
        pages.append(
            {
                "path": page.path,
                "pageType": page.page_type,
                "title": page.title,
                "slug": page.slug,
                "tags": page.tags,
                "date": page.date,
                "format": page.format,
                "timestamp": timestamp,
            }
        )
    return sorted(pages, key=lambda item: item["timestamp"], reverse=True)


def target_path(data: dict[str, Any], existing_path: str | None) -> Path:
    if existing_path:
        return safe_relative_path(existing_path)
    title = str(data.get("title") or "").strip()
    slug = slugify(str(data.get("slug") or title))
    page_type = str(data.get("pageType") or "blog")
    explicit_path = str(data.get("targetPath") or "").strip()
    if explicit_path:
        return safe_target_path(explicit_path, slug, page_type)
    if page_type == "blog":
        dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now()
        folder = BLOGS_DIR / str(dt.year) / f"{dt.month:02d}-{MONTH_NAMES[dt.month - 1]}"
        return folder / f"{slug}.org"
    if page_type == "post":
        raw_section = str(data.get("section") or "").strip()
        section = slugify(raw_section) if raw_section else ""
        folder = POSTS_DIR / section if section else POSTS_DIR
        return folder / f"{slug}.org"
    if page_type == "lima":
        return LIMA_DIR / f"{slug}.md"
    if page_type == "page":
        return ROOT / f"{slug}.org"
    raise ValueError("pageType must be blog, post, page, or lima.")


def render_markdown(data: dict[str, Any]) -> str:
    content = str(data.get("content") or "").replace("\r\n", "\n").strip()
    title = str(data.get("title") or "").strip()
    if not title:
        raise ValueError("Title is required.")
    content = re.sub(
        r'<a\b[^>]*>\s*<img\b[^>]*\bsrc="([^"]+)"[^>]*\balt="([^"]*)"[^>]*>\s*</a>',
        lambda match: f"![{match.group(2)}]({match.group(1)})",
        content,
        flags=re.IGNORECASE,
    )
    if content:
        if re.search(r"^#{1,6}[^\S\r\n]+.+?[^\S\r\n]*$", content, flags=re.MULTILINE):
            content = re.sub(
                r"^#{1,6}[^\S\r\n]+.+?[^\S\r\n]*$",
                f"# {title}",
                content,
                count=1,
                flags=re.MULTILINE,
            )
        else:
            content = f"# {title}\n\n{content}"
        return content + "\n"
    return f"# {title}\n"


def render_org(data: dict[str, Any], previous: ContentPage | None) -> str:
    title = str(data.get("title") or "").strip()
    if not title:
        raise ValueError("Title is required.")
    slug = slugify(str(data.get("slug") or title))
    tags = normalise_tags(data.get("tags", []))
    content = str(data.get("content") or "").replace("\r\n", "\n").strip()
    date = str(data.get("date") or "").strip()
    if not parse_org_datetime(date):
        date = previous.date if previous else org_date(datetime.now())
    options = str(data.get("options") or (previous.options if previous else "num:nil")).strip()
    comments = bool(data.get("comments", True))
    lines = [
        f"#+TITLE: {title}",
        f"#+OPTIONS: {options}",
        f"#+DATE: {date}",
        f"#+filetags: {''.join(f':{tag}' for tag in tags)}:",
    ]
    wip = str(data.get("wip") or (previous.wip if previous else "") or "").strip()
    if wip:
        lines.append(f"#+WIP: {wip}")
    lines.extend(
        [
            f"#+COMMENTS: {'t' if comments else ''}",
            f"#+SLUG: {slug}",
            "",
            content,
            "",
        ]
    )
    return "\n".join(lines)


def save_page(data: dict[str, Any]) -> dict[str, Any]:
    existing_path = data.get("path") or None
    target = target_path(data, str(existing_path) if existing_path else None)
    previous = read_page(target) if target.exists() else None
    if not target.parent.exists():
        target.parent.mkdir(parents=True)
    if target.exists() and not existing_path:
        raise ValueError(f"{target.relative_to(ROOT)} already exists.")
    if target.suffix == ".md":
        target.write_text(render_markdown(data), encoding="utf-8")
    else:
        target.write_text(render_org(data, previous), encoding="utf-8")
    return page_to_dict(read_page(target))


def image_dimensions(payload: bytes, ext: str) -> tuple[int, int] | None:
    if ext == ".png" and payload.startswith(b"\x89PNG\r\n\x1a\n") and len(payload) >= 24:
        width, height = struct.unpack(">II", payload[16:24])
        return width, height
    if ext == ".gif" and payload[:6] in {b"GIF87a", b"GIF89a"} and len(payload) >= 10:
        width, height = struct.unpack("<HH", payload[6:10])
        return width, height
    if ext in {".jpg", ".jpeg"} and payload.startswith(b"\xff\xd8"):
        i = 2
        while i + 9 < len(payload):
            if payload[i] != 0xFF:
                i += 1
                continue
            marker = payload[i + 1]
            i += 2
            if marker in {0xD8, 0xD9}:
                continue
            if i + 2 > len(payload):
                break
            size = int.from_bytes(payload[i:i + 2], "big")
            if size < 2:
                break
            if marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
                if i + 7 <= len(payload):
                    height = int.from_bytes(payload[i + 3:i + 5], "big")
                    width = int.from_bytes(payload[i + 5:i + 7], "big")
                    return width, height
                break
            i += size
    return None


def relative_asset_path(page_path: str, asset_path: str) -> str:
    page = safe_relative_path(page_path) if page_path else ROOT / "index.org"
    page_rel = page.relative_to(ROOT).as_posix()
    page_output_dir = posixpath.dirname(page_rel)
    return posixpath.relpath(asset_path, page_output_dir or ".")


def gallery_image_html(filename: str, asset_path: str, page_path: str, payload: bytes, ext: str) -> str:
    absolute_url = f"https://zainezq.com/{asset_path}"
    relative_url = relative_asset_path(page_path, asset_path)
    dims = image_dimensions(payload, ext)
    width, height = dims if dims else (1920, 1080)
    alt = html_escape(filename)
    return (
        f'<a href="{absolute_url}" data-img="{absolute_url}" data-alt="{alt}" '
        f'data-width="{width}" data-height="{height}">'
        f'<img src="{relative_url}" alt="{alt}" style="cursor: zoom-in;"></a>'
    )


def attachment_image_dir(page_path: str) -> Path:
    page = safe_relative_path(page_path) if page_path else ROOT / "index.org"
    if page.suffix == ".md" and page.is_relative_to(LIMA_DIR):
        return HZONE_ASSETS_DIR
    if page.is_relative_to(POSTS_DIR):
        rel = page.relative_to(POSTS_DIR)
        section = rel.parts[0] if len(rel.parts) > 1 else "posts"
        return IMAGE_ASSETS_DIR / slugify(section)
    if page.is_relative_to(BLOGS_DIR):
        return IMAGE_ASSETS_DIR / "blogs"
    rel = page.relative_to(ROOT)
    if len(rel.parts) > 1:
        return IMAGE_ASSETS_DIR / slugify(rel.parts[0])
    return IMAGE_ASSETS_DIR / "pages"


def save_upload(filename: str, payload: bytes, page_path: str = "") -> dict[str, str]:
    original = Path(filename or "attachment").name
    ext = Path(original).suffix.lower()
    if ext not in ALLOWED_UPLOAD_EXTENSIONS:
        raise ValueError("Only common image files can be uploaded.")
    now = datetime.now()
    target_dir = attachment_image_dir(page_path)
    target_dir.mkdir(parents=True, exist_ok=True)
    stem = slugify(Path(original).stem)
    prefix = "" if re.match(r"^\d{4}-\d{2}-\d{2}-", stem) else f"{now.strftime('%Y-%m-%d')}-"
    target = target_dir / f"{prefix}{stem}{ext}"
    counter = 2
    while target.exists():
        target = target_dir / f"{prefix}{stem}-{counter}{ext}"
        counter += 1
    target.write_bytes(payload)
    rel = target.relative_to(ROOT).as_posix()
    absolute_url = f"https://zainezq.com/{rel}"
    relative_url = relative_asset_path(page_path, rel)
    is_markdown = page_path.endswith(".md")
    insert_text = f"![{target.name}]({relative_url})" if is_markdown else f"[[{relative_url}]]"
    return {
        "url": absolute_url,
        "relativeUrl": relative_url,
        "path": rel,
        "markdown": insert_text,
        "insertText": insert_text,
        "filename": target.name,
    }


def run_build_commands() -> tuple[bool, str, str]:
    venv_python = ROOT / ".venv" / "bin" / "python"
    venv_pip = ROOT / ".venv" / "bin" / "pip"
    commands = [["emacs", "-Q", "--script", "build-site.el"]]
    if not venv_python.exists():
        commands.extend(
            [
                [sys.executable, "-m", "venv", ".venv"],
                [str(venv_pip), "install", "-r", "requirements.txt"],
            ]
        )
    commands.append([str(venv_python), "search-index-json.py"])
    combined = []
    ok = True
    for command in commands:
        combined.append(f"$ {' '.join(command)}\n")
        proc = subprocess.run(
            command,
            cwd=ROOT,
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
        )
        combined.append(proc.stdout)
        if proc.returncode != 0:
            ok = False
            combined.append(f"\nCommand exited with {proc.returncode}.\n")
            break
    message = "Build complete. The site output and search index were regenerated." if ok else "Build failed. Check the log below."
    return ok, message, "".join(combined)


def queue_build(page: dict[str, Any]) -> dict[str, Any]:
    return BUILD_QUEUE.enqueue(page["path"], page["title"]).to_dict()


APP_HTML = r"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Org Site Authoring</title>
  <style>
    :root { color-scheme: light; --bg: #f6f3ed; --panel: #fffdf8; --ink: #222; --muted: #64615b; --line: #d8d1c3; --accent: #18615b; --accent-2: #8f3f2b; }
    * { box-sizing: border-box; }
    body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--ink); }
    button, input, textarea, select { font: inherit; }
    button { border: 1px solid var(--line); background: #fff; color: var(--ink); min-height: 38px; padding: 0 12px; border-radius: 6px; cursor: pointer; }
    button.primary { background: var(--accent); color: white; border-color: var(--accent); }
    button.icon { min-width: 38px; padding: 0 10px; font-weight: 700; }
    .app { min-height: 100vh; display: grid; grid-template-columns: minmax(260px, 360px) 1fr; }
    aside { border-right: 1px solid var(--line); background: #eee8dc; padding: 18px; overflow: auto; max-height: 100vh; }
    main { padding: 20px clamp(18px, 3vw, 42px); overflow: auto; max-height: 100vh; }
    .topbar { display: flex; align-items: center; gap: 10px; justify-content: space-between; margin-bottom: 16px; }
    h1 { font-size: 20px; margin: 0; font-weight: 700; }
    h2 { font-size: 15px; margin: 18px 0 8px; color: var(--muted); font-weight: 700; }
    .status { border: 1px solid var(--line); background: var(--panel); padding: 10px 12px; border-radius: 6px; font-size: 14px; margin-bottom: 14px; }
    .status.running { border-color: var(--accent-2); }
    .status.ok { border-color: var(--accent); }
    .status.fail { border-color: #a12a2a; }
    .quick-link { display: inline-block; color: var(--accent); font-size: 13px; margin: -6px 0 14px; }
    .filters { display: grid; gap: 8px; margin-bottom: 14px; }
    .page-list { display: grid; gap: 7px; }
    .page-item { width: 100%; text-align: left; min-height: auto; padding: 9px 10px; background: var(--panel); }
    .page-item strong { display: block; font-size: 14px; margin-bottom: 2px; overflow-wrap: anywhere; }
    .page-item span { display: block; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
    form { display: grid; gap: 14px; }
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
    .toolbar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; border: 1px solid var(--line); background: var(--panel); border-radius: 6px; padding: 7px; }
    .toolbar input[type="file"] { display: none; }
    .link-picker { display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 8px; align-items: center; border: 1px solid var(--line); background: var(--panel); border-radius: 6px; padding: 8px; }
    label { display: grid; gap: 5px; font-size: 13px; font-weight: 700; color: var(--muted); }
    input, textarea, select { width: 100%; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); padding: 9px 10px; }
    textarea { min-height: 48vh; resize: vertical; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.45; font-size: 14px; }
    .content-layout { display: grid; gap: 12px; }
    .content-layout.previewing { grid-template-columns: minmax(0, 1fr) minmax(280px, 1fr); align-items: start; }
    .preview-panel { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 12px 14px; min-height: 48vh; overflow: auto; overflow-wrap: anywhere; }
    .preview-panel h1, .preview-panel h2, .preview-panel h3 { color: var(--ink); margin: 0.8em 0 0.35em; }
    .preview-panel h1 { font-size: 24px; }
    .preview-panel h2 { font-size: 20px; }
    .preview-panel h3 { font-size: 17px; }
    .preview-panel p, .preview-panel ul, .preview-panel ol, .preview-panel blockquote { margin: 0 0 0.8em; }
    .preview-panel blockquote { border-left: 3px solid var(--line); padding-left: 10px; color: var(--muted); }
    .preview-panel img, .preview-panel video { max-width: 100%; height: auto; }
    .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
    .hint { color: var(--muted); font-size: 13px; }
    .full { grid-column: 1 / -1; }
    .tags { display: flex; gap: 6px; flex-wrap: wrap; }
    .tag { background: #e5eee9; color: #134d48; padding: 2px 7px; border-radius: 999px; font-size: 12px; }
    .queue-list { display: grid; gap: 8px; margin-bottom: 12px; }
    .queue-item { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 9px 10px; font-size: 13px; }
    .queue-item strong { display: block; overflow-wrap: anywhere; }
    .queue-item span { color: var(--muted); overflow-wrap: anywhere; }
    pre { white-space: pre-wrap; overflow: auto; max-height: 280px; background: #201f1d; color: #f7f1e4; padding: 12px; border-radius: 6px; font-size: 12px; }
    @media (max-width: 980px) { .content-layout.previewing { grid-template-columns: 1fr; } }
    @media (max-width: 860px) { .app { grid-template-columns: 1fr; } aside, main { max-height: none; } aside { border-right: 0; border-bottom: 1px solid var(--line); } .grid { grid-template-columns: 1fr; } .link-picker { grid-template-columns: 1fr; } }
  </style>
</head>
<body>
  <div class="app">
    <aside>
      <div class="topbar">
        <h1>Authoring</h1>
        <button id="newBtn" type="button">New</button>
      </div>
      <a class="quick-link" href="https://author.zainezq.com">author.zainezq.com</a>
      <div id="status" class="status"></div>
      <div class="filters">
        <input id="search" type="search" placeholder="Filter pages" />
        <select id="typeFilter">
          <option value="">All files</option>
          <option value="blog">Blogs</option>
          <option value="post">Posts</option>
          <option value="page">Pages</option>
          <option value="lima">Lima</option>
        </select>
      </div>
      <div id="pages" class="page-list"></div>
    </aside>
    <main>
      <form id="editor">
        <div class="topbar">
          <h1 id="formTitle">New page</h1>
          <span id="pathLabel" class="hint"></span>
        </div>
        <div class="grid">
          <label>Page
            <select name="pageType">
              <option value="blog">Blog</option>
              <option value="post">Post</option>
              <option value="page">Page</option>
              <option value="lima">Lima</option>
            </select>
          </label>
          <label>Post section
            <input name="section" placeholder="Optional, for example career" />
          </label>
          <label>Title
            <input name="title" required />
          </label>
          <label>Slug
            <input name="slug" required />
          </label>
          <label>Tags
            <input name="tags" placeholder="life, review" />
          </label>
          <label>Date
            <input name="date" placeholder="<2026-05-07 Thu 12:00>" />
          </label>
          <label class="full">Org file path
            <input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
          </label>
        </div>
        <div id="mdToolbar" class="toolbar">
          <button class="icon" type="button" data-md="bold" title="Bold">B</button>
          <button class="icon" type="button" data-md="italic" title="Italic"><i>I</i></button>
          <button class="icon" type="button" data-md="underline" title="Underline"><u>U</u></button>
          <button type="button" data-md="h1">H1</button>
          <button type="button" data-md="h2">H2</button>
          <button type="button" data-md="h3">H3</button>
          <button type="button" data-md="bullet">List</button>
          <button type="button" data-md="numbered">1. List</button>
          <button type="button" data-md="quote">Quote</button>
          <button type="button" data-md="link">Link</button>
          <button type="button" id="pageLinkBtn">Link to page</button>
          <button type="button" id="attachBtn">Insert image</button>
          <input id="attachInput" type="file" accept="image/*" />
        </div>
        <div id="pageLinkPicker" class="link-picker" hidden>
          <select id="pageLinkSelect"></select>
          <button type="button" id="insertPageLinkBtn">Insert link</button>
          <button type="button" id="cancelPageLinkBtn">Cancel</button>
        </div>
        <div id="contentLayout" class="content-layout">
          <label>Content
            <textarea name="content" spellcheck="true"></textarea>
          </label>
          <label id="previewWrap" hidden>Preview
            <div id="markdownPreview" class="preview-panel"></div>
          </label>
        </div>
        <label><span><input name="comments" type="checkbox" checked style="width:auto" /> Comments enabled</span></label>
        <div class="actions">
          <button class="primary" id="saveBtn" type="submit">Save and build</button>
          <button id="resetBtn" type="button">Reset</button>
          <span id="saveMessage" class="hint"></span>
        </div>
      </form>
      <h2>Build queue</h2>
      <div id="queue" class="queue-list"></div>
      <h2>Latest build log</h2>
      <pre id="buildLog"></pre>
    </main>
  </div>
  <script>
    const state = { pages: [], currentPath: "", build: null, pathManual: false };
    const $ = (selector) => document.querySelector(selector);
    const editor = $("#editor");
    const mdToolbar = $("#mdToolbar");
    const attachInput = $("#attachInput");
    const attachBtn = $("#attachBtn");
    const pageLinkPicker = $("#pageLinkPicker");
    const pageLinkSelect = $("#pageLinkSelect");
    const contentLayout = $("#contentLayout");
    const previewWrap = $("#previewWrap");
    const markdownPreview = $("#markdownPreview");
    const statusBox = $("#status");
    const pagesBox = $("#pages");
    const buildLog = $("#buildLog");
    const saveMessage = $("#saveMessage");

    function slugify(value) {
      return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "untitled";
    }

    function currentOrgDate() {
      const d = new Date();
      const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
      const pad = (n) => String(n).padStart(2, "0");
      return `<${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${days[d.getDay()]} ${pad(d.getHours())}:${pad(d.getMinutes())}>`;
    }

    async function api(path, options = {}) {
      const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
      let data = null;
      try {
        data = await res.clone().json();
      } catch (_err) {
        data = { error: await res.text().catch(() => "") };
      }
      if (!res.ok) throw new Error(data.error || "Request failed");
      return data;
    }

    function renderStatus() {
      const build = state.build || {};
      const latest = build.current || (build.recent && build.recent[build.recent.length - 1]);
      statusBox.className = "status" + (build.running ? " running" : latest && latest.ok === true ? " ok" : latest && latest.ok === false ? " fail" : "");
      statusBox.textContent = build.message || "Checking build status.";
      buildLog.textContent = build.log || "";
      renderQueue();
    }

    function renderQueue() {
      const box = $("#queue");
      const build = state.build || {};
      const items = [];
      if (build.current) items.push(build.current);
      (build.pending || []).forEach((job) => items.push(job));
      (build.recent || []).slice().reverse().slice(0, 5).forEach((job) => items.push(job));
      box.innerHTML = "";
      if (!items.length) {
        box.innerHTML = `<div class="queue-item"><span>No queued builds.</span></div>`;
        return;
      }
      items.forEach((job) => {
        const row = document.createElement("div");
        row.className = "queue-item";
        row.innerHTML = `<strong>#${job.id} ${html(job.status)} · ${html(job.title)}</strong><span>${html(job.path)} · ${html(job.message)}</span>`;
        box.appendChild(row);
      });
    }

    function renderPages() {
      const query = $("#search").value.toLowerCase();
      const type = $("#typeFilter").value;
      pagesBox.innerHTML = "";
      const matches = state.pages
        .filter((page) => (!type || page.pageType === type))
        .filter((page) => `${page.title} ${page.path} ${page.tags.join(" ")}`.toLowerCase().includes(query))
        .slice(0, 120);
      if (!matches.length) {
        pagesBox.innerHTML = `<div class="queue-item"><span>No files matched.</span></div>`;
      }
      matches.forEach((page) => {
          const btn = document.createElement("button");
          btn.type = "button";
          btn.className = "page-item";
          btn.innerHTML = `<strong>${html(page.title)}</strong><span>${html(page.path)}</span><span class="tags">${page.tags.map((tag) => `<span class="tag">${html(tag)}</span>`).join("")}</span>`;
          btn.onclick = () => loadPage(page.path);
          pagesBox.appendChild(btn);
        });
      renderStatus();
    }

    function html(value) {
      return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
    }

    function renderInlineMarkdown(value) {
      let out = html(value);
      out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
      out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
      out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
      out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
      out = out.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>');
      return out;
    }

    function markdownToHtml(markdown) {
      const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
      const blocks = [];
      let paragraph = [];
      let list = null;

      function flushParagraph() {
        if (!paragraph.length) return;
        blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
        paragraph = [];
      }

      function closeList() {
        if (!list) return;
        blocks.push(`<${list.type}>${list.items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</${list.type}>`);
        list = null;
      }

      lines.forEach((line) => {
        const trimmed = line.trim();
        if (!trimmed) {
          flushParagraph();
          closeList();
          return;
        }
        const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
        if (heading) {
          flushParagraph();
          closeList();
          blocks.push(`<h${heading[1].length}>${renderInlineMarkdown(heading[2])}</h${heading[1].length}>`);
          return;
        }
        const bullet = trimmed.match(/^[-*]\s+(.+)$/);
        const numbered = trimmed.match(/^\d+\.\s+(.+)$/);
        if (bullet || numbered) {
          flushParagraph();
          const type = bullet ? "ul" : "ol";
          if (!list || list.type !== type) {
            closeList();
            list = { type, items: [] };
          }
          list.items.push((bullet || numbered)[1]);
          return;
        }
        const quote = trimmed.match(/^>\s?(.+)$/);
        if (quote) {
          flushParagraph();
          closeList();
          blocks.push(`<blockquote>${renderInlineMarkdown(quote[1])}</blockquote>`);
          return;
        }
        closeList();
        paragraph.push(trimmed);
      });
      flushParagraph();
      closeList();
      return blocks.join("\n");
    }

    function updateMarkdownPreview() {
      markdownPreview.innerHTML = markdownToHtml(editor.content.value);
    }

    function setForm(page) {
      state.currentPath = page.path || "";
      state.pathManual = Boolean(page.path);
      editor.pageType.value = page.pageType || "blog";
      editor.section.value = "";
      editor.targetPath.value = page.path || "";
      editor.title.value = page.title || "";
      editor.slug.value = page.slug || slugify(page.title || "");
      editor.tags.value = (page.tags || []).join(", ");
      editor.date.value = page.date || currentOrgDate();
      editor.content.value = page.content || "";
      editor.comments.checked = page.comments !== false;
      $("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
      $("#pathLabel").textContent = state.currentPath;
      editor.targetPath.disabled = Boolean(state.currentPath);
      updateEditorMode();
      updateSuggestedPath();
      updateMarkdownPreview();
      saveMessage.textContent = "";
    }

    async function loadPage(path) {
      const page = await api(`/api/page?path=${encodeURIComponent(path)}`);
      setForm(page);
    }

    async function refreshPages() {
      try {
        state.pages = await api("/api/pages");
        renderPages();
      } catch (err) {
        if (state.pages.length) {
          renderPages();
          saveMessage.textContent = `Could not refresh file list: ${err.message}`;
        } else {
          pagesBox.innerHTML = `<div class="queue-item"><span>${html(err.message)}</span></div>`;
        }
      }
    }

    async function refreshBuild() {
      try {
        state.build = await api("/api/build");
        renderStatus();
      } catch (err) {
        statusBox.className = "status fail";
        statusBox.textContent = `Could not refresh build status: ${err.message}`;
      }
      await refreshPages();
    }

    editor.title.addEventListener("input", () => {
      if (!state.currentPath) {
        editor.slug.value = slugify(editor.title.value);
        updateSuggestedPath();
      }
    });
    editor.slug.addEventListener("input", updateSuggestedPath);
    editor.pageType.addEventListener("change", () => {
      if (!state.currentPath) {
        state.pathManual = false;
        editor.targetPath.value = "";
      }
      updateEditorMode();
      updateSuggestedPath();
    });
    editor.section.addEventListener("input", updateSuggestedPath);
    editor.date.addEventListener("input", updateSuggestedPath);
    editor.targetPath.addEventListener("input", () => { state.pathManual = true; });
    mdToolbar.querySelectorAll("[data-md]").forEach((button) => {
      button.addEventListener("click", () => applyMarkdown(button.dataset.md));
    });
    attachBtn.addEventListener("click", () => attachInput.click());
    $("#pageLinkBtn").addEventListener("click", showPageLinkPicker);
    $("#insertPageLinkBtn").addEventListener("click", insertSelectedPageLink);
    $("#cancelPageLinkBtn").addEventListener("click", () => { pageLinkPicker.hidden = true; });
    attachInput.addEventListener("change", uploadAttachment);
    editor.content.addEventListener("input", updateMarkdownPreview);
    $("#search").addEventListener("input", renderPages);
    $("#typeFilter").addEventListener("change", renderPages);
    $("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
    $("#resetBtn").addEventListener("click", () => state.currentPath ? loadPage(state.currentPath) : setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
    editor.addEventListener("submit", async (event) => {
      event.preventDefault();
      saveMessage.textContent = "Saving and queueing build.";
      const payload = Object.fromEntries(new FormData(editor).entries());
      payload.path = state.currentPath;
      payload.comments = editor.comments.checked;
      try {
        const saved = await api("/api/page", { method: "POST", body: JSON.stringify(payload) });
        setForm(saved);
        saveMessage.textContent = "Saved. Build queued.";
        await refreshBuild();
      } catch (err) {
        saveMessage.textContent = err.message;
      }
    });

    function updateSuggestedPath() {
      if (state.currentPath || state.pathManual) return;
      const slug = slugify(editor.slug.value || editor.title.value);
      if (editor.pageType.value === "lima") {
        editor.targetPath.value = `lima/${slug}.md`;
        return;
      }
      if (editor.pageType.value === "page") {
        editor.targetPath.value = `${slug}.org`;
        return;
      }
      if (editor.pageType.value === "post") {
        const section = slugify(editor.section.value || "");
        editor.targetPath.value = section === "untitled" ? `posts/${slug}.org` : `posts/${section}/${slug}.org`;
        return;
      }
      const match = editor.date.value.match(/<(\d{4})-(\d{2})-/);
      const year = match ? match[1] : String(new Date().getFullYear());
      const month = match ? Number(match[2]) : new Date().getMonth() + 1;
      const months = ["january","february","march","april","may","june","july","august","september","october","november","december"];
      editor.targetPath.value = `blogs/${year}/${String(month).padStart(2, "0")}-${months[month - 1]}/${slug}.org`;
    }

    function updateEditorMode() {
      const isLima = editor.pageType.value === "lima";
      const isOrg = !isLima;
      mdToolbar.querySelectorAll("[data-md='link']").forEach((button) => {
        button.textContent = isLima ? "Link" : "Org link";
      });
      editor.tags.closest("label").hidden = isLima;
      editor.date.closest("label").hidden = isLima;
      editor.comments.closest("label").hidden = isLima;
      editor.section.closest("label").hidden = editor.pageType.value !== "post";
      attachBtn.hidden = false;
      previewWrap.hidden = !isLima;
      contentLayout.classList.toggle("previewing", isLima);
      pageLinkPicker.hidden = true;
      const pathLabel = editor.targetPath.closest("label").firstChild;
      if (pathLabel) pathLabel.textContent = isLima ? "Markdown file path" : "Org file path";
      editor.targetPath.placeholder = isLima ? "lima/family-update.md" : "blogs/2026/05-may/my-page.org";
      updateMarkdownPreview();
    }

    function selectedText() {
      const area = editor.content;
      return {
        start: area.selectionStart,
        end: area.selectionEnd,
        text: area.value.slice(area.selectionStart, area.selectionEnd),
      };
    }

    function replaceSelection(value, selectStart = null, selectEnd = null) {
      const area = editor.content;
      const { start, end } = selectedText();
      area.setRangeText(value, start, end, "end");
      area.focus();
      if (selectStart !== null && selectEnd !== null) {
        area.setSelectionRange(start + selectStart, start + selectEnd);
      }
      updateMarkdownPreview();
    }

    function toggleLinePrefix(prefix, fallback, ordered = false) {
      const area = editor.content;
      let { start, end, text } = selectedText();
      if (start === end) {
        start = area.value.lastIndexOf("\n", start - 1) + 1;
        const lineEnd = area.value.indexOf("\n", end);
        end = lineEnd === -1 ? area.value.length : lineEnd;
        text = area.value.slice(start, end);
      }
      const value = text || fallback;
      const lines = value.split("\n");
      const hasPrefix = lines.every((line) => ordered ? /^\d+\.\s/.test(line) : line.startsWith(prefix));
      const replacement = lines.map((line, index) => {
        if (hasPrefix) return ordered ? line.replace(/^\d+\.\s/, "") : line.slice(prefix.length);
        return ordered ? `${index + 1}. ${line || fallback}` : `${prefix}${line || fallback}`;
      }).join("\n");
      area.setRangeText(replacement, start, end, "end");
      area.setSelectionRange(start, start + replacement.length);
      area.focus();
      updateMarkdownPreview();
    }

    function toggleWrap(prefix, suffix, fallback) {
      const area = editor.content;
      const { start, end, text } = selectedText();
      const before = area.value.slice(start - prefix.length, start);
      const after = area.value.slice(end, end + suffix.length);
      if (text && text.startsWith(prefix) && text.endsWith(suffix)) {
        const inner = text.slice(prefix.length, text.length - suffix.length);
        area.setRangeText(inner, start, end, "select");
        area.setSelectionRange(start, start + inner.length);
      } else if (text && before === prefix && after === suffix) {
        area.setSelectionRange(start - prefix.length, end + suffix.length);
        area.setRangeText(text, start - prefix.length, end + suffix.length, "select");
        area.setSelectionRange(start - prefix.length, start - prefix.length + text.length);
      } else {
        const value = text || fallback;
        area.setRangeText(`${prefix}${value}${suffix}`, start, end, "select");
        area.setSelectionRange(start + prefix.length, start + prefix.length + value.length);
      }
      area.focus();
      updateMarkdownPreview();
    }

    function applyMarkdown(action) {
      const { text } = selectedText();
      const sample = text || "text";
      const isLima = editor.pageType.value === "lima";
      if (action === "bold") toggleWrap(isLima ? "**" : "*", isLima ? "**" : "*", sample);
      if (action === "italic") toggleWrap(isLima ? "*" : "/", isLima ? "*" : "/", sample);
      if (action === "underline") toggleWrap(isLima ? "<u>" : "_", isLima ? "</u>" : "_", sample);
      if (action === "h1") toggleLinePrefix(isLima ? "# " : "* ", "Heading");
      if (action === "h2") toggleLinePrefix(isLima ? "## " : "** ", "Heading");
      if (action === "h3") toggleLinePrefix(isLima ? "### " : "*** ", "Heading");
      if (action === "bullet") toggleLinePrefix(isLima ? "- " : "- ", "List item");
      if (action === "numbered") toggleLinePrefix("", "List item", true);
      if (action === "quote") {
        if (isLima) {
          toggleLinePrefix("> ", "Quote");
        } else {
          const body = text || "Quote";
          if (body.startsWith("#+begin_quote") && body.trimEnd().endsWith("#+end_quote")) {
            replaceSelection(body.replace(/^#\+begin_quote\s*\n?/i, "").replace(/\n?#\+end_quote\s*$/i, ""));
          } else {
            replaceSelection(`#+begin_quote\n${body}\n#+end_quote`, 14, 14 + body.length);
          }
        }
      }
      if (action === "link") {
        const label = sample === "text" ? "link text" : sample;
        if (isLima) {
          replaceSelection(`[${label}](https://)`, 1, 1 + label.length);
        } else {
          replaceSelection(`[[https://][${label}]]`, 11, 11);
        }
      }
    }

    function pageUrl(page) {
      const path = page.path.replace(/\.(org|md)$/i, ".html");
      return `https://zainezq.com/${path}`;
    }

    function currentEditorPath() {
      return state.currentPath || editor.targetPath.value || "";
    }

    function dirname(path) {
      const index = path.lastIndexOf("/");
      return index === -1 ? "" : path.slice(0, index);
    }

    function relativePath(fromDir, toPath) {
      const fromParts = fromDir ? fromDir.split("/").filter(Boolean) : [];
      const toParts = toPath.split("/").filter(Boolean);
      while (fromParts.length && toParts.length && fromParts[0] === toParts[0]) {
        fromParts.shift();
        toParts.shift();
      }
      return [...fromParts.map(() => ".."), ...toParts].join("/") || toPath;
    }

    function orgFileLink(page) {
      const current = currentEditorPath();
      const target = current ? relativePath(dirname(current), page.path) : page.path;
      return `[[file:${target}][${page.title}]]`;
    }

    function markdownPageLink(page, label) {
      return `[${label || page.title}](${pageUrl(page)})`;
    }

    function showPageLinkPicker() {
      pageLinkSelect.innerHTML = "";
      state.pages.forEach((page) => {
        const option = document.createElement("option");
        option.value = page.path;
        option.textContent = `${page.title} - ${page.path}`;
        pageLinkSelect.appendChild(option);
      });
      pageLinkPicker.hidden = false;
      pageLinkSelect.focus();
    }

    function insertSelectedPageLink() {
      const page = state.pages.find((item) => item.path === pageLinkSelect.value);
      if (!page) return;
      const { text } = selectedText();
      const isLima = editor.pageType.value === "lima";
      replaceSelection(isLima ? markdownPageLink(page, text || page.title) : orgFileLink(page));
      pageLinkPicker.hidden = true;
    }

    async function uploadAttachment() {
      const file = attachInput.files[0];
      if (!file) return;
      saveMessage.textContent = "Uploading image.";
      const body = new FormData();
      body.append("attachment", file);
      body.append("pagePath", currentEditorPath() || "index.org");
      try {
        const res = await fetch("/api/upload", { method: "POST", body });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || "Upload failed");
        replaceSelection(`\n${data.insertText || data.markdown}\n`);
        saveMessage.textContent = "Image inserted.";
      } catch (err) {
        saveMessage.textContent = err.message;
      } finally {
        attachInput.value = "";
      }
    }

    setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
    refreshPages();
    refreshBuild();
    setInterval(refreshBuild, 2500);
  </script>
</body>
</html>
"""


class Handler(BaseHTTPRequestHandler):
    server_version = "OrgAuthoring/1.0"

    def log_message(self, fmt: str, *args: Any) -> None:
        sys.stderr.write("%s - %s\n" % (formatdate(time.time()), fmt % args))

    def send_json(self, data: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
        body = json.dumps(data).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)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        if parsed.path == "/":
            body = APP_HTML.encode("utf-8")
            self.send_response(HTTPStatus.OK)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        if parsed.path == "/api/pages":
            try:
                self.send_json(list_pages())
            except Exception as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
            return
        if parsed.path == "/api/diagnostics":
            try:
                self.send_json(server_diagnostics())
            except Exception as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
            return
        if parsed.path == "/api/page":
            query = parse_qs(parsed.query)
            try:
                path = safe_relative_path(query.get("path", [""])[0])
                self.send_json(page_to_dict(read_page(path)))
            except Exception as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/build":
            self.send_json(BUILD_QUEUE.snapshot())
            return
        self.send_error(HTTPStatus.NOT_FOUND)

    def do_POST(self) -> None:
        if self.path == "/api/upload":
            try:
                form = cgi.FieldStorage(
                    fp=self.rfile,
                    headers=self.headers,
                    environ={
                        "REQUEST_METHOD": "POST",
                        "CONTENT_TYPE": self.headers.get("Content-Type", ""),
                    },
                )
                field = form["attachment"] if "attachment" in form else None
                if field is None or not getattr(field, "filename", ""):
                    raise ValueError("No attachment was uploaded.")
                payload = field.file.read()
                if not payload:
                    raise ValueError("Attachment is empty.")
                page_path = ""
                if "pagePath" in form:
                    page_path = str(form["pagePath"].value or "")
                self.send_json(save_upload(field.filename, payload, page_path))
            except Exception as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if self.path != "/api/page":
            self.send_error(HTTPStatus.NOT_FOUND)
            return
        try:
            length = int(self.headers.get("Content-Length", "0"))
            data = json.loads(self.rfile.read(length).decode("utf-8"))
            saved = save_page(data)
            saved["queuedBuild"] = queue_build(saved)
            self.send_json(saved)
        except Exception as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)


def main() -> None:
    port = int(os.environ.get("AUTHOR_PORT", "8765"))
    server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
    page_count = len(list_pages())
    print(f"Authoring UI running at http://127.0.0.1:{port}")
    print(f"Content root: {ROOT}")
    print(f"Editable pages: {page_count}")
    if page_count == 0:
        print("WARNING: no editable pages were found. Check AUTHOR_ROOT and the launch directory.")
    print("Press Ctrl-C to stop.")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()
