"""Build documentation as a Cacao app."""

import json
from pathlib import Path
from typing import Any

from .types import (
    ClassDoc,
    DocType,
    FunctionDoc,
    MethodDoc,
    ModuleDoc,
    PageDoc,
    ParsedDocstring,
)


def _serialize_docstring(docstring: ParsedDocstring) -> dict[str, Any]:
    result: dict[str, Any] = {
        "summary": docstring.summary,
        "description": docstring.description,
        "doc_type": docstring.doc_type.value,
        # Function sections
        "args": [
            {
                "name": a.name,
                "type": a.type,
                "description": a.description,
                "default": a.default,
                "required": a.required,
            }
            for a in docstring.args
        ],
        "returns": (
            {
                "type": docstring.returns.type,
                "description": docstring.returns.description,
            }
            if docstring.returns
            else None
        ),
        "raises": [
            {"type": r.type, "description": r.description} for r in docstring.raises
        ],
        "examples": docstring.examples,
        "attributes": [
            {
                "name": a.name,
                "type": a.type,
                "description": a.description,
                "default": a.default,
                "required": a.required,
            }
            for a in docstring.attributes
        ],
        "notes": docstring.notes,
    }

    # API sections
    if docstring.doc_type == DocType.API or docstring.http_method or docstring.path:
        result["http_method"] = docstring.http_method
        result["path"] = docstring.path
        result["path_params"] = [
            {
                "name": a.name,
                "type": a.type,
                "description": a.description,
                "default": a.default,
                "required": a.required,
            }
            for a in docstring.path_params
        ]
        result["query_params"] = [
            {
                "name": a.name,
                "type": a.type,
                "description": a.description,
                "default": a.default,
                "required": a.required,
            }
            for a in docstring.query_params
        ]
        result["request_body"] = [
            {
                "name": a.name,
                "type": a.type,
                "description": a.description,
                "default": a.default,
                "required": a.required,
            }
            for a in docstring.request_body
        ]
        result["responses"] = [
            {
                "status_code": r.status_code,
                "description": r.description,
                "fields": [
                    {"name": f.name, "type": f.type, "description": f.description}
                    for f in r.fields
                ],
            }
            for r in docstring.responses
        ]
        result["headers"] = [
            {
                "name": h.name,
                "description": h.description,
                "required": h.required,
                "example": h.example,
            }
            for h in docstring.headers
        ]

    # Event sections
    if docstring.doc_type == DocType.EVENT or docstring.trigger or docstring.payload:
        result["trigger"] = docstring.trigger
        result["payload"] = [
            {"name": f.name, "type": f.type, "description": f.description}
            for f in docstring.payload
        ]

    # Config sections
    if docstring.doc_type == DocType.CONFIG or docstring.config_fields:
        result["config_fields"] = [
            {
                "name": f.name,
                "type": f.type,
                "description": f.description,
                "default": f.default,
                "required": f.required,
                "env_var": f.env_var,
            }
            for f in docstring.config_fields
        ]

    # Custom sections
    if docstring.custom_sections:
        result["custom_sections"] = docstring.custom_sections

    return result


def _serialize_method(method: MethodDoc) -> dict[str, Any]:
    return {
        "name": method.name,
        "module": method.module,
        "signature": method.signature,
        "docstring": _serialize_docstring(method.docstring),
        "is_async": method.is_async,
        "is_classmethod": method.is_classmethod,
        "is_staticmethod": method.is_staticmethod,
        "is_property": method.is_property,
        "source": method.source,
        "line_number": method.line_number,
        "decorators": method.decorators,
        "calls": method.calls,
        "doc_type": method.doc_type.value,
        "signature_hash": method.signature_hash,
        "body_hash": method.body_hash,
        "complexity": method.complexity,
        "is_deprecated": method.is_deprecated,
        "deprecation_message": method.deprecation_message,
        "deprecation_since": method.deprecation_since,
        "category": method.category,
        "version": method.version,
        "hidden": method.hidden,
    }


def _serialize_function(func: FunctionDoc) -> dict[str, Any]:
    return {
        "name": func.name,
        "module": func.module,
        "full_path": func.full_path,
        "signature": func.signature,
        "docstring": _serialize_docstring(func.docstring),
        "is_async": func.is_async,
        "source": func.source,
        "line_number": func.line_number,
        "decorators": func.decorators,
        "calls": func.calls,
        "doc_type": func.doc_type.value,
        "signature_hash": func.signature_hash,
        "body_hash": func.body_hash,
        "complexity": func.complexity,
        "is_deprecated": func.is_deprecated,
        "deprecation_message": func.deprecation_message,
        "deprecation_since": func.deprecation_since,
        "category": func.category,
        "version": func.version,
        "hidden": func.hidden,
    }


def _serialize_class(cls: ClassDoc) -> dict[str, Any]:
    return {
        "name": cls.name,
        "module": cls.module,
        "full_path": cls.full_path,
        "docstring": _serialize_docstring(cls.docstring),
        "bases": cls.bases,
        "methods": [_serialize_method(m) for m in cls.methods],
        "source": cls.source,
        "line_number": cls.line_number,
        "decorators": cls.decorators,
        "doc_type": cls.doc_type.value,
        "signature_hash": cls.signature_hash,
        "body_hash": cls.body_hash,
    }


def _serialize_module(module: ModuleDoc) -> dict[str, Any]:
    return {
        "name": module.name,
        "full_path": module.full_path,
        "file_path": module.file_path,
        "docstring": module.docstring,
        "classes": [_serialize_class(c) for c in module.classes],
        "functions": [_serialize_function(f) for f in module.functions],
        "todos": [
            {
                "tag": t.tag,
                "text": t.text,
                "file_path": t.file_path,
                "line_number": t.line_number,
                "module": t.module,
            }
            for t in module.todos
        ],
    }


def _serialize_page(page: PageDoc) -> dict[str, Any]:
    return {
        "title": page.title,
        "slug": page.slug,
        "content": page.content,
        "file_path": page.file_path,
        "order": page.order,
    }


def _compute_changes(
    old_data: dict[str, Any], new_data: dict[str, Any]
) -> list[dict[str, Any]]:
    """Compare old and new build data to detect function/endpoint changes.

    Returns a list of change records with keys:
        - full_path: The function/class identifier
        - name: Display name
        - doc_type: function, api, class, etc.
        - change: "new", "removed", "signature", "body", "signature+body"
    """
    changes: list[dict[str, Any]] = []

    def _index_items(data: dict[str, Any]) -> dict[str, dict[str, str]]:
        """Build a lookup: full_path -> {signature_hash, body_hash, name, doc_type}."""
        index: dict[str, dict[str, str]] = {}
        for item in data.get("functions", []):
            index[item["full_path"]] = {
                "signature_hash": item.get("signature_hash", ""),
                "body_hash": item.get("body_hash", ""),
                "name": item["name"],
                "doc_type": item.get("doc_type", "function"),
            }
        for item in data.get("api_endpoints", []):
            index[item["full_path"]] = {
                "signature_hash": item.get("signature_hash", ""),
                "body_hash": item.get("body_hash", ""),
                "name": item["name"],
                "doc_type": item.get("doc_type", "api"),
            }
        for item in data.get("classes", []):
            index[item["full_path"]] = {
                "signature_hash": item.get("signature_hash", ""),
                "body_hash": item.get("body_hash", ""),
                "name": item["name"],
                "doc_type": item.get("doc_type", "class"),
            }
            for method in item.get("methods", []):
                method_path = f"{item['full_path']}.{method['name']}"
                index[method_path] = {
                    "signature_hash": method.get("signature_hash", ""),
                    "body_hash": method.get("body_hash", ""),
                    "name": method["name"],
                    "doc_type": method.get("doc_type", "function"),
                }
        return index

    old_index = _index_items(old_data)
    new_index = _index_items(new_data)

    all_paths = set(old_index) | set(new_index)

    for path in sorted(all_paths):
        old = old_index.get(path)
        new = new_index.get(path)

        if new and not old:
            changes.append(
                {
                    "full_path": path,
                    "name": new["name"],
                    "doc_type": new["doc_type"],
                    "change": "new",
                }
            )
        elif old and not new:
            changes.append(
                {
                    "full_path": path,
                    "name": old["name"],
                    "doc_type": old["doc_type"],
                    "change": "removed",
                }
            )
        elif old and new:
            sig_changed = old["signature_hash"] != new["signature_hash"]
            body_changed = old["body_hash"] != new["body_hash"]
            if sig_changed and body_changed:
                change_type = "signature+body"
            elif sig_changed:
                change_type = "signature"
            elif body_changed:
                change_type = "body"
            else:
                continue
            changes.append(
                {
                    "full_path": path,
                    "name": new["name"],
                    "doc_type": new["doc_type"],
                    "change": change_type,
                }
            )

    return changes


