"""
Document Note Service · Batch 11 · local/dev notes backend
A-owned · session_a · FastAPI

File-backed CRUD + aggregate for the document note system defined in
docs/assets/notes/document_note_schema.json.

Replaces the Batch-6 browser-localStorage-only persistence path for
clients that point DS_NOTES_BASE at this service. Browser-local remains
the fallback when the service is unreachable.

Endpoints:
  GET    /api/notes/health
  POST   /api/notes/create          body {doc_id, title, body?, note_type?, priority?, section_ref?, created_by_type?}
  GET    /api/notes/list            [?doc_id=...]
  PATCH  /api/notes/{note_id}       body {status?, linked_change_ref?, title?, body?, priority?}
  DELETE /api/notes/{note_id}
  GET    /api/notes/aggregate
  GET    /api/notes/by-doc

Persistence:
  · single JSON file, rewritten atomically (temp-file + rename)
  · single-writer assumption · one uvicorn worker
  · start fresh by deleting notes_store.json (service recreates on next write)

Honest state:
  · Local/dev only. No TLS. No auth on the notes endpoints themselves.
  · Single-writer. Running multiple workers WILL lose updates.
  · No audit trail.

Run:
  bash run.sh                       # 127.0.0.1:8091
  DNS_PORT=8091 uvicorn app:app --reload

Env overrides:
  DNS_HOST                  bind host · default 127.0.0.1
  DNS_PORT                  bind port · default 8091
  DNS_STORE_PATH            notes store path · default ./notes_store.json
"""
from __future__ import annotations

import json
import logging
import os
import re
import time
import uuid
from typing import Any, Optional

from fastapi import FastAPI, HTTPException, Request, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

SERVICE_VERSION = "batch13-team-candidate-0.2"

# Batch 13: optional session enforcement. When DNS_REQUIRE_SESSION=true,
# mutation endpoints (create/patch/delete) require a valid access session
# cookie verified against the Document Access Service's /api/access/me.
# Read endpoints remain open for internal use.

import urllib.request  # stdlib — no new dependency

HERE = os.path.dirname(os.path.abspath(__file__))
STORE_PATH = os.environ.get("DNS_STORE_PATH", os.path.join(HERE, "notes_store.json"))

REQUIRE_SESSION = os.environ.get("DNS_REQUIRE_SESSION", "").lower() in {"1", "true", "yes"}
DAS_BASE = os.environ.get("DNS_DAS_BASE", "http://127.0.0.1:8090").rstrip("/")
ALLOW_ORIGINS_NOTES = [o.strip() for o in os.environ.get("DNS_ALLOW_ORIGINS", "*").split(",") if o.strip()]

logging.basicConfig(
    level=os.environ.get("DNS_LOG_LEVEL", "INFO"),
    format='{"ts":"%(asctime)s","level":"%(levelname)s","msg":%(message)s}',
)
log = logging.getLogger("document-note-service")

# ------------------------------------------------------------------
# Valid enums · mirror docs/assets/notes/document_note_schema.json
# ------------------------------------------------------------------
NOTE_STATUSES = {
    "open", "triaged", "in_review", "planned", "in_progress",
    "done", "deferred", "rejected",
}
OPEN_STATUSES = {"open", "triaged", "in_review", "planned", "in_progress"}
TERMINAL_STATUSES = {"done", "rejected"}
NOTE_PRIORITIES = {"P0", "P1", "P2", "P3"}
NOTE_TYPES = {
    "requirement", "enhancement", "bug", "content-gap", "structure",
    "linkage", "metadata", "glossary", "references", "export", "design",
    "runtime-mismatch",
}
CREATED_BY = {"human", "AI", "system", "imported"}

# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
STARTED_AT = time.time()
STARTED_AT_ISO = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(STARTED_AT))


def _load_store() -> dict:
    if not os.path.exists(STORE_PATH):
        return {"schema_version": "1.0", "notes": []}
    try:
        with open(STORE_PATH, encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data.get("notes"), list):
            data["notes"] = []
        return data
    except Exception as e:  # noqa: BLE001
        log.warning(json.dumps({"event": "store_load_error", "err": str(e)}))
        return {"schema_version": "1.0", "notes": []}


def _save_store(data: dict) -> None:
    tmp = STORE_PATH + ".tmp"
    payload = json.dumps(data, ensure_ascii=False, indent=2)
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(payload)
    os.replace(tmp, STORE_PATH)


STORE = _load_store()


def _honest_banner() -> dict:
    return {
        "en": "Local/dev · file-backed notes · no auth on notes endpoints · single-writer · NOT production",
        "th": "Local/dev · เก็บไฟล์ · endpoint ไม่มี auth · ผู้เขียนเดียว · ยังไม่ใช่ production",
    }


def _now_iso() -> str:
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())


