"""HTTP routes for Memory Atlas — operator browse/edit of durable memory files."""
from __future__ import annotations

import asyncio
import logging
import os
import re
import time
from typing import Any

from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse, PlainTextResponse
from pydantic import BaseModel, Field


class MemoryPutBody(BaseModel):
    content: str = Field(..., max_length=500_000)

from core import get_workspace_path
from core.config import get_memory_config
from utils.memory_shard_layout import (
    archive_rel_path,
    default_shard,
    layout_is_sharded,
    list_archive_stems,
    list_shard_rel_paths,
    list_summary_slugs,
    normalize_shard_slug,
    regenerate_memory_hub_toc,
    shard_rel_path,
    summary_rel_path,
    validate_archive_stem,
    validate_summary_slug,
)
from utils.memory_scopes import list_mesh_safe_memory_files
from utils.path_safety import safe_resolve_path, secure_write_file

_log = logging.getLogger(__name__)

_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_MAX_SEARCH_RESULTS = 20
_REINDEX_COOLDOWN_SEC = 60.0
_READ_ONLY_CAP_BYTES = 512 * 1024
_TRUNCATION_BANNER = (
    "... [content truncated for Memory Atlas display; "
    "full file remains on disk]\n\n"
)
_last_reindex_ts = 0.0

# Workspace-relative paths operators may read/write via Atlas.
_HUB_REL = "MEMORY.md"
_INSIGHTS_REL = "memory/insights.md"
_MESH_SAFE_PREFIX = "memory/mesh_safe/"


def _workspace() -> str:
    ws = get_workspace_path()
    if not ws or not os.path.isdir(ws):
        raise HTTPException(status_code=503, detail="Workspace not available")
    return ws


def _slug_from_path_param(slug: str) -> str:
    raw = (slug or "").strip()
    if raw.endswith(".md"):
        raw = raw[:-3]
    normalized = normalize_shard_slug(raw)
    if normalized is None or not _SLUG_RE.match(normalized):
        raise HTTPException(status_code=400, detail="Invalid shard slug")
    return normalized


def _archive_stem_from_param(slug: str) -> str:
    raw = (slug or "").strip()
    if raw.endswith(".md"):
        raw = raw[:-3]
    stem = validate_archive_stem(raw)
    if stem is None:
        raise HTTPException(status_code=400, detail="Invalid archive slug")
    return stem


def _summary_slug_from_param(slug: str) -> str:
    raw = (slug or "").strip()
    if raw.endswith(".md"):
        raw = raw[:-3]
    norm = validate_summary_slug(raw)
    if norm is None:
        raise HTTPException(status_code=400, detail="Invalid summary slug")
    return norm


def _read_rel(workspace: str, rel: str, *, cap_bytes: int | None = None) -> tuple[str, bool]:
    abs_path, err = safe_resolve_path(workspace, rel, allow_write=False)
    if err or not abs_path:
        raise HTTPException(status_code=400, detail=err or "Invalid path")
    if not os.path.isfile(abs_path):
        raise HTTPException(status_code=404, detail="Not found")
    try:
        with open(abs_path, encoding="utf-8", errors="replace") as f:
            if cap_bytes is None:
                return f.read(), False
            raw = f.read()
            encoded = raw.encode("utf-8")
            if len(encoded) <= cap_bytes:
                return raw, False
            trimmed = encoded[:cap_bytes]
            while trimmed:
                try:
                    return _TRUNCATION_BANNER + trimmed.decode("utf-8"), True
                except UnicodeDecodeError:
                    trimmed = trimmed[:-1]
            return _TRUNCATION_BANNER, True
    except OSError as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc


def _plain_read_response(workspace: str, rel: str, *, cap_bytes: int | None = None) -> PlainTextResponse:
    text, truncated = _read_rel(workspace, rel, cap_bytes=cap_bytes)
    headers = {"X-Mugi-Truncated": "true" if truncated else "false"}
    return PlainTextResponse(text, headers=headers)