def _detect_breaking_changes(
    old_data: dict[str, Any], new_data: dict[str, Any]
) -> list[dict[str, Any]]:
    """Detect breaking vs non-breaking signature changes between builds.

    Compares function arguments to classify changes as:
        - arg_removed: An argument was removed
        - arg_renamed: An argument name changed
        - type_changed: An argument's type annotation changed
        - required_arg_added: A new argument without a default was added
        - optional_arg_added: A new argument with a default was added (non-breaking)
        - return_type_changed: Return type annotation changed
    """
    breaking: list[dict[str, Any]] = []

    def _index_with_args(data: dict[str, Any]) -> dict[str, dict[str, Any]]:
        index: dict[str, dict[str, Any]] = {}
        for lst in ("functions", "api_endpoints"):
            for item in data.get(lst, []):
                sig = item.get("signature", "")
                sig_params = _parse_signature_params(sig)
                # Extract return type from signature
                ret_type = ""
                arrow_pos = sig.rfind("->")
                if arrow_pos != -1:
                    ret_type = sig[arrow_pos + 2 :].strip()
                index[item["full_path"]] = {
                    "name": item["name"],
                    "doc_type": item.get("doc_type", "function"),
                    "params": {p["name"]: p for p in sig_params},
                    "return_type": ret_type,
                    "signature_hash": item.get("signature_hash", ""),
                }
        return index

    old_idx = _index_with_args(old_data)
    new_idx = _index_with_args(new_data)

    for path in set(old_idx) & set(new_idx):
        old = old_idx[path]
        new = new_idx[path]

        if old["signature_hash"] == new["signature_hash"]:
            continue

        details: list[dict[str, Any]] = []

        old_params = old["params"]
        new_params = new["params"]

        # Removed params
        for name in old_params:
            if name not in new_params:
                details.append({"type": "arg_removed", "arg": name, "breaking": True})

        # New params
        for name in new_params:
            if name not in old_params:
                has_default = new_params[name].get("default") is not None
                if has_default:
                    details.append(
                        {"type": "optional_arg_added", "arg": name, "breaking": False}
                    )
                else:
                    details.append(
                        {"type": "required_arg_added", "arg": name, "breaking": True}
                    )

        # Type changes on existing params
        for name in old_params:
            if name in new_params:
                old_type = old_params[name].get("type", "")
                new_type = new_params[name].get("type", "")
                if old_type and new_type and old_type != new_type:
                    details.append(
                        {
                            "type": "type_changed",
                            "arg": name,
                            "from": old_type,
                            "to": new_type,
                            "breaking": True,
                        }
                    )

        # Return type change
        old_ret = old.get("return_type", "")
        new_ret = new.get("return_type", "")
        if old_ret and new_ret and old_ret != new_ret:
            details.append(
                {
                    "type": "return_type_changed",
                    "from": old_ret,
                    "to": new_ret,
                    "breaking": True,
                }
            )

        if details:
            is_breaking = any(d.get("breaking") for d in details)
            breaking.append(
                {
                    "full_path": path,
                    "name": new["name"],
                    "doc_type": new["doc_type"],
                    "is_breaking": is_breaking,
                    "details": details,
                }
            )

    return breaking


def _parse_signature_params(signature: str) -> list[dict[str, str]]:
    """Extract parameter names, types, and defaults from a signature string.

    Parses "(self, x: int, y: str = 'hi', *args, **kwargs) -> bool"
    into a list of param dicts, excluding 'self' and 'cls'.
    """
    params: list[dict[str, str]] = []
    if not signature:
        return params

    # Strip outer parens and return annotation
    inner = signature.strip()
    if inner.startswith("("):
        # Find matching closing paren (handles nested generics)
        depth = 0
        end = 0
        for i, ch in enumerate(inner):
            if ch == "(":
                depth += 1
            elif ch == ")":
                depth -= 1
                if depth == 0:
                    end = i
                    break
        inner = inner[1:end]

    # Extract return type
    rest = signature.strip()
    arrow_pos = rest.rfind("->")
    if arrow_pos != -1:
        rest[arrow_pos + 2 :].strip()

    if not inner.strip():
        return params

    # Split on commas, but respect brackets/parens
    parts: list[str] = []
    depth = 0
    current: list[str] = []
    for ch in inner:
        if ch in "([{":
            depth += 1
        elif ch in ")]}":
            depth -= 1
        if ch == "," and depth == 0:
            parts.append("".join(current).strip())
            current = []
        else:
            current.append(ch)
    if current:
        parts.append("".join(current).strip())

    for part in parts:
        part = part.strip()
        if not part or part in ("/", "*"):
            continue

        name = part
        ptype = ""
        default = None

        # Handle *args, **kwargs
        if part.startswith("**"):
            part = part[2:]
        elif part.startswith("*"):
            part = part[1:]

        # Split on = for default
        eq_pos = part.find("=")
        if eq_pos != -1:
            default = part[eq_pos + 1 :].strip()
            part = part[:eq_pos].strip()

        # Split on : for type
        colon_pos = part.find(":")
        if colon_pos != -1:
            name = part[:colon_pos].strip()
            ptype = part[colon_pos + 1 :].strip()
        else:
            name = part.strip()

        # Skip self/cls
        if name in ("self", "cls"):
            continue

        params.append({"name": name, "type": ptype, "default": default or ""})

    return params


def _compute_coverage(json_data: dict[str, Any]) -> dict[str, Any]:
    """Compute documentation coverage scores.

    Cross-references actual signature params with documented Args.
    """
    items: list[dict[str, Any]] = []
    total_score = 0.0
    count = 0

    for lst in ("functions", "api_endpoints"):
        for func in json_data.get(lst, []):
            ds = func.get("docstring", {})
            sig = func.get("signature", "")

            sig_params = _parse_signature_params(sig)
            sig_param_names = {p["name"] for p in sig_params}
            doc_arg_names = {a["name"] for a in ds.get("args", [])}

            has_docstring = bool(ds.get("summary"))
            has_returns = ds.get("returns") is not None
            has_examples = bool(ds.get("examples"))

            # Args coverage: what fraction of signature params are documented?
            if sig_param_names:
                documented = sig_param_names & doc_arg_names
                args_ratio = len(documented) / len(sig_param_names)
                missing_args = sorted(sig_param_names - doc_arg_names)
            else:
                args_ratio = 1.0  # no params to document
                missing_args = []

            # Has return type in signature?
            has_return_annotation = "->" in sig

            checks = {
                "has_docstring": has_docstring,
                "args_documented": len(sig_param_names & doc_arg_names),
                "args_total": len(sig_param_names),
                "args_missing": missing_args,
                "has_returns": has_returns,
                "has_return_annotation": has_return_annotation,
                "has_examples": has_examples,
            }

            # Score: docstring=35%, args=35%, returns=20%, examples=10%
            score = 0.0
            if has_docstring:
                score += 35
            score += 35 * args_ratio
            if has_returns:
                score += 20
            if has_examples:
                score += 10

            score = round(score, 1)

            items.append(
                {
                    "full_path": func.get("full_path", func["name"]),
                    "name": func["name"],
                    "doc_type": func.get("doc_type", "function"),
                    "score": score,
                    "checks": checks,
                }
            )
            total_score += score
            count += 1

    for cls in json_data.get("classes", []):
        ds = cls.get("docstring", {})

        has_docstring = bool(ds.get("summary"))
        has_attributes = bool(ds.get("attributes"))

        methods = cls.get("methods", [])
        documented_methods = sum(
            1 for m in methods if m.get("docstring", {}).get("summary")
        )
        method_ratio = (documented_methods / len(methods)) if methods else 1.0

        checks = {
            "has_docstring": has_docstring,
            "has_attributes": has_attributes,
            "methods_documented": documented_methods,
            "methods_total": len(methods),
        }

        score = 0.0
        if has_docstring:
            score += 40
        if has_attributes:
            score += 25
        score += 35 * method_ratio
        score = round(score, 1)

        items.append(
            {
                "full_path": cls.get("full_path", cls["name"]),
                "name": cls["name"],
                "doc_type": "class",
                "score": score,
                "checks": checks,
            }
        )
        total_score += score
        count += 1

    return {
        "project_score": round(total_score / count, 1) if count else 0,
        "total_items": count,
        "fully_documented": sum(1 for i in items if i["score"] == 100),
        "undocumented": sum(1 for i in items if i["score"] == 0),
        "items": items,
    }