_ID_RE = re.compile(r"^note-[a-z0-9]{1,}-[a-z0-9]{3,}$")


def _new_id() -> str:
    return f"note-{int(time.time()):x}-{uuid.uuid4().hex[:8]}"


def _as_note_row(doc_id: str, payload: dict) -> dict:
    title = (payload.get("title") or "").strip()
    if not title:
        raise HTTPException(400, {"reason": "missing_title"})
    note_type = payload.get("note_type") or "requirement"
    if note_type not in NOTE_TYPES:
        raise HTTPException(400, {"reason": "invalid_note_type", "allowed": sorted(NOTE_TYPES)})
    priority = payload.get("priority") or "P2"
    if priority not in NOTE_PRIORITIES:
        raise HTTPException(400, {"reason": "invalid_priority", "allowed": sorted(NOTE_PRIORITIES)})
    created_by_type = payload.get("created_by_type") or "human"
    if created_by_type not in CREATED_BY:
        raise HTTPException(400, {"reason": "invalid_created_by_type", "allowed": sorted(CREATED_BY)})
    now = _now_iso()
    return {
        "note_id": _new_id(),
        "doc_id": doc_id,
        "section_ref": payload.get("section_ref"),
        "created_at": now,
        "created_by_type": created_by_type,
        "title": title[:200],
        "body": payload.get("body", ""),
        "note_type": note_type,
        "priority": priority,
        "status": "open",
        "resolved_flag": False,
        "linked_change_ref": None,
        "resolved_at": None,
        "last_action_at": now,
    }


def _apply_patch(note: dict, patch: dict) -> dict:
    if "status" in patch:
        new_status = patch["status"]
        if new_status not in NOTE_STATUSES:
            raise HTTPException(400, {"reason": "invalid_status", "allowed": sorted(NOTE_STATUSES)})
        note["status"] = new_status
        note["resolved_flag"] = new_status in TERMINAL_STATUSES
        note["resolved_at"] = _now_iso() if note["resolved_flag"] else None
    if "linked_change_ref" in patch:
        v = patch["linked_change_ref"]
        if v is not None and not isinstance(v, str):
            raise HTTPException(400, {"reason": "invalid_linked_change_ref"})
        note["linked_change_ref"] = v
    if "title" in patch and patch["title"] is not None:
        note["title"] = str(patch["title"])[:200]
    if "body" in patch and patch["body"] is not None:
        note["body"] = str(patch["body"])
    if "priority" in patch and patch["priority"] is not None:
        if patch["priority"] not in NOTE_PRIORITIES:
            raise HTTPException(400, {"reason": "invalid_priority"})
        note["priority"] = patch["priority"]
    note["last_action_at"] = _now_iso()
    return note


# ------------------------------------------------------------------
# FastAPI
# ------------------------------------------------------------------
app = FastAPI(
    title="PTT Document Note Service",
    version=SERVICE_VERSION,
    description="Local/dev notes backend. File-backed. Single-writer. NOT production.",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOW_ORIGINS_NOTES,
    allow_credentials=True,
    allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
    allow_headers=["*"],
)


def _require_session(request: Request) -> None:
    """Batch 13: when DNS_REQUIRE_SESSION=true, forward cookies to DAS /api/access/me
    and require authenticated=true. This is advisory internal enforcement; still
    not a substitute for real identity + TLS.
    """
    if not REQUIRE_SESSION:
        return
    cookie = request.headers.get("cookie", "")
    if "ds_session=" not in cookie:
        raise HTTPException(401, {"reason": "session_required", "hint": "set DNS_REQUIRE_SESSION=false for local dev OR log in via DAS"})
    try:
        req = urllib.request.Request(DAS_BASE + "/api/access/me", headers={"Cookie": cookie})
        with urllib.request.urlopen(req, timeout=2) as r:
            data = json.loads(r.read().decode("utf-8"))
        if not data.get("authenticated"):
            raise HTTPException(401, {"reason": "session_invalid"})
    except HTTPException:
        raise
    except Exception as e:  # noqa: BLE001
        raise HTTPException(503, {"reason": "access_service_unavailable", "err": str(e)[:200]})


@app.get("/api/notes/health")
def health():
    notes = STORE.get("notes", [])
    docs_touched = len({n.get("doc_id") for n in notes if n.get("doc_id")})
    return {
        "ok": True,
        "service_version": SERVICE_VERSION,
        "mode": "local-dev",
        "notes_total": len(notes),
        "docs_touched": docs_touched,
        "store_path": STORE_PATH,
        "require_session": REQUIRE_SESSION,
        "das_base": DAS_BASE,
        "allow_origins": ALLOW_ORIGINS_NOTES,
        "started_at": STARTED_AT_ISO,
        "honest_banner": _honest_banner(),
    }


