"""HTTP server for the Lattice dashboard."""

from __future__ import annotations

import json
import shutil
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

from lattice.core.config import (
    VALID_PRIORITIES,
    VALID_URGENCIES,
    serialize_config,
    validate_status,
    validate_task_type,
    validate_transition,
)
from lattice.core.events import create_event, serialize_event, utc_now
from lattice.core.ids import generate_task_id, validate_actor, validate_id
from lattice.core.tasks import apply_event_to_snapshot, compact_snapshot, serialize_snapshot
from lattice.storage.fs import atomic_write, jsonl_append
from lattice.storage.locks import multi_lock
from lattice.storage.hooks import execute_hooks
from lattice.storage.operations import scaffold_notes, write_task_event
from lattice.storage.short_ids import allocate_short_id

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

# Maximum allowed request body size (1 MiB) to prevent DoS via oversized payloads.
MAX_REQUEST_BODY_BYTES = 1_048_576

# ---------------------------------------------------------------------------
# JSON envelope helpers
# ---------------------------------------------------------------------------


def _ok(data: Any) -> str:
    return json.dumps({"ok": True, "data": data}, sort_keys=True, indent=2) + "\n"


def _err(code: str, message: str) -> str:
    return (
        json.dumps(
            {"ok": False, "error": {"code": code, "message": message}},
            sort_keys=True,
            indent=2,
        )
        + "\n"
    )


# ---------------------------------------------------------------------------
# Request handler
# ---------------------------------------------------------------------------