def _build_reverse_call_map(json_data: dict[str, Any]) -> dict[str, list[str]]:
    """Invert the call graph: for each function, who calls it.

    Returns a dict mapping function name -> list of callers.
    """
    called_by: dict[str, list[str]] = {}

    for lst in ("functions", "api_endpoints"):
        for func in json_data.get(lst, []):
            caller = func.get("full_path", func["name"])
            for callee in func.get("calls", []):
                called_by.setdefault(callee, []).append(caller)

    for cls in json_data.get("classes", []):
        for method in cls.get("methods", []):
            caller = f"{cls.get('full_path', cls['name'])}.{method['name']}"
            for callee in method.get("calls", []):
                called_by.setdefault(callee, []).append(caller)

    # Sort each list for determinism
    return {k: sorted(v) for k, v in sorted(called_by.items())}


def _detect_dead_code(json_data: dict[str, Any]) -> list[dict[str, Any]]:
    """Find functions/methods that are never called by anything internal.

    Excludes: __init__, __main__, decorated endpoints, test functions,
    and anything starting with 'main'.
    """
    called_by = json_data.get("called_by", {})
    dead: list[dict[str, Any]] = []

    # Build set of all callee names (both full_path and short name forms)
    all_called: set[str] = set()
    for callee in called_by:
        all_called.add(callee)

    # Exclusion patterns
    skip_names = {
        "__init__",
        "__main__",
        "main",
        "__str__",
        "__repr__",
        "__eq__",
        "__hash__",
        "__len__",
        "__iter__",
        "__next__",
        "__enter__",
        "__exit__",
        "__call__",
        "__getattr__",
        "__setattr__",
        "__delattr__",
        "__getitem__",
        "__setitem__",
        "__contains__",
        "__bool__",
    }

    for lst in ("functions", "api_endpoints"):
        for func in json_data.get(lst, []):
            name = func["name"]
            full_path = func.get("full_path", name)

            # Skip private, dunder, decorated (likely entry points), tests
            if name.startswith("_"):
                continue
            if name in skip_names:
                continue
            if func.get("decorators"):
                continue
            if name.startswith("test"):
                continue

            # Check if anyone calls this
            if full_path not in all_called and name not in all_called:
                dead.append(
                    {
                        "full_path": full_path,
                        "name": name,
                        "doc_type": func.get("doc_type", "function"),
                        "module": func.get("module", ""),
                    }
                )

    for cls in json_data.get("classes", []):
        for method in cls.get("methods", []):
            name = method["name"]
            if name.startswith("_") or name in skip_names:
                continue
            if method.get("decorators"):
                continue

            method_path = f"{cls.get('full_path', cls['name'])}.{name}"
            if method_path not in all_called and name not in all_called:
                dead.append(
                    {
                        "full_path": method_path,
                        "name": name,
                        "doc_type": "method",
                        "module": cls.get("module", ""),
                    }
                )

    return dead


def _collect_todos(json_data: dict[str, Any]) -> list[dict[str, Any]]:
    """Collect all TODOs from all modules into a flat list."""
    todos: list[dict[str, Any]] = []
    for module in json_data.get("modules", []):
        todos.extend(module.get("todos", []))
    return todos


def build_json(
    modules: list[ModuleDoc], pages: list[PageDoc], config: dict[str, Any]
) -> dict[str, Any]:
    """Build JSON documentation structure."""
    all_classes = []
    all_functions = []

    for module in modules:
        for cls in module.classes:
            all_classes.append(_serialize_class(cls))
        for func in module.functions:
            all_functions.append(_serialize_function(func))

    # Separate API endpoints from regular functions
    api_endpoints = [f for f in all_functions if f["doc_type"] == "api"]
    regular_functions = [f for f in all_functions if f["doc_type"] != "api"]

    # Apply page_order from config if provided
    page_order = config.get("page_order", [])
    if page_order:
        order_map = {slug: i for i, slug in enumerate(page_order)}
        pages = sorted(
            pages,
            key=lambda p: (
                order_map.get(p.slug, len(page_order)),
                p.order,
                p.title,
            ),
        )

    json_data = {
        "modules": [_serialize_module(m) for m in modules],
        "classes": all_classes,
        "functions": regular_functions,
        "api_endpoints": api_endpoints,
        "pages": [_serialize_page(p) for p in pages],
        "config": config,
    }

    # Coverage scores
    json_data["coverage"] = _compute_coverage(json_data)

    # Reverse call map
    json_data["called_by"] = _build_reverse_call_map(json_data)

    # Flat TODO list
    json_data["todos"] = _collect_todos(json_data)

    # Dead code detection
    json_data["dead_code"] = _detect_dead_code(json_data)

    return json_data


def _chunk_documentation(json_data: dict[str, Any]) -> list[dict[str, str]]:
    """Convert documentation JSON into text chunks for embedding.

    Each chunk has: text, source (identifier), type (function/class/api/page).
    """
    chunks: list[dict[str, str]] = []

    for func in json_data.get("functions", []):
        ds = func.get("docstring", {})
        parts = [f"Function: {func['name']}"]
        if func.get("signature"):
            parts[0] += func["signature"]
        if ds.get("summary"):
            parts.append(ds["summary"])
        if ds.get("description"):
            parts.append(ds["description"])
        for arg in ds.get("args", []):
            parts.append(
                f"  param {arg['name']}: {arg.get('type', '')} - {arg.get('description', '')}"
            )
        ret = ds.get("returns")
        if ret:
            parts.append(
                f"  returns: {ret.get('type', '')} - {ret.get('description', '')}"
            )
        for ex in ds.get("examples", []):
            parts.append(f"  example: {ex}")
        chunks.append(
            {
                "text": "\n".join(parts),
                "source": func.get("full_path", func["name"]),
                "type": "function",
            }
        )

    for ep in json_data.get("api_endpoints", []):
        ds = ep.get("docstring", {})
        method = ds.get("http_method", "")
        path = ds.get("path", "")
        parts = [f"API: {method} {path}"]
        if ds.get("summary"):
            parts.append(ds["summary"])
        if ds.get("description"):
            parts.append(ds["description"])
        for param in ds.get("path_params", []) + ds.get("query_params", []):
            parts.append(
                f"  param {param['name']}: {param.get('type', '')} - {param.get('description', '')}"
            )
        chunks.append(
            {
                "text": "\n".join(parts),
                "source": f"{method} {path}"
                if method
                else ep.get("full_path", ep["name"]),
                "type": "api",
            }
        )

    for cls in json_data.get("classes", []):
        ds = cls.get("docstring", {})
        parts = [f"Class: {cls['name']}"]
        if ds.get("summary"):
            parts.append(ds["summary"])
        if ds.get("description"):
            parts.append(ds["description"])
        for attr in ds.get("attributes", []):
            parts.append(
                f"  attr {attr['name']}: {attr.get('type', '')} - {attr.get('description', '')}"
            )
        for m in cls.get("methods", []):
            mds = m.get("docstring", {})
            parts.append(f"  method {m['name']}: {mds.get('summary', '')}")
        chunks.append(
            {
                "text": "\n".join(parts),
                "source": cls.get("full_path", cls["name"]),
                "type": "class",
            }
        )

    for page in json_data.get("pages", []):
        # Split long pages into ~2000 char paragraphs
        content = page.get("content", "")
        # Strip HTML tags for embedding
        import re

        clean = re.sub(r"<[^>]+>", " ", content)
        clean = re.sub(r"\s+", " ", clean).strip()
        if not clean:
            continue
        # Chunk long pages
        max_chunk = 2000
        if len(clean) <= max_chunk:
            chunks.append(
                {
                    "text": f"Page: {page['title']}\n{clean}",
                    "source": page["title"],
                    "type": "page",
                }
            )
        else:
            sentences = clean.split(". ")
            current = f"Page: {page['title']}\n"
            for sent in sentences:
                if len(current) + len(sent) > max_chunk:
                    chunks.append(
                        {
                            "text": current.strip(),
                            "source": page["title"],
                            "type": "page",
                        }
                    )
                    current = f"Page: {page['title']} (cont.)\n"
                current += sent + ". "
            if current.strip():
                chunks.append(
                    {
                        "text": current.strip(),
                        "source": page["title"],
                        "type": "page",
                    }
                )

    return chunks