def _write_rel(workspace: str, rel: str, content: str) -> None:
    cfg = get_memory_config() or {}
    hard = int(cfg.get("hard_cap_characters") or 500_000)
    cap = min(hard, 500_000)
    if len(content) > cap:
        raise HTTPException(status_code=413, detail=f"Content exceeds cap ({cap} chars)")

    abs_path, err = safe_resolve_path(workspace, rel, allow_write=True, create_parents=True)
    if err or not abs_path:
        raise HTTPException(status_code=400, detail=err or "Invalid path")
    rel_norm = rel.replace("\\", "/")
    if rel_norm == _HUB_REL or rel_norm.startswith("memory/shards/"):
        pass
    elif rel_norm == _INSIGHTS_REL or rel_norm.startswith(_MESH_SAFE_PREFIX):
        pass
    else:
        raise HTTPException(status_code=403, detail="Path not allowed for Memory Atlas")

    try:
        secure_write_file(abs_path, content, mode=0o600)
    except OSError as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc

    if layout_is_sharded(workspace) and rel_norm.startswith("memory/shards/"):
        try:
            regenerate_memory_hub_toc(workspace)
        except Exception:
            _log.debug("hub TOC regen failed after shard write", exc_info=True)


def _list_mesh_safe_slugs(workspace: str) -> list[str]:
    slugs: list[str] = []
    for rel, _abs in list_mesh_safe_memory_files(workspace):
        base = os.path.basename(rel)
        if base.endswith(".md"):
            slug = base[:-3]
            if normalize_shard_slug(slug):
                slugs.append(slug)
    return sorted(set(slugs))


def _list_shard_slugs(workspace: str) -> list[str]:
    slugs: list[str] = []
    for rel in list_shard_rel_paths(workspace):
        base = os.path.basename(rel)
        if base.endswith(".md"):
            slug = base[:-3]
            if normalize_shard_slug(slug):
                slugs.append(slug)
    return sorted(set(slugs))