def _make_handler_class(lattice_dir: Path, *, readonly: bool = False) -> type:
    """Create a handler class bound to a specific .lattice/ directory."""

    class LatticeHandler(BaseHTTPRequestHandler):
        _lattice_dir: Path = lattice_dir
        _readonly: bool = readonly

        # Suppress default access logging to stdout; send to stderr instead
        def log_message(self, format: str, *args: Any) -> None:  # noqa: A002
            sys.stderr.write(f"{self.address_string()} - {format % args}\n")

        def do_GET(self) -> None:  # noqa: N802
            parsed = urlparse(self.path)
            path = parsed.path.rstrip("/") or "/"

            if path == "/":
                self._serve_static("index.html", "text/html")
            elif path == "/stats-demo":
                self._serve_notes_file("stats-demo/demo.html", "text/html")
            elif path.startswith("/api/"):
                self._route_api(path)
            else:
                self._send_json(404, _err("NOT_FOUND", f"Not found: {path}"))

        def do_POST(self) -> None:  # noqa: N802
            if self._readonly:
                self._send_json(403, _err("FORBIDDEN", "Dashboard is in read-only mode"))
                return

            parsed = urlparse(self.path)
            path = parsed.path.rstrip("/") or "/"

            if path.startswith("/api/"):
                self._route_api_post(path)
            else:
                self._send_json(404, _err("NOT_FOUND", f"Not found: {path}"))

        # ---------------------------------------------------------------
        # Static file serving
        # ---------------------------------------------------------------

        def _serve_static(self, filename: str, content_type: str) -> None:
            filepath = STATIC_DIR / filename
            if not filepath.is_file():
                self._send_json(404, _err("NOT_FOUND", f"Static file not found: {filename}"))
                return
            data = filepath.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", f"{content_type}; charset=utf-8")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        def _serve_notes_file(self, relpath: str, content_type: str) -> None:
            """Serve a file from the repo's notes/ directory."""
            repo_root = Path(self._lattice_dir).resolve().parent
            filepath = repo_root / "notes" / relpath
            if not filepath.is_file():
                self._send_json(404, _err("NOT_FOUND", f"File not found: {relpath}"))
                return
            data = filepath.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", f"{content_type}; charset=utf-8")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        # ---------------------------------------------------------------
        # API routing
        # ---------------------------------------------------------------

        def _route_api(self, path: str) -> None:
            ld = self._lattice_dir

            if path == "/api/config":
                self._handle_config(ld)
            elif path == "/api/tasks":
                self._handle_tasks(ld)
            elif path == "/api/stats":
                self._handle_stats(ld)
            elif path == "/api/activity":
                self._handle_activity(ld)
            elif path == "/api/archived":
                self._handle_archived(ld)
            elif path == "/api/graph":
                self._handle_graph(ld)
            elif path.startswith("/api/tasks/"):
                remainder = path[len("/api/tasks/") :]
                if "/" in remainder:
                    # /api/tasks/<id>/events
                    task_id, sub = remainder.rsplit("/", 1)
                    if sub == "events":
                        self._handle_task_events(ld, task_id)
                    else:
                        self._send_json(404, _err("NOT_FOUND", f"Not found: {path}"))
                else:
                    self._handle_task_detail(ld, remainder)
            else:
                self._send_json(404, _err("NOT_FOUND", f"Unknown API endpoint: {path}"))

        def _route_api_post(self, path: str) -> None:
            ld = self._lattice_dir

            if path == "/api/config/dashboard":
                self._handle_post_dashboard_config(ld)
            elif path == "/api/tasks":
                self._handle_post_create_task(ld)
            elif path.startswith("/api/tasks/"):
                remainder = path[len("/api/tasks/") :]
                if "/" in remainder:
                    task_id, sub = remainder.rsplit("/", 1)
                    if sub == "status":
                        self._handle_post_task_status(ld, task_id)
                    elif sub == "assign":
                        self._handle_post_task_assign(ld, task_id)
                    elif sub == "comment":
                        self._handle_post_task_comment(ld, task_id)
                    elif sub == "update":
                        self._handle_post_task_update(ld, task_id)
                    elif sub == "archive":
                        self._handle_post_task_archive(ld, task_id)
                    else:
                        self._send_json(404, _err("NOT_FOUND", f"Not found: {path}"))
                else:
                    self._send_json(404, _err("NOT_FOUND", f"Not found: {path}"))
            else:
                self._send_json(404, _err("NOT_FOUND", f"Unknown API endpoint: {path}"))

        # ---------------------------------------------------------------
        # JSON response helper
        # ---------------------------------------------------------------

        def _send_json(self, status: int, body: str) -> None:
            data = body.encode("utf-8")
            self.send_response(status)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        # ---------------------------------------------------------------
        # Endpoint handlers
        # ---------------------------------------------------------------

        def _handle_config(self, ld: Path) -> None:
            config_path = ld / "config.json"
            try:
                config = json.loads(config_path.read_text())
            except (json.JSONDecodeError, OSError) as exc:
                self._send_json(500, _err("READ_ERROR", f"Failed to read config: {exc}"))
                return
            self._send_json(200, _ok(config))

        def _handle_tasks(self, ld: Path) -> None:
            tasks_dir = ld / "tasks"
            snapshots: list[dict] = []
            if tasks_dir.is_dir():
                for task_file in sorted(tasks_dir.glob("*.json")):
                    try:
                        snap = json.loads(task_file.read_text())
                    except (json.JSONDecodeError, OSError):
                        continue
                    compact = compact_snapshot(snap)
                    compact["updated_at"] = snap.get("updated_at")
                    compact["created_at"] = snap.get("created_at")
                    snapshots.append(compact)
            # Sort by ID
            snapshots.sort(key=lambda s: s.get("id", ""))
            self._send_json(200, _ok(snapshots))

        def _handle_task_detail(self, ld: Path, task_id: str) -> None:
            if not validate_id(task_id, "task"):
                self._send_json(400, _err("INVALID_ID", "Invalid task ID format"))
                return

            snapshot = _read_snapshot(ld, task_id)
            is_archived = False

            if snapshot is None:
                # Check archive
                snapshot = _read_snapshot_archive(ld, task_id)
                if snapshot is not None:
                    is_archived = True

            if snapshot is None:
                self._send_json(404, _err("NOT_FOUND", f"Task {task_id} not found"))
                return

            # Enrich with notes_exists and artifacts
            if is_archived:
                notes_path = ld / "archive" / "notes" / f"{task_id}.md"
            else:
                notes_path = ld / "notes" / f"{task_id}.md"

            result = dict(snapshot)
            result["notes_exists"] = notes_path.exists()
            result["artifacts"] = _read_artifact_info(ld, snapshot)
            if is_archived:
                result["archived"] = True

            self._send_json(200, _ok(result))

        def _handle_task_events(self, ld: Path, task_id: str) -> None:
            if not validate_id(task_id, "task"):
                self._send_json(400, _err("INVALID_ID", "Invalid task ID format"))
                return

            events = _read_task_events(ld, task_id)
            if events is None:
                # Check archive
                events = _read_task_events_archive(ld, task_id)

            if events is None:
                events = []

            # Return newest first
            events.reverse()
            self._send_json(200, _ok(events))

        def _handle_activity(self, ld: Path) -> None:
            all_events: list[dict] = []
            events_dir = ld / "events"
            if events_dir.is_dir():
                for event_file in events_dir.glob("*.jsonl"):
                    if event_file.name == "_lifecycle.jsonl":
                        continue
                    # Tail: read last few events from each file
                    try:
                        lines = event_file.read_text().splitlines()
                    except OSError:
                        continue
                    # Take last 5 lines from each file
                    for line in lines[-5:]:
                        line = line.strip()
                        if line:
                            try:
                                all_events.append(json.loads(line))
                            except json.JSONDecodeError:
                                continue

            # Sort by (ts, id) descending
            all_events.sort(key=lambda e: (e.get("ts", ""), e.get("id", "")), reverse=True)
            # Return top 50
            self._send_json(200, _ok(all_events[:50]))

        def _handle_stats(self, ld: Path) -> None:
            config_path = ld / "config.json"
            try:
                config = json.loads(config_path.read_text())
            except (json.JSONDecodeError, OSError) as exc:
                self._send_json(500, _err("READ_ERROR", f"Failed to read config: {exc}"))
                return
            from lattice.core.stats import build_stats

            stats = build_stats(ld, config)
            self._send_json(200, _ok(stats))

        def _handle_archived(self, ld: Path) -> None:
            archive_dir = ld / "archive" / "tasks"
            snapshots: list[dict] = []
            if archive_dir.is_dir():
                for task_file in sorted(archive_dir.glob("*.json")):
                    try:
                        snap = json.loads(task_file.read_text())
                    except (json.JSONDecodeError, OSError):
                        continue
                    compact = compact_snapshot(snap)
                    compact["updated_at"] = snap.get("updated_at")
                    compact["created_at"] = snap.get("created_at")
                    compact["archived"] = True
                    snapshots.append(compact)
            snapshots.sort(key=lambda s: s.get("id", ""))
            self._send_json(200, _ok(snapshots))

        def _handle_graph(self, ld: Path) -> None:
            """Handle GET /api/graph — return nodes + directed edges for graph visualization."""
            tasks_dir = ld / "tasks"
            snapshots: list[dict] = []
            if tasks_dir.is_dir():
                for task_file in sorted(tasks_dir.glob("*.json")):
                    try:
                        snap = json.loads(task_file.read_text())
                    except (json.JSONDecodeError, OSError):
                        continue
                    snapshots.append(snap)

            # Build set of active task IDs for filtering link targets
            active_ids: set[str] = {s["id"] for s in snapshots if "id" in s}

            # Build nodes — extract only the fields needed for graph rendering
            nodes: list[dict] = []
            max_updated_at = ""
            for snap in snapshots:
                node = {
                    "id": snap.get("id"),
                    "short_id": snap.get("short_id"),
                    "title": snap.get("title"),
                    "status": snap.get("status"),
                    "priority": snap.get("priority"),
                    "type": snap.get("type"),
                    "assigned_to": snap.get("assigned_to"),
                }
                nodes.append(node)

                updated = snap.get("updated_at", "")
                if updated > max_updated_at:
                    max_updated_at = updated

            # Build directed edges from relationships_out.
            # Edges are directed: source is the task containing the relationship,
            # target is the referenced task. Edge direction meaning varies by type
            # (e.g., for "blocks", source blocks target).
            links: list[dict] = []
            for snap in snapshots:
                task_id = snap.get("id")
                for rel in snap.get("relationships_out", []):
                    target_id = rel.get("target_task_id")
                    # Only emit link if target exists in the active task set
                    if target_id and target_id in active_ids:
                        links.append(
                            {
                                "source": task_id,
                                "target": target_id,
                                "type": rel.get("type"),
                            }
                        )

            # Revision string for cheap change detection
            revision = f"{len(nodes)}:{max_updated_at}"

            # ETag / 304 support — return early if client has current data
            etag = f'"{revision}"'  # ETags must be quoted per RFC 7232
            if_none_match = self.headers.get("If-None-Match")
            if if_none_match and if_none_match == etag:
                self.send_response(304)
                self.send_header("ETag", etag)
                self.end_headers()
                return

            body = _ok({"nodes": nodes, "links": links, "revision": revision})
            data = body.encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(data)))
            self.send_header("ETag", etag)
            self.end_headers()
            self.wfile.write(data)

        # ---------------------------------------------------------------
        # POST endpoint handlers
        # ---------------------------------------------------------------

        def _read_request_body(self) -> dict | None:
            """Read and parse a JSON request body. Returns None on failure."""
            try:
                content_length = int(self.headers.get("Content-Length", 0))
            except (TypeError, ValueError):
                self._send_json(400, _err("BAD_REQUEST", "Missing or invalid Content-Length"))
                return None

            if content_length == 0:
                self._send_json(400, _err("BAD_REQUEST", "Empty request body"))
                return None

            if content_length > MAX_REQUEST_BODY_BYTES:
                self._send_json(
                    413,
                    _err(
                        "PAYLOAD_TOO_LARGE", f"Request body exceeds {MAX_REQUEST_BODY_BYTES} bytes"
                    ),
                )
                return None

            try:
                raw = self.rfile.read(content_length)
                return json.loads(raw)
            except json.JSONDecodeError:
                self._send_json(400, _err("BAD_REQUEST", "Invalid JSON in request body"))
                return None

        def _handle_post_task_status(self, ld: Path, task_id: str) -> None:
            """Handle POST /api/tasks/<id>/status — change task status."""
            if not validate_id(task_id, "task"):
                self._send_json(400, _err("INVALID_ID", "Invalid task ID format"))
                return

            body = self._read_request_body()
            if body is None:
                return  # error already sent

            new_status = body.get("status")
            actor = body.get("actor", "dashboard:web")
            force = body.get("force", False)
            reason = body.get("reason")

            if not new_status:
                self._send_json(400, _err("VALIDATION_ERROR", "Missing 'status' field"))
                return

            if not isinstance(new_status, str):
                self._send_json(400, _err("VALIDATION_ERROR", "'status' must be a string"))
                return

            if not validate_actor(actor):
                self._send_json(400, _err("VALIDATION_ERROR", f"Invalid actor format: '{actor}'"))
                return

            # Read config
            config_path = ld / "config.json"
            try:
                config = json.loads(config_path.read_text())
            except (json.JSONDecodeError, OSError) as exc:
                self._send_json(500, _err("READ_ERROR", f"Failed to read config: {exc}"))
                return

            # Validate new_status is a known status
            if not validate_status(config, new_status):
                valid = ", ".join(config.get("workflow", {}).get("statuses", []))
                self._send_json(
                    400,
                    _err("VALIDATION_ERROR", f"Invalid status: '{new_status}'. Valid: {valid}"),
                )
                return

            # Read snapshot (outside lock — acceptable TOCTOU at v0 scale)
            snapshot = _read_snapshot(ld, task_id)
            if snapshot is None:
                self._send_json(404, _err("NOT_FOUND", f"Task {task_id} not found"))
                return

            current_status = snapshot["status"]

            if current_status == new_status:
                self._send_json(
                    200,
                    _ok({"message": f"Already at status {new_status}"}),
                )
                return

            if not validate_transition(config, current_status, new_status):
                if not force:
                    self._send_json(
                        400,
                        _err(
                            "INVALID_TRANSITION",
                            f"Invalid transition from {current_status} to {new_status}. "
                            "Send force=true with a reason to override.",
                        ),
                    )
                    return
                if not reason or not isinstance(reason, str) or not reason.strip():
                    self._send_json(
                        400,
                        _err("VALIDATION_ERROR", "'reason' is required with force=true"),
                    )
                    return

            # Build event data
            event_data: dict = {"from": current_status, "to": new_status}
            if force:
                event_data["force"] = True
                event_data["reason"] = reason

            event = create_event(
                type="status_changed",
                task_id=task_id,
                actor=actor,
                data=event_data,
            )
            updated_snapshot = apply_event_to_snapshot(snapshot, event)

            try:
                write_task_event(ld, task_id, [event], updated_snapshot, config)
            except Exception as exc:
                self._send_json(500, _err("WRITE_ERROR", f"Failed to update status: {exc}"))
                return

            self._send_json(200, _ok(updated_snapshot))

        def _handle_post_dashboard_config(self, ld: Path) -> None:
            """Handle POST /api/config/dashboard — save dashboard settings."""
            body = self._read_request_body()
            if body is None:
                return  # error already sent

            # Validate body structure: only allow known keys
            allowed_keys = {"background_image", "lane_colors", "theme", "voice"}
            unknown = set(body.keys()) - allowed_keys
            if unknown:
                self._send_json(
                    400,
                    _err("VALIDATION_ERROR", f"Unknown keys: {', '.join(sorted(unknown))}"),
                )
                return

            # Validate lane_colors if present
            if "lane_colors" in body:
                lc = body["lane_colors"]
                if not isinstance(lc, dict):
                    self._send_json(
                        400, _err("VALIDATION_ERROR", "'lane_colors' must be an object")
                    )
                    return
                for k, v in lc.items():
                    if not isinstance(k, str) or not isinstance(v, str):
                        self._send_json(
                            400,
                            _err(
                                "VALIDATION_ERROR", "lane_colors keys and values must be strings"
                            ),
                        )
                        return

            # Validate theme if present
            if "theme" in body:
                theme = body["theme"]
                if theme is not None and not isinstance(theme, str):
                    self._send_json(
                        400,
                        _err("VALIDATION_ERROR", "'theme' must be a string or null"),
                    )
                    return

            # Validate background_image if present
            if "background_image" in body:
                bg = body["background_image"]
                if bg is not None and not isinstance(bg, str):
                    self._send_json(
                        400,
                        _err("VALIDATION_ERROR", "'background_image' must be a string or null"),
                    )
                    return
                if bg is not None and bg != "" and not bg.startswith(("http://", "https://")):
                    self._send_json(
                        400,
                        _err(
                            "VALIDATION_ERROR", "'background_image' must be an http or https URL"
                        ),
                    )
                    return

            # Validate voice if present
            if "voice" in body:
                v = body["voice"]
                if not isinstance(v, str):
                    self._send_json(400, _err("VALIDATION_ERROR", "'voice' must be a string"))
                    return

            # Read, merge, write config atomically
            config_path = ld / "config.json"
            locks_dir = ld / "locks"

            try:
                with multi_lock(locks_dir, ["config"]):
                    try:
                        config = json.loads(config_path.read_text())
                    except (json.JSONDecodeError, OSError) as exc:
                        self._send_json(500, _err("READ_ERROR", f"Failed to read config: {exc}"))
                        return

                    dashboard = config.get("dashboard", {})

                    if "background_image" in body:
                        bg = body["background_image"]
                        if bg is None or bg == "":
                            dashboard.pop("background_image", None)
                        else:
                            dashboard["background_image"] = bg

                    if "lane_colors" in body:
                        dashboard["lane_colors"] = body["lane_colors"]

                    if "theme" in body:
                        theme = body["theme"]
                        if theme is None:
                            dashboard.pop("theme", None)
                        else:
                            dashboard["theme"] = theme

                    if "voice" in body:
                        voice = body["voice"]
                        if voice is None:
                            dashboard.pop("voice", None)
                        else:
                            dashboard["voice"] = voice

                    if dashboard:
                        config["dashboard"] = dashboard
                    else:
                        config.pop("dashboard", None)

                    atomic_write(config_path, serialize_config(config))

            except Exception as exc:
                self._send_json(
                    500, _err("WRITE_ERROR", f"Failed to save dashboard config: {exc}")
                )
                return

            self._send_json(200, _ok(config.get("dashboard", {})))

        # ---------------------------------------------------------------
        # POST /api/tasks — Create Task
        # ---------------------------------------------------------------

        def _handle_post_create_task(self, ld: Path) -> None:
            """Handle POST /api/tasks — create a new task."""
            body = self._read_request_body()
            if body is None:
                return

            title = body.get("title")
            actor = body.get("actor", "dashboard:web")

            if not title or not isinstance(title, str) or not title.strip():
                self._send_json(400, _err("VALIDATION_ERROR", "Missing or empty 'title' field"))
                return

            title = title.strip()

            if not validate_actor(actor):
                self._send_json(400, _err("VALIDATION_ERROR", f"Invalid actor format: '{actor}'"))
                return

            # Read config
            config_path = ld / "config.json"
            try:
                config = json.loads(config_path.read_text())
            except (json.JSONDecodeError, OSError) as exc:
                self._send_json(500, _err("READ_ERROR", f"Failed to read config: {exc}"))
                return

            # Apply defaults
            status = body.get("status") or config.get("default_status", "backlog")
            priority = body.get("priority") or config.get("default_priority", "medium")
            task_type = body.get("type") or "task"
            description = body.get("description")
            tags = body.get("tags")
            assigned_to = body.get("assigned_to")
            urgency = body.get("urgency")

            # Validate
            if not validate_status(config, status):
                valid = ", ".join(config.get("workflow", {}).get("statuses", []))
                self._send_json(
                    400, _err("VALIDATION_ERROR", f"Invalid status: '{status}'. Valid: {valid}")
                )
                return

            if not validate_task_type(config, task_type):
                valid = ", ".join(config.get("task_types", []))
                self._send_json(
                    400,
                    _err("VALIDATION_ERROR", f"Invalid task type: '{task_type}'. Valid: {valid}"),
                )
                return

            if priority not in VALID_PRIORITIES:
                valid = ", ".join(VALID_PRIORITIES)
                self._send_json(
                    400,
                    _err("VALIDATION_ERROR", f"Invalid priority: '{priority}'. Valid: {valid}"),
                )
                return

            if urgency is not None and urgency not in VALID_URGENCIES:
                valid = ", ".join(VALID_URGENCIES)
                self._send_json(
                    400,
                    _err("VALIDATION_ERROR", f"Invalid urgency: '{urgency}'. Valid: {valid}"),
                )
                return

            if assigned_to is not None and not validate_actor(assigned_to):
                self._send_json(
                    400,
                    _err("VALIDATION_ERROR", f"Invalid assigned_to format: '{assigned_to}'"),
                )
                return

            if tags is not None and not isinstance(tags, list):
                self._send_json(400, _err("VALIDATION_ERROR", "'tags' must be an array"))
                return

            if description is not None and not isinstance(description, str):
                self._send_json(400, _err("VALIDATION_ERROR", "'description' must be a string"))
                return

            # Generate ID
            task_id = generate_task_id()

            # Allocate short ID if project code is configured
            project_code = config.get("project_code")
            subproject_code = config.get("subproject_code")
            short_id: str | None = None
            if project_code:
                prefix = f"{project_code}-{subproject_code}" if subproject_code else project_code
                short_id, _ = allocate_short_id(ld, prefix, task_ulid=task_id)

            # Build event data
            event_data: dict = {
                "title": title,
                "status": status,
                "type": task_type,
                "priority": priority,
            }
            if urgency is not None:
                event_data["urgency"] = urgency
            if description is not None:
                event_data["description"] = description
            if tags:
                event_data["tags"] = tags
            if assigned_to is not None:
                event_data["assigned_to"] = assigned_to
            if short_id is not None:
                event_data["short_id"] = short_id

            event = create_event(
                type="task_created",
                task_id=task_id,
                actor=actor,
                data=event_data,
            )
            snapshot = apply_event_to_snapshot(None, event)

            try:
                write_task_event(ld, task_id, [event], snapshot, config)
            except Exception as exc:
                self._send_json(500, _err("WRITE_ERROR", f"Failed to create task: {exc}"))
                return

            # Scaffold notes file
            scaffold_notes(ld, task_id, title, short_id, description)

            self._send_json(201, _ok(snapshot))

        # ---------------------------------------------------------------
        # POST /api/tasks/<id>/assign — Assign/Unassign
        # ---------------------------------------------------------------

        def _handle_post_task_assign(self, ld: Path, task_id: str) -> None:
            """Handle POST /api/tasks/<id>/assign — assign or unassign a task."""
            if not validate_id(task_id, "task"):
                self._send_json(400, _err("INVALID_ID", "Invalid task ID format"))
                return

            body = self._read_request_body()
            if body is None:
                return

            assigned_to = body.get("assigned_to")
            actor = body.get("actor", "dashboard:web")

            if not validate_actor(actor):
                self._send_json(400, _err("VALIDATION_ERROR", f"Invalid actor format: '{actor}'"))
                return

            # assigned_to can be null (unassign) or a valid actor string
            if assigned_to is not None and not validate_actor(assigned_to):
                self._send_json(
                    400,
                    _err("VALIDATION_ERROR", f"Invalid assigned_to format: '{assigned_to}'"),
                )
                return

            # Read config for hooks
            config_path = ld / "config.json"
            try:
                config = json.loads(config_path.read_text())
            except (json.JSONDecodeError, OSError) as exc:
                self._send_json(500, _err("READ_ERROR", f"Failed to read config: {exc}"))
                return

            snapshot = _read_snapshot(ld, task_id)
            if snapshot is None:
                self._send_json(404, _err("NOT_FOUND", f"Task {task_id} not found"))
                return

            current_assigned = snapshot.get("assigned_to")

            if current_assigned == assigned_to:
                self._send_json(200, _ok(snapshot))
                return

            event = create_event(
                type="assignment_changed",
                task_id=task_id,
                actor=actor,
                data={"from": current_assigned, "to": assigned_to},
            )
            updated_snapshot = apply_event_to_snapshot(snapshot, event)

            try:
                write_task_event(ld, task_id, [event], updated_snapshot, config)
            except Exception as exc:
                self._send_json(500, _err("WRITE_ERROR", f"Failed to assign task: {exc}"))
                return

            self._send_json(200, _ok(updated_snapshot))

        # ---------------------------------------------------------------
        # POST /api/tasks/<id>/comment — Add Comment
        # ---------------------------------------------------------------

        def _handle_post_task_comment(self, ld: Path, task_id: str) -> None:
            """Handle POST /api/tasks/<id>/comment — add a comment."""
            if not validate_id(task_id, "task"):
                self._send_json(400, _err("INVALID_ID", "Invalid task ID format"))
                return

            body = self._read_request_body()
            if body is None:
                return

            comment_body = body.get("body")
            actor = body.get("actor", "dashboard:web")

            if not comment_body or not isinstance(comment_body, str) or not comment_body.strip():
                self._send_json(400, _err("VALIDATION_ERROR", "Missing or empty 'body' field"))
                return

            if not validate_actor(actor):
                self._send_json(400, _err("VALIDATION_ERROR", f"Invalid actor format: '{actor}'"))
                return

            # Read config for hooks
            config_path = ld / "config.json"
            try:
                config = json.loads(config_path.read_text())
            except (json.JSONDecodeError, OSError) as exc:
                self._send_json(500, _err("READ_ERROR", f"Failed to read config: {exc}"))
                return

            snapshot = _read_snapshot(ld, task_id)
            if snapshot is None:
                self._send_json(404, _err("NOT_FOUND", f"Task {task_id} not found"))
                return

            event = create_event(
                type="comment_added",
                task_id=task_id,
                actor=actor,
                data={"body": comment_body.strip()},
            )
            updated_snapshot = apply_event_to_snapshot(snapshot, event)

            try:
                write_task_event(ld, task_id, [event], updated_snapshot, config)
            except Exception as exc:
                self._send_json(500, _err("WRITE_ERROR", f"Failed to add comment: {exc}"))
                return

            self._send_json(200, _ok(updated_snapshot))

        # ---------------------------------------------------------------
        # POST /api/tasks/<id>/update — Edit Fields
        # ---------------------------------------------------------------

        _UPDATABLE_FIELDS = frozenset(
            {"title", "description", "priority", "urgency", "type", "tags"}
        )

        def _handle_post_task_update(self, ld: Path, task_id: str) -> None:
            """Handle POST /api/tasks/<id>/update — edit task fields."""
            if not validate_id(task_id, "task"):
                self._send_json(400, _err("INVALID_ID", "Invalid task ID format"))
                return

            body = self._read_request_body()
            if body is None:
                return

            fields = body.get("fields")
            actor = body.get("actor", "dashboard:web")

            if not fields or not isinstance(fields, dict):
                self._send_json(
                    400, _err("VALIDATION_ERROR", "Missing or invalid 'fields' object")
                )
                return

            if not validate_actor(actor):
                self._send_json(400, _err("VALIDATION_ERROR", f"Invalid actor format: '{actor}'"))
                return

            # Read config for validation
            config_path = ld / "config.json"
            try:
                config = json.loads(config_path.read_text())
            except (json.JSONDecodeError, OSError) as exc:
                self._send_json(500, _err("READ_ERROR", f"Failed to read config: {exc}"))
                return

            # Validate field names
            unknown = set(fields.keys()) - self._UPDATABLE_FIELDS
            if unknown:
                valid = ", ".join(sorted(self._UPDATABLE_FIELDS))
                self._send_json(
                    400,
                    _err(
                        "VALIDATION_ERROR",
                        f"Unknown fields: {', '.join(sorted(unknown))}. Updatable: {valid}",
                    ),
                )
                return

            # Validate field values
            if "priority" in fields and fields["priority"] not in VALID_PRIORITIES:
                valid = ", ".join(VALID_PRIORITIES)
                self._send_json(
                    400,
                    _err(
                        "VALIDATION_ERROR",
                        f"Invalid priority: '{fields['priority']}'. Valid: {valid}",
                    ),
                )
                return

            if "urgency" in fields and fields["urgency"] not in VALID_URGENCIES:
                valid = ", ".join(VALID_URGENCIES)
                self._send_json(
                    400,
                    _err(
                        "VALIDATION_ERROR",
                        f"Invalid urgency: '{fields['urgency']}'. Valid: {valid}",
                    ),
                )
                return

            if "type" in fields and not validate_task_type(config, fields["type"]):
                valid = ", ".join(config.get("task_types", []))
                self._send_json(
                    400,
                    _err(
                        "VALIDATION_ERROR",
                        f"Invalid task type: '{fields['type']}'. Valid: {valid}",
                    ),
                )
                return

            if "title" in fields:
                if not isinstance(fields["title"], str) or not fields["title"].strip():
                    self._send_json(
                        400, _err("VALIDATION_ERROR", "Title must be a non-empty string")
                    )
                    return

            if "tags" in fields:
                if not isinstance(fields["tags"], list):
                    self._send_json(400, _err("VALIDATION_ERROR", "'tags' must be an array"))
                    return

            snapshot = _read_snapshot(ld, task_id)
            if snapshot is None:
                self._send_json(404, _err("NOT_FOUND", f"Task {task_id} not found"))
                return

            # Build events for changed fields
            shared_ts = utc_now()
            events: list[dict] = []

            for field, new_value in fields.items():
                if field == "tags":
                    old_value = snapshot.get("tags") or []
                else:
                    old_value = snapshot.get(field)

                if old_value == new_value:
                    continue

                events.append(
                    create_event(
                        type="field_updated",
                        task_id=task_id,
                        actor=actor,
                        data={"field": field, "from": old_value, "to": new_value},
                        ts=shared_ts,
                    )
                )

            if not events:
                self._send_json(200, _ok(snapshot))
                return

            # Apply events incrementally
            updated_snapshot = snapshot
            for event in events:
                updated_snapshot = apply_event_to_snapshot(updated_snapshot, event)

            try:
                write_task_event(ld, task_id, events, updated_snapshot, config)
            except Exception as exc:
                self._send_json(500, _err("WRITE_ERROR", f"Failed to update task: {exc}"))
                return

            self._send_json(200, _ok(updated_snapshot))

        # ---------------------------------------------------------------
        # POST /api/tasks/<id>/archive — Archive Task
        # ---------------------------------------------------------------

        def _handle_post_task_archive(self, ld: Path, task_id: str) -> None:
            """Handle POST /api/tasks/<id>/archive — archive a task."""
            if not validate_id(task_id, "task"):
                self._send_json(400, _err("INVALID_ID", "Invalid task ID format"))
                return

            body = self._read_request_body()
            if body is None:
                return

            actor = body.get("actor", "dashboard:web")

            if not validate_actor(actor):
                self._send_json(400, _err("VALIDATION_ERROR", f"Invalid actor format: '{actor}'"))
                return

            # Read config for hooks
            config_path = ld / "config.json"
            try:
                config = json.loads(config_path.read_text())
            except (json.JSONDecodeError, OSError) as exc:
                self._send_json(500, _err("READ_ERROR", f"Failed to read config: {exc}"))
                return

            # Check if already archived
            archive_check = ld / "archive" / "tasks" / f"{task_id}.json"
            if archive_check.is_file():
                self._send_json(400, _err("CONFLICT", f"Task {task_id} is already archived"))
                return

            event = None  # will be set inside the lock
            try:
                locks_dir = ld / "locks"
                lock_keys = sorted([f"events_{task_id}", f"tasks_{task_id}", "events__lifecycle"])

                with multi_lock(locks_dir, lock_keys):
                    snapshot = _read_snapshot(ld, task_id)
                    if snapshot is None:
                        self._send_json(404, _err("NOT_FOUND", f"Task {task_id} not found"))
                        return

                    event = create_event(
                        type="task_archived",
                        task_id=task_id,
                        actor=actor,
                        data={},
                    )
                    updated_snapshot = apply_event_to_snapshot(snapshot, event)

                    # 1. Append event to per-task log
                    event_path = ld / "events" / f"{task_id}.jsonl"
                    jsonl_append(event_path, serialize_event(event))

                    # 2. Append to lifecycle log
                    lifecycle_path = ld / "events" / "_lifecycle.jsonl"
                    jsonl_append(lifecycle_path, serialize_event(event))

                    # 3. Write snapshot to archive
                    atomic_write(
                        ld / "archive" / "tasks" / f"{task_id}.json",
                        serialize_snapshot(updated_snapshot),
                    )

                    # 4. Remove active snapshot
                    snapshot_path = ld / "tasks" / f"{task_id}.json"
                    if snapshot_path.exists():
                        snapshot_path.unlink()

                    # 5. Move event log to archive
                    if event_path.exists():
                        shutil.move(
                            str(event_path),
                            str(ld / "archive" / "events" / f"{task_id}.jsonl"),
                        )

                    # 6. Move notes if they exist
                    notes_path = ld / "notes" / f"{task_id}.md"
                    if notes_path.exists():
                        shutil.move(
                            str(notes_path),
                            str(ld / "archive" / "notes" / f"{task_id}.md"),
                        )

            except Exception as exc:
                self._send_json(500, _err("WRITE_ERROR", f"Failed to archive task: {exc}"))
                return

            # Fire hooks after locks released
            if event is not None:
                execute_hooks(config, ld, task_id, event)

            self._send_json(200, _ok({"message": f"Task {task_id} archived"}))

    return LatticeHandler