def _embed_chunks(
    chunks: list[dict[str, str]], embedding_model: str
) -> dict[str, Any] | None:
    """Embed text chunks using Prompture's embedding driver.

    Returns the embeddings dict, or None if embedding fails.
    """
    if not chunks:
        return None

    try:
        from prompture.drivers.embedding_registry import get_embedding_driver_for_model  # type: ignore[import-not-found]
    except ImportError:
        return None

    try:
        driver = get_embedding_driver_for_model(embedding_model)
        texts = [c["text"] for c in chunks]
        result = driver.embed(texts, {})
        embeddings = result.get("embeddings", [])
        if not embeddings:
            return None

        return {
            "model": embedding_model,
            "dimensions": result.get("meta", {}).get("dimensions", len(embeddings[0])),
            "chunks": [
                {
                    "text": chunks[i]["text"],
                    "source": chunks[i]["source"],
                    "type": chunks[i]["type"],
                    "embedding": embeddings[i],
                }
                for i in range(len(chunks))
            ],
        }
    except Exception as e:
        import logging

        logging.getLogger("cacaodocs").warning("Embedding failed: %s", e)
        return None


def _is_light_color(hex_color: str) -> bool:
    """Check if a hex color is light based on luminance."""
    hex_color = hex_color.lstrip("#")
    if len(hex_color) == 3:
        hex_color = "".join(c * 2 for c in hex_color)
    try:
        r, g, b = (
            int(hex_color[0:2], 16),
            int(hex_color[2:4], 16),
            int(hex_color[4:6], 16),
        )
        luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
        return luminance > 0.5
    except (ValueError, IndexError):
        return False