def register_memory_http_routes(fastapi_app: Any) -> None:
    """Mount ``/memory/*`` on *fastapi_app* (auth required via global middleware)."""

    @fastapi_app.get("/memory/layout")
    async def memory_layout():
        workspace = _workspace()
        sharded = layout_is_sharded(workspace)
        insights_abs = os.path.join(workspace, _INSIGHTS_REL)
        return JSONResponse(
            content={
                "schema": "mugi.memory.layout.v2",
                "sharded": sharded,
                "hub_path": _HUB_REL,
                "shard_slugs": _list_shard_slugs(workspace) if sharded else [],
                "archive_slugs": list_archive_stems(workspace),
                "summary_slugs": list_summary_slugs(workspace),
                "insights_exists": os.path.isfile(insights_abs),
                "mesh_safe_slugs": _list_mesh_safe_slugs(workspace),
                "default_shard": default_shard(),
            }
        )

    @fastapi_app.get("/memory/insights")
    async def memory_insights_get():
        workspace = _workspace()
        insights_abs = os.path.join(workspace, _INSIGHTS_REL)
        if not os.path.isfile(insights_abs):
            raise HTTPException(status_code=404, detail="insights.md not found")
        return _plain_read_response(workspace, _INSIGHTS_REL, cap_bytes=_READ_ONLY_CAP_BYTES)

    @fastapi_app.get("/memory/hub")
    async def memory_hub_get():
        workspace = _workspace()
        return PlainTextResponse(_read_rel(workspace, _HUB_REL)[0])

    @fastapi_app.put("/memory/hub")
    async def memory_hub_put(body: MemoryPutBody):
        workspace = _workspace()
        _write_rel(workspace, _HUB_REL, body.content)
        _log.debug("memory atlas hub write bytes=%d", len(body.content.encode("utf-8")))
        return JSONResponse(content={"ok": True})

    @fastapi_app.get("/memory/shard/{slug}")
    async def memory_shard_get(slug: str):
        workspace = _workspace()
        norm = _slug_from_path_param(slug)
        rel = shard_rel_path(norm)
        return PlainTextResponse(_read_rel(workspace, rel)[0])

    @fastapi_app.put("/memory/shard/{slug}")
    async def memory_shard_put(slug: str, body: MemoryPutBody):
        workspace = _workspace()
        norm = _slug_from_path_param(slug)
        rel = shard_rel_path(norm)
        _write_rel(workspace, rel, body.content)
        _log.debug("memory atlas shard write slug=%s bytes=%d", norm, len(body.content.encode("utf-8")))
        return JSONResponse(content={"ok": True})

    @fastapi_app.get("/memory/mesh_safe/{slug}")
    async def memory_mesh_safe_get(slug: str):
        workspace = _workspace()
        norm = _slug_from_path_param(slug)
        rel = f"{_MESH_SAFE_PREFIX}{norm}.md"
        return PlainTextResponse(_read_rel(workspace, rel)[0])

    @fastapi_app.put("/memory/mesh_safe/{slug}")
    async def memory_mesh_safe_put(slug: str, body: MemoryPutBody):
        workspace = _workspace()
        norm = _slug_from_path_param(slug)
        rel = f"{_MESH_SAFE_PREFIX}{norm}.md"
        _write_rel(workspace, rel, body.content)
        _log.debug("memory atlas mesh_safe write slug=%s bytes=%d", norm, len(body.content.encode("utf-8")))
        return JSONResponse(content={"ok": True})

    @fastapi_app.get("/memory/archive/{slug}")
    async def memory_archive_get(slug: str):
        workspace = _workspace()
        stem = _archive_stem_from_param(slug)
        rel = archive_rel_path(stem)
        return _plain_read_response(workspace, rel, cap_bytes=_READ_ONLY_CAP_BYTES)

    @fastapi_app.get("/memory/summary/{slug}")
    async def memory_summary_get(slug: str):
        workspace = _workspace()
        norm = _summary_slug_from_param(slug)
        rel = summary_rel_path(norm)
        return _plain_read_response(workspace, rel, cap_bytes=_READ_ONLY_CAP_BYTES)

    @fastapi_app.get("/memory/search")
    async def memory_search(request: Request):
        q = (request.query_params.get("q") or "").strip()
        if not q:
            raise HTTPException(status_code=400, detail="q required")
        semantic_raw = (request.query_params.get("semantic") or "false").lower()
        semantic = semantic_raw in ("1", "true", "yes")
        limit_raw = request.query_params.get("limit")
        if limit_raw is not None and str(limit_raw).strip():
            try:
                limit_val = int(limit_raw)
            except ValueError as exc:
                raise HTTPException(status_code=400, detail="limit must be an integer") from exc
            if limit_val < 1:
                raise HTTPException(status_code=400, detail="limit must be at least 1")
            max_results = min(_MAX_SEARCH_RESULTS, limit_val)
        else:
            max_results = _MAX_SEARCH_RESULTS

        from tools.memory_tools import search_memory

        text = await asyncio.to_thread(
            search_memory.invoke,
            {
                "query": q,
                "max_results": max_results,
                "include_archives": True,
                "semantic": semantic,
            },
        )
        return JSONResponse(
            content={
                "schema": "mugi.memory.search.v1",
                "query": q,
                "semantic": semantic,
                "text": text,
            }
        )

    @fastapi_app.post("/memory/reindex")
    async def memory_reindex():
        global _last_reindex_ts
        now = time.time()
        if now - _last_reindex_ts < _REINDEX_COOLDOWN_SEC:
            raise HTTPException(status_code=429, detail="Reindex rate limited; try again later")
        _last_reindex_ts = now
        workspace = _workspace()
        try:
            from utils.memory_fts_index import rebuild_memory_fts_index

            await asyncio.to_thread(rebuild_memory_fts_index, workspace)
        except Exception as exc:
            raise HTTPException(status_code=500, detail=str(exc)) from exc
        return JSONResponse(content={"ok": True})