# ---------------------------------------------------------------------------
# File-reading helpers (no locking needed — read-only)
# ---------------------------------------------------------------------------


def _read_snapshot(ld: Path, task_id: str) -> dict | None:
    path = ld / "tasks" / f"{task_id}.json"
    if not path.is_file():
        return None
    try:
        return json.loads(path.read_text())
    except (json.JSONDecodeError, OSError):
        return None


def _read_snapshot_archive(ld: Path, task_id: str) -> dict | None:
    path = ld / "archive" / "tasks" / f"{task_id}.json"
    if not path.is_file():
        return None
    try:
        return json.loads(path.read_text())
    except (json.JSONDecodeError, OSError):
        return None


def _read_task_events(ld: Path, task_id: str) -> list[dict] | None:
    path = ld / "events" / f"{task_id}.jsonl"
    if not path.is_file():
        return None
    events: list[dict] = []
    try:
        for line in path.read_text().splitlines():
            line = line.strip()
            if line:
                try:
                    events.append(json.loads(line))
                except json.JSONDecodeError:
                    continue
    except OSError:
        return None
    return events


def _read_task_events_archive(ld: Path, task_id: str) -> list[dict] | None:
    path = ld / "archive" / "events" / f"{task_id}.jsonl"
    if not path.is_file():
        return None
    events: list[dict] = []
    try:
        for line in path.read_text().splitlines():
            line = line.strip()
            if line:
                try:
                    events.append(json.loads(line))
                except json.JSONDecodeError:
                    continue
    except OSError:
        return None
    return events