def _generate_app_code(json_data: dict[str, Any]) -> str:
    """Generate the Cacao app Python code for the documentation."""
    from . import __version__ as self_version

    config = json_data.get("config", {})
    title = config.get("title", "Documentation")
    description = config.get("description", "")
    raw_theme = config.get("theme", "dark")
    # Theme can be a dict of colors (from cacao.yaml) — normalize to "dark"/"light"
    if isinstance(raw_theme, dict):
        # Guess from bg_color: light backgrounds → "light", else "dark"
        bg = raw_theme.get("bg_color", "#000")
        theme = "light" if _is_light_color(bg) else "dark"
    else:
        theme = raw_theme
    version = config.get("version", "")

    # Chat configuration
    chat_enabled = config.get("chat", False)
    chat_config = config.get("chat_config", {})
    chat_default_model = chat_config.get("default_model", "openai/gpt-4o-mini")
    chat_system_prompt = chat_config.get(
        "system_prompt",
        f"You are a helpful assistant that answers questions about the {title} library. "
        "Be concise and reference specific functions, classes, or modules when relevant.",
    )

    if chat_enabled:
        # Parse provider/model from "provider/model" format
        _parts = chat_default_model.split("/", 1)
        _provider = _parts[0] if len(_parts) > 1 else "openai"
        _model = _parts[1] if len(_parts) > 1 else _parts[0]

        # Build RAG context summary from chunked docs (embedded in system prompt)
        _rag_chunks = _chunk_documentation(json_data)
        _rag_context = ""
        if _rag_chunks:
            _rag_texts = [ch["text"] for ch in _rag_chunks[:50]]  # top 50 chunks
            _rag_context = "\n\nDocumentation reference:\n" + "\n---\n".join(_rag_texts)

        _full_system_prompt = chat_system_prompt + _rag_context

        _chat_state_block = """# --- Chat ---
_show_chat = c.signal(False, name="show_chat")"""

        _chat_nav_item = ""

        _chat_panel_block = f"""
        # --- Chat (Floating Bubble + Modal) ---
        with c.modal(title="Ask about {title}", signal=_show_chat, size="lg"):
            c.chat(
                provider={_provider!r},
                model={_model!r},
                system_prompt={_full_system_prompt!r},
                height="450px",
                show_clear=True,
                placeholder="Ask about functions, classes, usage...",
            )
        c.html(\'\'\'<style>
.cacaodocs-fab {{
    position: fixed;
    bottom: 24px;
    right: 24px;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    background: var(--c-primary, #6366f1);
    color: white;
    border: none;
    cursor: pointer;
    box-shadow: 0 4px 14px rgba(0,0,0,0.3);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 999;
    transition: transform 0.2s, box-shadow 0.2s;
}}
.cacaodocs-fab:hover {{
    transform: scale(1.08);
    box-shadow: 0 6px 20px rgba(0,0,0,0.4);
}}
.cacaodocs-fab svg {{
    width: 24px;
    height: 24px;
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
}}
</style>
<button class="cacaodocs-fab" onclick="window.dispatchEvent(new CustomEvent(\'cacao:signal\', {{detail: {{name: \'show_chat\', value: true}}}}));" aria-label="Open chat">
    <svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</button>\'\'\')"""

        _chat_static_registration = ""
    else:
        _chat_state_block = ""
        _chat_nav_item = ""
        _chat_panel_block = ""
        _chat_static_registration = ""

    code = f'''"""Auto-generated documentation app powered by CacaoDocs + Cacao."""
import json
import os as _os
import cacao as c

# --- Documentation Data ---
_DATA_PATH = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "data.json")
with open(_DATA_PATH, "r", encoding="utf-8") as _f:
    _DATA = json.load(_f)

_PAGES = _DATA["pages"]
_CONFIG = _DATA["config"]


def _has_docstring(item):
    """Check if an item has a meaningful docstring."""
    ds = item.get("docstring")
    if not ds:
        return False
    if isinstance(ds, dict):
        return bool(ds.get("summary") or ds.get("description"))
    if isinstance(ds, str):
        return bool(ds.strip())
    return False


# Filter: only items with actual docstrings
_ALL_MODULES = _DATA["modules"]
_CONTENT_MODULES = [m for m in _ALL_MODULES
                    if m.get("docstring")
                    or any(_has_docstring(c) for c in m.get("classes", []))
                    or any(_has_docstring(f) for f in m.get("functions", []))]

_CLASSES = [c for c in _DATA["classes"] if _has_docstring(c)]
_FUNCTIONS = [f for f in _DATA["functions"] if _has_docstring(f) and not f.get("hidden")]
_API_ENDPOINTS = [e for e in _DATA.get("api_endpoints", []) if _has_docstring(e) and not e.get("hidden")]

# Filter methods within classes
for cls in _CLASSES:
    cls["methods"] = [m for m in cls.get("methods", [])
                      if (_has_docstring(m) or m["name"] == "__init__") and not m.get("hidden")]

for mod in _CONTENT_MODULES:
    mod["classes"] = [c for c in mod.get("classes", []) if _has_docstring(c)]
    mod["functions"] = [f for f in mod.get("functions", []) if _has_docstring(f)]
    for cls in mod["classes"]:
        cls["methods"] = [m for m in cls.get("methods", [])
                          if _has_docstring(m) or m["name"] == "__init__"]

# --- App Config ---
c.config(
    title={title!r},
    theme={theme!r},
)

# --- Plugin Registration ---
_plugin = c.register_plugin(
    "cacaodocs",
    version={self_version!r},
    description="Auto-generated documentation",
)
_plugin.on("on_ready", lambda: (
    c.notify(
        "Documentation loaded",
        f"{{len(_FUNCTIONS)}} functions, {{len(_CLASSES)}} classes, {{len(_API_ENDPOINTS)}} endpoints",
        variant="success",
    ),
    c.emit("docs:loaded", {{
        "functions": len(_FUNCTIONS),
        "classes": len(_CLASSES),
        "endpoints": len(_API_ENDPOINTS),
        "pages": len(_PAGES),
    }}),
))

{_chat_state_block}

{_chat_static_registration}

# --- Helpers ---

_METHOD_COLORS = {{
    "GET": "success",
    "POST": "info",
    "PUT": "warning",
    "PATCH": "warning",
    "DELETE": "danger",
    "OPTIONS": "default",
    "HEAD": "default",
}}


def _render_args_table(args, label="Parameters"):
    """Render a list of args as a table."""
    if not args:
        return
    c.spacer(3)
    c.title(label, level=4)
    c.spacer(1)
    table_data = []
    for arg in args:
        row = {{"Name": arg["name"]}}
        if arg.get("type"):
            row["Type"] = arg["type"]
        row["Description"] = arg.get("description", "")
        if arg.get("default"):
            row["Default"] = arg["default"]
        if arg.get("required"):
            row["Required"] = "Yes"
        if arg.get("env_var"):
            row["Env Var"] = arg["env_var"]
        table_data.append(row)
    c.table(table_data, paginate=False, sortable=False)


def _render_docstring(docstring):
    """Render a parsed docstring."""
    if docstring.get("summary"):
        c.text(docstring["summary"], size="lg")

    if docstring.get("description"):
        c.spacer(2)
        c.text(docstring["description"], color="muted")

    doc_type = docstring.get("doc_type", "function")

    # --- API-specific rendering ---
    if doc_type == "api":
        # Method + Path badge
        method = docstring.get("http_method", "")
        path = docstring.get("path", "")
        if method or path:
            c.spacer(2)
            with c.row(gap=2, wrap=True):
                if method:
                    c.badge(method, color=_METHOD_COLORS.get(method, "default"))
                if path:
                    c.code(path, language="text")

        _render_args_table(docstring.get("path_params", []), "Path Parameters")
        _render_args_table(docstring.get("query_params", []), "Query Parameters")
        _render_args_table(docstring.get("request_body", []), "Request Body")

        # Headers
        headers = docstring.get("headers", [])
        if headers:
            c.spacer(3)
            c.title("Headers", level=4)
            c.spacer(1)
            table_data = []
            for h in headers:
                row = {{"Name": h["name"], "Description": h.get("description", "")}}
                if h.get("required"):
                    row["Required"] = "Yes"
                if h.get("example"):
                    row["Example"] = h["example"]
                table_data.append(row)
            c.table(table_data, paginate=False, sortable=False)

        # Responses
        responses = docstring.get("responses", [])
        if responses:
            c.spacer(3)
            c.title("Responses", level=4)
            c.spacer(1)
            for resp in responses:
                status = resp.get("status_code", "")
                desc = resp.get("description", "")
                color = "success" if 200 <= status < 300 else "warning" if 300 <= status < 400 else "danger"
                with c.card(f"{{status}} {{desc}}"):
                    c.badge(str(status), color=color)
                    if desc:
                        c.text(desc, color="muted")
                    fields = resp.get("fields", [])
                    if fields:
                        _render_args_table(fields, "Fields")

    # --- Event-specific rendering ---
    elif doc_type == "event":
        trigger = docstring.get("trigger", "")
        if trigger:
            c.spacer(2)
            c.title("Trigger", level=4)
            c.text(trigger)

        payload = docstring.get("payload", [])
        if payload:
            _render_args_table(payload, "Payload")

    # --- Config-specific rendering ---
    elif doc_type == "config":
        config_fields = docstring.get("config_fields", [])
        if config_fields:
            _render_args_table(config_fields, "Configuration Fields")

    # --- Standard function/class rendering ---
    _render_args_table(docstring.get("args", []), "Parameters")

    # Returns
    ret = docstring.get("returns")
    if ret:
        c.spacer(3)
        c.title("Returns", level=4)
        c.spacer(1)
        parts = []
        if ret.get("type"):
            parts.append(ret["type"])
        if ret.get("description"):
            parts.append(ret["description"])
        c.text(" — ".join(parts) if parts else "")

    # Raises
    raises = docstring.get("raises", [])
    if raises:
        c.spacer(3)
        c.title("Raises", level=4)
        c.spacer(1)
        for r in raises:
            c.text(f'{{r["type"]}}: {{r.get("description", "")}}')

    # Attributes
    _render_args_table(docstring.get("attributes", []), "Attributes")

    # Examples
    examples = docstring.get("examples", [])
    if examples:
        c.spacer(3)
        c.title("Examples", level=4)
        c.spacer(1)
        for ex in examples:
            c.code(ex, language="python")

    # Notes
    notes = docstring.get("notes", [])
    if notes:
        c.spacer(3)
        for note in notes:
            c.alert(note, type="info")

    # Custom sections
    custom = docstring.get("custom_sections", {{}})
    for section_name, section_content in custom.items():
        c.spacer(3)
        c.title(section_name, level=4)
        c.spacer(1)
        c.text(section_content)


def _render_function_block(func):
    """Render a function as a section."""
    name = func["name"]
    sig = func.get("signature", "()")
    decorators = func.get("decorators", [])
    doc_type = func.get("doc_type", "function")

    c.divider()
    c.spacer(2)
    c.anchor(func.get("full_path", name))

    # For API endpoints, show method + path as header
    ds = func.get("docstring", {{}})
    if doc_type == "api":
        method = ds.get("http_method", "")
        path = ds.get("path", "")
        if method or path:
            with c.row(gap=2, wrap=True):
                if method:
                    c.badge(method, color=_METHOD_COLORS.get(method, "default"))
                c.text(f"{{path or name}}", size="lg")
        else:
            c.code(f"def {{name}}{{sig}}", language="python")
    else:
        prefix = "async " if func.get("is_async") else ""
        c.code(f"{{prefix}}def {{name}}{{sig}}", language="python")

    # Badges row
    badges = []
    if doc_type != "function":
        badges.append((doc_type, "info"))
    for dec in decorators:
        badges.append(("@" + dec, "default"))
    if func.get("is_async"):
        badges.append(("async", "warning"))
    if func.get("is_classmethod"):
        badges.append(("classmethod", "info"))
    if func.get("is_staticmethod"):
        badges.append(("staticmethod", "info"))
    if func.get("is_property"):
        badges.append(("property", "success"))

    # Deprecation
    if func.get("is_deprecated"):
        _dep_since = func.get("deprecation_since", "")
        badges.append((f"DEPRECATED since {{_dep_since}}" if _dep_since else "DEPRECATED", "danger"))

    # Version
    _version = func.get("version", "")
    if _version:
        badges.append((f"v{{_version}}", "info"))

    # Category
    _category = func.get("category", "")
    if _category:
        badges.append((_category, "default"))

    # Complexity
    complexity = func.get("complexity", 1)
    if complexity >= 15:
        badges.append((f"complexity: {{complexity}}", "danger"))
    elif complexity >= 8:
        badges.append((f"complexity: {{complexity}}", "warning"))

    if badges:
        c.spacer(1)
        with c.row(gap=2, wrap=True):
            for label, color in badges:
                c.badge(label, color=color)

    # Deprecation warning
    if func.get("is_deprecated"):
        dep_msg = func.get("deprecation_message", "")
        dep_since = func.get("deprecation_since", "")
        c.spacer(1)
        _dep_parts = []
        if dep_since:
            _dep_parts.append(f"since {{dep_since}}")
        if dep_msg:
            _dep_parts.append(dep_msg)
        _dep_text = f"Deprecated ({{', '.join(_dep_parts)}})" if _dep_parts else "This function is deprecated."
        c.text(_dep_text, color="danger")

    c.spacer(2)
    _render_docstring(ds)

    # Calls
    calls = func.get("calls", [])
    if calls:
        c.spacer(2)
        c.title("Calls", level=4)
        c.spacer(1)
        with c.row(gap=2, wrap=True):
            for call_name in calls:
                c.badge(call_name, color="default")

    # Used by (reverse call map)
    _called_by = _DATA.get("called_by", {{}})
    full_path = func.get("full_path", name)
    callers = _called_by.get(full_path, []) or _called_by.get(name, [])
    if callers:
        c.spacer(2)
        c.title("Used By", level=4)
        c.spacer(1)
        with c.row(gap=2, wrap=True):
            for caller in callers:
                c.badge(caller, color="info")

    # Source
    if func.get("source"):
        c.spacer(2)
        with c.tabs():
            with c.tab("src_" + name, "Source Code"):
                c.code(func["source"], language="python", line_numbers=True)

    c.spacer(3)


def _render_class_detail(cls):
    """Render full class documentation."""
    bases_str = ""
    if cls.get("bases"):
        bases_str = f'({{", ".join(cls["bases"])}})'

    c.code(f'class {{cls["name"]}}{{bases_str}}', language="python")

    if cls.get("decorators"):
        c.spacer(1)
        with c.row(gap=2, wrap=True):
            for dec in cls["decorators"]:
                c.badge(f"@{{dec}}", color="info")

    c.spacer(2)
    _render_docstring(cls["docstring"])

    init_method = next((m for m in cls.get("methods", []) if m["name"] == "__init__"), None)
    if init_method:
        c.spacer(3)
        c.title("Constructor", level=3)
        _render_function_block(init_method)

    methods = [m for m in cls.get("methods", []) if not m["name"].startswith("_")]
    if methods:
        c.spacer(2)
        c.title("Methods", level=3)
        for method in methods:
            _render_function_block(method)

    private_methods = [m for m in cls.get("methods", [])
                       if m["name"].startswith("_") and m["name"] != "__init__"]
    if private_methods:
        c.spacer(2)
        with c.tabs():
            with c.tab("private", f"Private Methods ({{len(private_methods)}})"):
                for method in private_methods:
                    _render_function_block(method)


# =============================================================================
# Build the App
# =============================================================================

_default_key = "home"

with c.app_shell(brand={title!r}, default=_default_key, theme_dark="dark", theme_light="light"):
    with c.nav_sidebar():
        c.nav_item("Home", key="home", icon="home")

        if _CONTENT_MODULES:
            with c.nav_group("Modules", icon="folder"):
                for mod in _CONTENT_MODULES:
                    label = mod["full_path"] if mod["name"] == "__init__" else mod["name"]
                    badge_count = str(len(mod.get("classes", [])) + len(mod.get("functions", [])))
                    c.nav_item(label, key=f"mod_{{mod['full_path']}}", icon="file", badge=badge_count)

        if _CLASSES:
            c.nav_item("Types", key="types_ref", icon="box",
                        badge=str(len(_CLASSES)))

        # API Endpoints section
        if _API_ENDPOINTS:
            c.nav_item("API Reference", key="api_ref", icon="code",
                        badge=str(len(_API_ENDPOINTS)))

        c.nav_item("Call Map", key="callmap", icon="code")

        with c.nav_group("Insights", icon="bar-chart"):
            c.nav_item("Dashboard", key="dashboard", icon="bar-chart")
            _todo_list = _DATA.get("todos", [])
            if _todo_list:
                c.nav_item("TODOs", key="todos_panel", icon="alert-circle",
                            badge=str(len(_todo_list)))
            _changes_list = _DATA.get("changes", [])
            if _changes_list:
                c.nav_item("Changelog", key="changelog_panel", icon="clock",
                            badge=str(len(_changes_list)))
            _dead_list = _DATA.get("dead_code", [])
            if _dead_list:
                c.nav_item("Dead Code", key="dead_code_panel", icon="trash",
                            badge=str(len(_dead_list)))

        if _PAGES:
            with c.nav_group("Pages", icon="book"):
                for page in _PAGES:
                    c.nav_item(page["title"], key=f"page_{{page['slug']}}", icon="book")

{_chat_nav_item}

    with c.shell_content():
        # --- Home ---
        with c.nav_panel("home"):
            c.title({title!r})
            if {description!r}:
                c.text({description!r}, size="lg", color="muted")
            c.spacer(4)

            with c.row(wrap=True, gap=4):
                c.metric("Modules", len(_CONTENT_MODULES))
                c.metric("Classes", len(_CLASSES))
                c.metric("Functions", len(_FUNCTIONS))
                if _API_ENDPOINTS:
                    c.metric("API Endpoints", len(_API_ENDPOINTS))
                if _PAGES:
                    c.metric("Pages", len(_PAGES))

            if {version!r}:
                c.spacer(3)
                c.badge(f"v{{{version!r}}}", color="info")

            # Quick overview
            if _CONTENT_MODULES:
                c.spacer(4)
                c.title("Modules", level=3)
                c.spacer(2)
                for mod in _CONTENT_MODULES:
                    label = mod["full_path"] if mod["name"] == "__init__" else mod["name"]
                    with c.card(label):
                        if mod.get("docstring"):
                            first_line = mod["docstring"].split("\\n")[0].strip()
                            c.text(first_line, color="muted")
                        with c.row(gap=2):
                            if mod.get("classes"):
                                c.badge(f'{{len(mod["classes"])}} classes', color="info")
                            if mod.get("functions"):
                                c.badge(f'{{len(mod["functions"])}} functions', color="default")

            # API overview on home
            if _API_ENDPOINTS:
                c.spacer(4)
                c.title("API Endpoints", level=3)
                c.spacer(2)
                table_data = []
                for ep in _API_ENDPOINTS:
                    ds = ep.get("docstring", {{}})
                    method = ds.get("http_method", "")
                    path = ds.get("path", "")
                    table_data.append({{
                        "Method": method,
                        "Path": path,
                        "Summary": ds.get("summary", ""),
                    }})
                c.table(table_data, paginate=False)

        # --- Module Panels ---
        for mod in _CONTENT_MODULES:
            with c.nav_panel(f"mod_{{mod['full_path']}}"):
                mod_label = mod["full_path"] if mod["name"] == "__init__" else mod["name"]
                c.title(mod_label, level=2)

                if mod.get("file_path"):
                    c.text(mod["file_path"], size="sm", color="muted")

                if mod.get("docstring"):
                    c.spacer(2)
                    c.text(mod["docstring"])

                if mod.get("classes"):
                    c.spacer(4)
                    c.title("Classes", level=3)
                    c.spacer(2)
                    for cls in mod["classes"]:
                        with c.card(f'class {{cls["name"]}}'):
                            _render_docstring(cls["docstring"])
                            pub_methods = [m for m in cls.get("methods", []) if not m["name"].startswith("_")]
                            if pub_methods:
                                c.spacer(2)
                                c.badge(f"{{len(pub_methods)}} methods", color="info")

                if mod.get("functions"):
                    c.spacer(4)
                    c.title("Functions", level=3)
                    for func in mod["functions"]:
                        _render_function_block(func)

        # --- Types Reference Panel ---
        if _CLASSES:
            with c.nav_panel("types_ref"):
                with c.layout("sidebar", sidebar_width="260px") as _tl:
                    with _tl.side():
                        c.title("Types", level=3)
                        c.spacer(2)
                        # Group classes by module
                        _cls_by_mod = {{}}
                        for cls in _CLASSES:
                            mod = cls.get("module", "")
                            _cls_by_mod.setdefault(mod, []).append(cls)

                        with c.subnav(searchable=True):
                            for mod, classes in _cls_by_mod.items():
                                if mod:
                                    c.subnav_group(mod)
                                for cls in classes:
                                    method_count = len(cls.get("methods", []))
                                    c.subnav_item(
                                        cls["name"],
                                        badge=str(method_count),
                                        target=f'type_{{cls["full_path"]}}',
                                    )

                    with _tl.main():
                        c.title("Types Reference", level=2)
                        c.text(f"{{len(_CLASSES)}} classes across the codebase.", color="muted")
                        c.spacer(4)

                        for cls in _CLASSES:
                            c.anchor(f'type_{{cls["full_path"]}}')
                            with c.card():
                                _render_class_detail(cls)
                            c.spacer(3)

        # --- API Reference Panel ---
        if _API_ENDPOINTS:
            with c.nav_panel("api_ref"):
                with c.layout("sidebar", sidebar_width="260px") as _al:
                    with _al.side():
                        c.title("Endpoints", level=3)
                        c.spacer(2)
                        # Group by path prefix (first path segment)
                        _ep_groups = {{}}
                        for ep in _API_ENDPOINTS:
                            ds = ep.get("docstring", {{}})
                            path = ds.get("path", "")
                            prefix = "/" + path.strip("/").split("/")[0] if path.strip("/") else "/"
                            _ep_groups.setdefault(prefix, []).append(ep)

                        _TAG_COLORS = {{"GET": "success", "POST": "info", "PUT": "warning", "PATCH": "warning", "DELETE": "danger"}}
                        with c.subnav(searchable=True):
                            for prefix, eps in _ep_groups.items():
                                c.subnav_group(prefix)
                                for ep in eps:
                                    ds = ep.get("docstring", {{}})
                                    method = ds.get("http_method", "GET")
                                    path = ds.get("path", ep["name"])
                                    c.subnav_item(
                                        path,
                                        tag=method,
                                        tag_color=_TAG_COLORS.get(method),
                                        target=f'ep_{{ep["full_path"]}}',
                                    )

                    with _al.main():
                        c.title("API Reference", level=2)
                        c.text(f"{{len(_API_ENDPOINTS)}} endpoints.", color="muted")
                        c.spacer(3)

                        # Summary table
                        _method_counts = {{}}
                        for ep in _API_ENDPOINTS:
                            m = ep.get("docstring", {{}}).get("http_method", "GET")
                            _method_counts[m] = _method_counts.get(m, 0) + 1
                        with c.row(gap=3, wrap=True):
                            for m, count in sorted(_method_counts.items()):
                                c.metric(m, count)
                        c.spacer(4)

                        for ep in _API_ENDPOINTS:
                            ds = ep.get("docstring", {{}})
                            method = ds.get("http_method", "")
                            path = ds.get("path", "")

                            c.anchor(f'ep_{{ep["full_path"]}}')
                            with c.card():
                                # Header with method badge + path
                                with c.row(gap=3, wrap=True):
                                    if method:
                                        c.badge(method, color=_METHOD_COLORS.get(method, "default"))
                                    c.title(path or ep["name"], level=3)

                                if ep.get("module"):
                                    c.text(f'{{ep["module"]}}.{{ep["name"]}}', size="sm", color="muted")

                                c.spacer(2)
                                _render_docstring(ds)

                                # Source
                                if ep.get("source"):
                                    c.spacer(3)
                                    with c.tabs():
                                        with c.tab("src_" + ep["name"], "Source Code"):
                                            c.code(ep["source"], language="python", line_numbers=True)
                            c.spacer(3)

        # --- Call Map Panel ---
        with c.nav_panel("callmap"):
            c.title("Call Map", level=2)
            c.text("Function and method call relationships across the codebase.", color="muted")
            c.spacer(3)

            _all_known = set()
            _call_entries = []

            for func in _FUNCTIONS:
                _all_known.add(func["full_path"])
                _all_known.add(func["name"])
            for ep in _API_ENDPOINTS:
                _all_known.add(ep["full_path"])
                _all_known.add(ep["name"])
            for cls in _CLASSES:
                _all_known.add(cls["full_path"])
                for m in cls.get("methods", []):
                    _all_known.add(f'{{cls["full_path"]}}.{{m["name"]}}')
                    _all_known.add(m["name"])

            all_funcs = _FUNCTIONS + _API_ENDPOINTS
            for func in all_funcs:
                calls = func.get("calls", [])
                if calls:
                    internal = [c for c in calls if c in _all_known or c.split(".")[-1] in _all_known]
                    external = [c for c in calls if c not in internal]
                    if internal or external:
                        _call_entries.append({{
                            "caller": func["full_path"],
                            "internal": internal,
                            "external": external,
                        }})

            for cls in _CLASSES:
                for m in cls.get("methods", []):
                    calls = m.get("calls", [])
                    if calls:
                        caller = f'{{cls["full_path"]}}.{{m["name"]}}'
                        internal = [c for c in calls if c in _all_known or c.split(".")[-1] in _all_known]
                        external = [c for c in calls if c not in internal]
                        if internal or external:
                            _call_entries.append({{
                                "caller": caller,
                                "internal": internal,
                                "external": external,
                            }})

            if _call_entries:
                table_data = []
                for entry in _call_entries:
                    table_data.append({{
                        "Caller": entry["caller"],
                        "Internal Calls": ", ".join(entry["internal"]) if entry["internal"] else "—",
                        "External Calls": str(len(entry["external"])),
                    }})
                c.table(table_data, searchable=True, page_size=20)

                c.spacer(4)
                c.title("Details", level=3)
                c.spacer(2)
                for entry in _call_entries:
                    if entry["internal"]:
                        with c.card(entry["caller"]):
                            c.title("Calls (internal)", level=4)
                            with c.row(gap=2, wrap=True):
                                for call_name in entry["internal"]:
                                    c.badge(call_name, color="info")
                            if entry["external"]:
                                c.spacer(2)
                                c.title("Calls (external)", level=4)
                                with c.row(gap=2, wrap=True):
                                    for call_name in entry["external"][:15]:
                                        c.badge(call_name, color="default")
                                    if len(entry["external"]) > 15:
                                        c.text(f'... and {{len(entry["external"]) - 15}} more', size="sm", color="muted")
            else:
                c.text("No call relationships found.", color="muted")

        # --- Dashboard Panel ---
        with c.nav_panel("dashboard"):
            c.title("Dashboard", level=2)
            c.text("Project health and code insights.", color="muted")
            c.spacer(4)

            # Coverage
            _cov = _DATA.get("coverage", {{}})
            _score = _cov.get("project_score", 0)
            _score_color = "success" if _score >= 80 else ("warning" if _score >= 50 else "danger")
            with c.row(wrap=True, gap=4):
                c.metric("Doc Coverage", f"{{_score}}%")
                c.metric("Fully Documented", _cov.get("fully_documented", 0))
                c.metric("Undocumented", _cov.get("undocumented", 0))
                c.metric("Total Items", _cov.get("total_items", 0))

            c.spacer(4)
            c.title("TODOs & Debt", level=3)
            c.spacer(2)
            _all_todos = _DATA.get("todos", [])
            _all_dead = _DATA.get("dead_code", [])
            _all_deprecated_items = _FUNCTIONS + _API_ENDPOINTS
            for _dcls in _CLASSES:
                for _dm in _dcls.get("methods", []):
                    _all_deprecated_items.append({{**_dm, "full_path": f'{{_dcls["full_path"]}}.{{_dm["name"]}}'}})
            _all_deprecated = [f for f in _all_deprecated_items if f.get("is_deprecated")]
            with c.row(wrap=True, gap=4):
                c.metric("TODOs", len([t for t in _all_todos if t.get("tag") == "TODO"]))
                c.metric("FIXMEs", len([t for t in _all_todos if t.get("tag") == "FIXME"]))
                c.metric("Deprecated", len(_all_deprecated))
                c.metric("Dead Code", len(_all_dead))

            # Deprecated functions detail
            if _all_deprecated:
                c.spacer(4)
                c.title("Deprecated Functions", level=3)
                c.spacer(2)
                _dep_table = []
                for f in _all_deprecated:
                    _dep_table.append({{
                        "Name": f.get("full_path", f["name"]),
                        "Since": f.get("deprecation_since", ""),
                        "Message": f.get("deprecation_message", ""),
                        "Type": f.get("doc_type", "function"),
                    }})
                c.table(_dep_table, paginate=False)

            # Recent changes summary
            _changes_summary = _DATA.get("changes", [])
            if _changes_summary:
                c.spacer(4)
                c.title("Recent Changes", level=3)
                c.spacer(2)
                _ch_summary = {{}}
                for ch in _changes_summary:
                    _ch_summary[ch["change"]] = _ch_summary.get(ch["change"], 0) + 1
                with c.row(wrap=True, gap=4):
                    for chtype, cnt in sorted(_ch_summary.items()):
                        c.metric(chtype.replace("+", " + ").title(), cnt)
                c.spacer(2)
                _ch_summary_table = []
                for ch in _changes_summary[:15]:
                    _ch_summary_table.append({{
                        "Name": ch["name"],
                        "Type": ch["doc_type"],
                        "Change": ch["change"],
                    }})
                c.table(_ch_summary_table, paginate=False)

            _breaking_summary = _DATA.get("breaking_changes", [])
            if _breaking_summary:
                c.spacer(2)
                c.alert(f"{{len(_breaking_summary)}} breaking change(s) detected. See Changelog for details.", type="danger")

            # Complexity hotspots
            c.spacer(4)
            c.title("Complexity Hotspots", level=3)
            c.spacer(2)
            _all_items = _FUNCTIONS + _API_ENDPOINTS
            for cls in _CLASSES:
                for m in cls.get("methods", []):
                    _all_items.append({{**m, "full_path": f'{{cls["full_path"]}}.{{m["name"]}}'}})
            _complex = sorted([f for f in _all_items if f.get("complexity", 1) >= 6],
                              key=lambda x: x.get("complexity", 1), reverse=True)[:15]
            if _complex:
                _cx_table = []
                for f in _complex:
                    cx = f.get("complexity", 1)
                    _cx_table.append({{
                        "Function": f.get("full_path", f["name"]),
                        "Complexity": cx,
                        "Level": "High" if cx >= 15 else ("Medium" if cx >= 8 else "Low"),
                    }})
                c.table(_cx_table, paginate=False)
            else:
                c.text("No high-complexity functions found.", color="muted")

            # Coverage per item
            c.spacer(4)
            c.title("Coverage Details", level=3)
            c.spacer(2)
            _cov_items = _cov.get("items", [])
            if _cov_items:
                _low_cov = sorted([i for i in _cov_items if i["score"] < 100],
                                  key=lambda x: x["score"])[:20]
                if _low_cov:
                    _cov_table = []
                    for i in _low_cov:
                        checks = i.get("checks", {{}})
                        missing = checks.get("args_missing", [])
                        _cov_table.append({{
                            "Name": i["name"],
                            "Type": i["doc_type"],
                            "Score": f'{{i["score"]}}%',
                            "Missing Args": ", ".join(missing) if missing else "",
                        }})
                    c.table(_cov_table, paginate=False)
                else:
                    c.text("All items are fully documented!", color="success")

        # --- TODOs Panel ---
        _todo_list_panel = _DATA.get("todos", [])
        if _todo_list_panel:
            with c.nav_panel("todos_panel"):
                c.title("TODOs", level=2)
                c.text("TODO, FIXME, HACK, and XXX comments found in source code.", color="muted")
                c.spacer(4)

                _tag_counts = {{}}
                for t in _todo_list_panel:
                    _tag_counts[t["tag"]] = _tag_counts.get(t["tag"], 0) + 1
                with c.row(wrap=True, gap=4):
                    for tag, cnt in sorted(_tag_counts.items()):
                        _tc = "danger" if tag == "FIXME" else ("warning" if tag in ("HACK", "XXX") else "info")
                        c.metric(tag, cnt)

                c.spacer(3)
                _todo_table = []
                for t in _todo_list_panel:
                    _todo_table.append({{
                        "Tag": t["tag"],
                        "Message": t["text"],
                        "Module": t.get("module", ""),
                        "Line": t.get("line_number", ""),
                    }})
                c.table(_todo_table, searchable=True, page_size=25)

        # --- Changelog Panel ---
        _changes_panel = _DATA.get("changes", [])
        if _changes_panel:
            with c.nav_panel("changelog_panel"):
                c.title("Recent Changes", level=2)
                c.text("Functions and endpoints that changed since the last build.", color="muted")
                c.spacer(4)

                _ch_counts = {{}}
                for ch in _changes_panel:
                    _ch_counts[ch["change"]] = _ch_counts.get(ch["change"], 0) + 1
                with c.row(wrap=True, gap=4):
                    for chtype, cnt in sorted(_ch_counts.items()):
                        c.metric(chtype.replace("+", " + ").title(), cnt)

                c.spacer(3)
                _breaking = _DATA.get("breaking_changes", [])
                if _breaking:
                    c.title("Breaking Changes", level=3)
                    c.spacer(2)
                    for b in _breaking:
                        with c.card(b["full_path"]):
                            for d in b.get("details", []):
                                _icon = "!!" if d.get("breaking") else ""
                                if d["type"] == "arg_removed":
                                    c.text(f'{{_icon}} Argument removed: {{d["arg"]}}', color="danger")
                                elif d["type"] == "required_arg_added":
                                    c.text(f'{{_icon}} Required argument added: {{d["arg"]}}', color="danger")
                                elif d["type"] == "type_changed":
                                    c.text(f'{{_icon}} Type changed: {{d["arg"]}} ({{d["from"]}} -> {{d["to"]}})', color="warning")
                                elif d["type"] == "return_type_changed":
                                    c.text(f'{{_icon}} Return type changed: {{d["from"]}} -> {{d["to"]}}', color="warning")
                                elif d["type"] == "optional_arg_added":
                                    c.text(f'Optional argument added: {{d["arg"]}}', color="success")
                    c.spacer(3)

                c.title("All Changes", level=3)
                c.spacer(2)
                _ch_table = []
                for ch in _changes_panel:
                    _ch_color = {{"new": "success", "removed": "danger", "signature": "warning",
                                 "body": "info", "signature+body": "danger"}}
                    _ch_table.append({{
                        "Name": ch["name"],
                        "Type": ch["doc_type"],
                        "Change": ch["change"],
                        "Path": ch["full_path"],
                    }})
                c.table(_ch_table, searchable=True, page_size=25)

        # --- Dead Code Panel ---
        _dead_panel = _DATA.get("dead_code", [])
        if _dead_panel:
            with c.nav_panel("dead_code_panel"):
                c.title("Dead Code", level=2)
                c.text("Public functions and methods with no internal callers.", color="muted")
                c.spacer(4)

                c.metric("Potentially Unused", len(_dead_panel))
                c.spacer(3)

                _dc_table = []
                for d in _dead_panel:
                    _dc_table.append({{
                        "Function": d["full_path"],
                        "Type": d["doc_type"],
                        "Module": d.get("module", ""),
                    }})
                c.table(_dc_table, searchable=True, page_size=25)

                c.spacer(2)
                c.text("Note: Entry points, decorated functions, and private methods are excluded. "
                       "These may still be used externally.", size="sm", color="muted")

        # --- Page Panels ---
        for page in _PAGES:
            with c.nav_panel(f"page_{{page['slug']}}"):
                c.title(page["title"], level=2)
                c.spacer(2)
                c.html(page.get("content", ""))

{_chat_panel_block}
'''

    return code