@app.post("/api/notes/create")
async def create(request: Request):
    _require_session(request)
    try:
        payload = await request.json()
    except Exception:  # noqa: BLE001
        raise HTTPException(400, {"reason": "invalid_json"})
    doc_id = (payload or {}).get("doc_id")
    if not doc_id or not isinstance(doc_id, str):
        raise HTTPException(400, {"reason": "missing_doc_id"})
    note = _as_note_row(doc_id, payload)
    STORE.setdefault("notes", []).append(note)
    _save_store(STORE)
    return {"note": note, "mode": "local-dev"}


@app.get("/api/notes/list")
def list_notes(doc_id: Optional[str] = Query(default=None)):
    notes = STORE.get("notes", [])
    if doc_id:
        notes = [n for n in notes if n.get("doc_id") == doc_id]
    return {"notes": notes, "count": len(notes), "mode": "local-dev"}


@app.patch("/api/notes/{note_id}")
async def patch_note(note_id: str, request: Request):
    _require_session(request)
    try:
        payload = await request.json()
    except Exception:  # noqa: BLE001
        payload = {}
    if not isinstance(payload, dict):
        raise HTTPException(400, {"reason": "invalid_patch_body"})
    notes = STORE.get("notes", [])
    for idx, n in enumerate(notes):
        if n.get("note_id") == note_id:
            notes[idx] = _apply_patch(n, payload)
            _save_store(STORE)
            return {"note": notes[idx], "mode": "local-dev"}
    raise HTTPException(404, {"reason": "note_not_found", "note_id": note_id})


@app.delete("/api/notes/{note_id}")
def delete_note(note_id: str, request: Request):
    _require_session(request)
    notes = STORE.get("notes", [])
    remaining = [n for n in notes if n.get("note_id") != note_id]
    if len(remaining) == len(notes):
        raise HTTPException(404, {"reason": "note_not_found", "note_id": note_id})
    STORE["notes"] = remaining
    _save_store(STORE)
    return {"ok": True, "deleted": note_id, "mode": "local-dev"}


@app.get("/api/notes/aggregate")
def aggregate():
    notes = STORE.get("notes", [])
    total = len(notes)
    open_cnt = sum(1 for n in notes if n.get("status") in OPEN_STATUSES)
    done_cnt = sum(1 for n in notes if n.get("status") in TERMINAL_STATUSES)
    deferred_cnt = sum(1 for n in notes if n.get("status") == "deferred")
    rejected_cnt = sum(1 for n in notes if n.get("status") == "rejected")
    p0_open = sum(1 for n in notes if n.get("priority") == "P0" and n.get("status") in OPEN_STATUSES)
    docs_touched = {n.get("doc_id") for n in notes if n.get("doc_id")}

    by_doc_open: dict[str, int] = {}
    for n in notes:
        if n.get("status") in OPEN_STATUSES:
            by_doc_open[n.get("doc_id", "?")] = by_doc_open.get(n.get("doc_id", "?"), 0) + 1
    top_docs = sorted(by_doc_open.items(), key=lambda kv: kv[1], reverse=True)[:5]
    top_docs_list = [{"doc_id": d, "open": n} for d, n in top_docs]

    recent = sorted(notes, key=lambda n: n.get("last_action_at") or "", reverse=True)[:8]
    return {
        "counters": {
            "total": total,
            "open": open_cnt,
            "done": done_cnt,
            "deferred": deferred_cnt,
            "rejected": rejected_cnt,
            "p0_open": p0_open,
            "docs_touched": len(docs_touched),
        },
        "top_docs_by_open": top_docs_list,
        "recent_activity": recent,
        "mode": "local-dev",
        "generated_at": _now_iso(),
        "honest_banner": _honest_banner(),
    }


@app.get("/api/notes/by-doc")
def by_doc():
    notes = STORE.get("notes", [])
    out: dict[str, dict] = {}
    for n in notes:
        d = n.get("doc_id", "?")
        row = out.setdefault(d, {"total": 0, "open": 0, "done": 0, "deferred": 0, "last_action_at": None})
        row["total"] += 1
        st = n.get("status")
        if st in OPEN_STATUSES:
            row["open"] += 1
        elif st in TERMINAL_STATUSES:
            row["done"] += 1
        elif st == "deferred":
            row["deferred"] += 1
        la = n.get("last_action_at") or ""
        if la > (row["last_action_at"] or ""):
            row["last_action_at"] = la
    return {"by_doc": out, "mode": "local-dev", "honest_banner": _honest_banner()}


@app.exception_handler(HTTPException)
async def handle_http_error(request: Request, exc: HTTPException):
    log.warning(json.dumps({"event": "http_error", "code": exc.status_code, "detail": exc.detail}))
    return JSONResponse(status_code=exc.status_code, content={"error": exc.detail, "mode": "local-dev"})