def _read_artifact_info(ld: Path, snapshot: dict) -> list[dict]:
    artifacts: list[dict] = []
    for art_id in snapshot.get("artifact_refs", []):
        meta_path = ld / "artifacts" / "meta" / f"{art_id}.json"
        info: dict = {"id": art_id}
        if meta_path.is_file():
            try:
                meta = json.loads(meta_path.read_text())
                info["title"] = meta.get("title")
                info["type"] = meta.get("type")
            except (json.JSONDecodeError, OSError):
                pass
        artifacts.append(info)
    return artifacts


# ---------------------------------------------------------------------------
# Server factory
# ---------------------------------------------------------------------------


def create_server(
    lattice_dir: Path, host: str, port: int, *, readonly: bool = False
) -> HTTPServer:
    """Create an HTTP server bound to *host*:*port* serving the Lattice dashboard.

    Parameters
    ----------
    lattice_dir:
        Path to the ``.lattice/`` directory (not the project root).
    host:
        Bind address (e.g. ``"127.0.0.1"``).
    port:
        TCP port to listen on.
    readonly:
        If ``True``, all POST requests return 403 FORBIDDEN.
    """
    handler_cls = _make_handler_class(lattice_dir, readonly=readonly)
    server = HTTPServer((host, port), handler_cls)
    return server