def build_docs(
    source: str | Path,
    output: str | Path,
    config: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Build documentation from source directory.

    Scans Python/Markdown files and generates a Cacao app.

    Args:
        source: Source directory containing Python/Markdown files.
        output: Output directory for the generated Cacao app.
        config: Optional configuration dictionary.

    Returns:
        The generated JSON documentation data.
    """
    from .scanner import scan_directory
    from .config import load_config
    from .parser import DocstringParser

    if config is None:
        config = load_config()

    # Create parser with custom doc types
    custom_types = config.get("custom_doc_types", [])
    parser = DocstringParser(custom_types=custom_types) if custom_types else None

    exclude_patterns = config.get("exclude_patterns", [])
    modules, pages = scan_directory(source, exclude_patterns, parser)

    json_data = build_json(modules, pages, config)

    output_dir = Path(output)
    output_dir.mkdir(parents=True, exist_ok=True)

    # Compare against previous build to detect changes + breaking changes
    data_path = output_dir / "data.json"
    if data_path.exists():
        try:
            with open(data_path, "r", encoding="utf-8") as f:
                old_data = json.load(f)
            changes = _compute_changes(old_data, json_data)
            if changes:
                json_data["changes"] = changes
            breaking = _detect_breaking_changes(old_data, json_data)
            if breaking:
                json_data["breaking_changes"] = breaking
        except (json.JSONDecodeError, KeyError):
            pass

    # Append to changelog
    changelog_path = output_dir / "changelog.json"
    changes_list = json_data.get("changes", [])
    breaking_list = json_data.get("breaking_changes", [])
    if changes_list or breaking_list:
        from datetime import datetime, timezone

        existing_changelog: list[dict[str, Any]] = []
        if changelog_path.exists():
            try:
                with open(changelog_path, "r", encoding="utf-8") as f:
                    existing_changelog = json.load(f)
            except (json.JSONDecodeError, ValueError):
                pass

        entry: dict[str, Any] = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "changes": changes_list,
        }
        if breaking_list:
            entry["breaking_changes"] = breaking_list

        existing_changelog.append(entry)

        with open(changelog_path, "w", encoding="utf-8") as f:
            json.dump(existing_changelog, f, indent=2, ensure_ascii=False, default=str)

    # Embedding step (if chat is enabled)
    chat_enabled = config.get("chat", False)
    if chat_enabled:
        chat_config = config.get("chat_config", {})
        embedding_model = chat_config.get(
            "embedding_model", "openai/text-embedding-3-small"
        )

        chunks = _chunk_documentation(json_data)
        if chunks:
            import logging

            logger = logging.getLogger("cacaodocs")
            logger.info(
                "Embedding %d documentation chunks with %s...",
                len(chunks),
                embedding_model,
            )

            embeddings = _embed_chunks(chunks, embedding_model)
            if embeddings:
                emb_path = output_dir / "embeddings.json"
                with open(emb_path, "w", encoding="utf-8") as f:
                    json.dump(embeddings, f, ensure_ascii=False)
                json_data["_embedding_stats"] = {
                    "chunks": len(chunks),
                    "model": embedding_model,
                    "dimensions": embeddings.get("dimensions", 0),
                }
            else:
                logger.warning(
                    "Embedding failed. Chat will work without RAG context. "
                    "Ensure %s is available (e.g. `ollama pull %s`).",
                    embedding_model,
                    embedding_model.split("/", 1)[-1]
                    if "/" in embedding_model
                    else embedding_model,
                )

    app_code = _generate_app_code(json_data)
    app_path = output_dir / "app.py"

    with open(app_path, "w", encoding="utf-8") as f:
        f.write(app_code)

    # Strip non-serializable objects from config before writing
    safe_config = {
        k: v
        for k, v in json_data.get("config", {}).items()
        if k not in ("custom_doc_types",)
    }
    safe_data = {**json_data, "config": safe_config}

    data_path = output_dir / "data.json"
    with open(data_path, "w", encoding="utf-8") as f:
        json.dump(safe_data, f, indent=2, ensure_ascii=False, default=str)

    return json_data
