"""Zones tab — zone → place → linked-faces management + "go here" destination.

Hierarchy (replaces the old flat places):
  Zone (name + description)
    └─ Place (name + description + optional reference photos + linked face ids)

Routes live under /api/zones. Toggle + CRUD changes write
data/.recognition_state.json (the SAME file faces use); the Gemini child polls
it at 1 Hz and re-primes / announces mid-session. The "go here" endpoints set a
navigation target the robot will head to once N2 locomotion is wired — for now
they just record the target and feed Gemini the place's reference.
"""

from __future__ import annotations

import asyncio
import io
from typing import Optional

from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel

from Project.Sanad.config import BASE_DIR
from Project.Sanad.core.logger import get_logger
from Project.Sanad.dashboard.routes._safe_io import check_upload_size
from Project.Sanad.vision import recognition_state

log = get_logger("zones_routes")

router = APIRouter()

STATE_PATH = BASE_DIR / "data" / ".recognition_state.json"


# ── lazy subsystem accessors ────────────────────────────────

def _get_camera():
    try:
        from Project.Sanad.main import camera  # type: ignore
        return camera
    except Exception:
        return None


def _get_zone_gallery():
    try:
        from Project.Sanad.main import zone_gallery  # type: ignore
        return zone_gallery
    except Exception:
        return None


def _get_face_gallery():
    try:
        from Project.Sanad.main import gallery  # type: ignore
        return gallery
    except Exception:
        return None


def _require_zones():
    g = _get_zone_gallery()
    if g is None:
        raise HTTPException(503, "Zone gallery subsystem unavailable.")
    return g


def _bump_zones_version() -> int:
    cur = recognition_state.read(STATE_PATH)
    v = cur.zones_version + 1
    recognition_state.mutate(STATE_PATH, zones_version=v)
    return v


def _validate_image(content: bytes, filename: str | None = None) -> None:
    check_upload_size(content)
    if len(content) < 16:
        raise HTTPException(400, "Image too small / empty.")
    if not (content[:3] == b"\xff\xd8\xff" or content[:8] == b"\x89PNG\r\n\x1a\n"):
        raise HTTPException(400, f"Only JPEG/PNG accepted (got {filename or 'unknown'}).")


def _safe_photo_name(name: str) -> None:
    if "/" in name or ".." in name or "\x00" in name:
        raise HTTPException(400, "Invalid photo name.")


def _resolve_faces(face_ids: list[int]) -> list[dict]:
    """Turn linked face ids into [{id, name}] using the face gallery."""
    g = _get_face_gallery()
    out = []
    for fid in face_ids:
        name = None
        if g is not None:
            try:
                e = g.get(fid)
                name = e.name if e else None
            except Exception:
                name = None
        out.append({"id": fid, "name": name})
    return out


def _place_to_dict(p) -> dict:
    d = p.to_dict()
    d["faces"] = _resolve_faces(p.face_ids)
    return d


def _zone_to_dict(z) -> dict:
    return {
        "id": z.id, "name": z.name, "description": z.description,
        "linked_map": getattr(z, "linked_map", None),
        "added_at": z.added_at,
        "places": [_place_to_dict(p) for p in z.places],
    }


async def _maybe_drive_to_place(zone, place) -> Optional[dict]:
    """If the place links a nav2 place AND its zone's map is the one currently
    localized, actually DRIVE there (arbiter-gated + arm arrival monitor).
    Returns the drive outcome, or None when the place isn't drivable (no link).
    Best-effort: never raises into the caller."""
    nav_place = getattr(place, "nav_place", None)
    linked_map = getattr(zone, "linked_map", None)
    if not nav_place or not linked_map:
        return None
    try:
        from Project.Sanad.dashboard.routes import navigation as navmod
        from Project.Sanad.dashboard.routes import _arbiter
    except Exception:
        return {"ok": False, "reason": "nav_unavailable"}
    client = getattr(navmod, "_CLIENT", None)
    if client is None:
        return {"ok": False, "reason": "nav_unavailable"}
    try:
        st = await asyncio.to_thread(client.status)
        body = st.as_dict() if hasattr(st, "as_dict") else dict(st)
    except Exception as exc:  # noqa: BLE001
        return {"ok": False, "reason": "status_error", "detail": str(exc)[:120]}
    if not body.get("bringup_alive"):
        return {"ok": False, "reason": "no_map"}
    # The robot can only drive in the currently-localized map. Require the
    # zone's linked map to match (compare on the sanitized .db stem).
    active = (body.get("active_map") or "").strip().lower()
    want = (linked_map or "").strip().lower()
    if want.endswith(".db"):
        want = want[:-3]
    if active and want and active != want:
        return {"ok": False, "reason": "wrong_map",
                "active": body.get("active_map"), "want": linked_map}
    if not _arbiter.acquire_nav():
        return {"ok": False, "reason": "manual_armed"}
    drive = await asyncio.to_thread(client.goto, nav_place)
    if isinstance(drive, dict) and not drive.get("ok", True):
        _arbiter.release_nav()
        return {"ok": False, "reason": "dispatch_failed", "detail": drive}
    try:
        from Project.Sanad.navigation.goal_monitor import arm_goal
        arm_goal(nav_place)
    except Exception:
        pass
    return {"ok": True, "resolved": nav_place}


def _nav_target_dict(st, gallery) -> Optional[dict]:
    zid, pid = st.nav_target_zone_id, st.nav_target_place_id
    if not zid or not pid:
        return None
    zone_name = place_name = None
    if gallery is not None:
        try:
            z = gallery.get_zone(zid)
            zone_name = z.name if z else None
            p = gallery.get_place(zid, pid)
            place_name = p.name if p else None
        except Exception:
            pass
    return {"zone_id": zid, "place_id": pid,
            "zone_name": zone_name, "place_name": place_name}


# ── state + toggle ──────────────────────────────────────────

@router.get("/state")
async def get_state():
    st = recognition_state.read(STATE_PATH)
    g = _get_zone_gallery()
    zones_count = places_count = 0
    if g is not None:
        try:
            zones = g.list_zones()
            zones_count = len(zones)
            places_count = sum(len(z.places) for z in zones)
        except Exception:
            pass
    return {
        "zone_rec_enabled": st.zone_rec_enabled,
        "zones_version": st.zones_version,
        "zones_count": zones_count,
        "places_count": places_count,
        "nav_target": _nav_target_dict(st, g),
    }


@router.post("/zone-rec")
async def set_zone_rec(on: bool = Query(...)):
    """Enable / disable the robot's knowledge of zones & places (hot)."""
    st = recognition_state.mutate(STATE_PATH, zone_rec_enabled=bool(on))
    log.info("zone recognition %s", "ON" if on else "OFF")
    return {"ok": True, "zone_rec_enabled": st.zone_rec_enabled}


@router.post("/sync")
async def sync_zones():
    v = _bump_zones_version()
    log.info("zones sync requested → v.%d", v)
    return {"ok": True, "zones_version": v}


# ── zones CRUD ──────────────────────────────────────────────

class NamePayload(BaseModel):
    name: Optional[str] = None


class DescribePayload(BaseModel):
    description: Optional[str] = None


class FacesPayload(BaseModel):
    face_ids: list[int] = []


class LinkMapPayload(BaseModel):
    # nav2 map .db basename (e.g. "office.db"); None/"" unlinks.
    map: Optional[str] = None


class NavPlacePayload(BaseModel):
    # nav2 place name in the zone's linked map; None/"" unlinks.
    nav_place: Optional[str] = None


@router.get("")
async def list_zones():
    g = _require_zones()
    zones = g.list_zones()
    return {"zones": [_zone_to_dict(z) for z in zones], "total": len(zones)}


@router.post("/create")
async def create_zone(name: Optional[str] = Query(default=None),
                      description: Optional[str] = Query(default=None)):
    g = _require_zones()
    if not (name or "").strip() and not (description or "").strip():
        raise HTTPException(400, "A zone needs at least a name or a description.")
    z = g.create_zone(name=name, description=description)
    _bump_zones_version()
    return {"ok": True, "zone": _zone_to_dict(z)}


@router.post("/{zone_id}/rename")
async def rename_zone(zone_id: int, payload: NamePayload):
    g = _require_zones()
    try:
        g.rename_zone(zone_id, payload.name)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    _bump_zones_version()
    return {"ok": True, "zone": _zone_to_dict(g.get_zone(zone_id))}


@router.post("/{zone_id}/describe")
async def describe_zone(zone_id: int, payload: DescribePayload):
    g = _require_zones()
    try:
        g.describe_zone(zone_id, payload.description)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    _bump_zones_version()
    return {"ok": True, "zone": _zone_to_dict(g.get_zone(zone_id))}


@router.delete("/{zone_id}")
async def delete_zone(zone_id: int):
    g = _require_zones()
    try:
        g.delete_zone(zone_id)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    # If the active destination was inside this zone, clear it.
    st = recognition_state.read(STATE_PATH)
    if st.nav_target_zone_id == zone_id:
        recognition_state.mutate(STATE_PATH, nav_target_zone_id=0, nav_target_place_id=0)
    _bump_zones_version()
    return {"ok": True, "deleted": zone_id}


# ── places CRUD (within a zone) ─────────────────────────────

@router.post("/{zone_id}/places/create")
async def create_place(
    zone_id: int,
    name: Optional[str] = Query(default=None),
    description: Optional[str] = Query(default=None),
    face_ids: list[int] = Query(default=[]),
    nav_place: Optional[str] = Query(default=None),
    files: Optional[list[UploadFile]] = File(default=None),
):
    g = _require_zones()
    if g.get_zone(zone_id) is None:
        raise HTTPException(404, f"zone_{zone_id} not found")
    if not (name or "").strip() and not (description or "").strip():
        raise HTTPException(400, "A place needs at least a name or a description.")
    image_bytes: list[bytes] = []
    for f in (files or []):
        content = await f.read()
        if not content:
            continue
        _validate_image(content, f.filename)
        image_bytes.append(content)
    p = g.create_place(zone_id, name=name, description=description,
                       face_ids=face_ids, image_bytes_list=image_bytes or None,
                       nav_place=nav_place)
    _bump_zones_version()
    return {"ok": True, "place": _place_to_dict(p)}


@router.post("/{zone_id}/link_map")
async def link_zone_map(zone_id: int, payload: LinkMapPayload):
    """Bind (or unbind) the zone to a nav2 map .db. Required before its places
    can link to that map's nav places / before Gemini Nav can drive in it."""
    g = _require_zones()
    try:
        g.set_zone_map(zone_id, payload.map)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    _bump_zones_version()
    return {"ok": True, "zone": _zone_to_dict(g.get_zone(zone_id))}


@router.post("/{zone_id}/places/{place_id}/nav_link")
async def link_place_nav(zone_id: int, place_id: int, payload: NavPlacePayload):
    """Link (or unlink) a place to a nav2 place name in the zone's map — this is
    what makes the place drivable from voice / 'Go here'."""
    g = _require_zones()
    try:
        g.set_place_nav(zone_id, place_id, payload.nav_place)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    _bump_zones_version()
    return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))}


@router.post("/{zone_id}/places/{place_id}/rename")
async def rename_place(zone_id: int, place_id: int, payload: NamePayload):
    g = _require_zones()
    try:
        g.rename_place(zone_id, place_id, payload.name)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    _bump_zones_version()
    return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))}


@router.post("/{zone_id}/places/{place_id}/describe")
async def describe_place(zone_id: int, place_id: int, payload: DescribePayload):
    g = _require_zones()
    try:
        g.describe_place(zone_id, place_id, payload.description)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    _bump_zones_version()
    return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))}


@router.post("/{zone_id}/places/{place_id}/faces")
async def set_place_faces(zone_id: int, place_id: int, payload: FacesPayload):
    """Replace the set of saved faces linked to this place."""
    g = _require_zones()
    try:
        g.set_place_faces(zone_id, place_id, payload.face_ids)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    _bump_zones_version()
    return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))}


@router.post("/{zone_id}/places/{place_id}/capture")
async def capture_to_place(zone_id: int, place_id: int):
    g = _require_zones()
    cam = _get_camera()
    if cam is None or not cam.is_running():
        raise HTTPException(409, "Camera is not running. Toggle Vision ON first.")
    jpeg = cam.get_fresh_frame(max_age_s=0.5, timeout_s=1.5)
    if not jpeg:
        raise HTTPException(409, "Camera has no frame yet.")
    try:
        fname = g.add_photo(zone_id, place_id, jpeg)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    _bump_zones_version()
    return {"ok": True, "added": fname, "place": _place_to_dict(g.get_place(zone_id, place_id))}


@router.post("/{zone_id}/places/{place_id}/upload")
async def upload_to_place(zone_id: int, place_id: int,
                          files: list[UploadFile] = File(...)):
    g = _require_zones()
    if g.get_place(zone_id, place_id) is None:
        raise HTTPException(404, f"zone_{zone_id}/place_{place_id} not found")
    added: list[str] = []
    for f in files:
        content = await f.read()
        _validate_image(content, f.filename)
        try:
            added.append(g.add_photo(zone_id, place_id, content))
        except FileNotFoundError as exc:
            raise HTTPException(404, str(exc))
    _bump_zones_version()
    return {"ok": True, "added": added, "place": _place_to_dict(g.get_place(zone_id, place_id))}


@router.delete("/{zone_id}/places/{place_id}")
async def delete_place(zone_id: int, place_id: int):
    g = _require_zones()
    try:
        g.delete_place(zone_id, place_id)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    st = recognition_state.read(STATE_PATH)
    if st.nav_target_zone_id == zone_id and st.nav_target_place_id == place_id:
        recognition_state.mutate(STATE_PATH, nav_target_zone_id=0, nav_target_place_id=0)
    _bump_zones_version()
    return {"ok": True, "deleted": place_id}


@router.delete("/{zone_id}/places/{place_id}/photo/{photo_name}")
async def delete_place_photo(zone_id: int, place_id: int, photo_name: str):
    g = _require_zones()
    _safe_photo_name(photo_name)
    try:
        g.delete_photo(zone_id, place_id, photo_name)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    _bump_zones_version()
    return {"ok": True, "deleted": photo_name}


@router.get("/{zone_id}/places/{place_id}/photo/{photo_name}")
async def get_place_photo(zone_id: int, place_id: int, photo_name: str,
                          download: int = Query(default=0)):
    g = _require_zones()
    _safe_photo_name(photo_name)
    path = g.get_photo(zone_id, place_id, photo_name)
    if path is None:
        raise HTTPException(404, "Photo not found.")
    media = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
    headers = {}
    if download:
        headers["Content-Disposition"] = (
            f'attachment; filename="zone_{zone_id}_place_{place_id}_{photo_name}"')
    return FileResponse(path, media_type=media, headers=headers)


@router.get("/{zone_id}/places/{place_id}/download.zip")
async def download_place_zip(zone_id: int, place_id: int):
    g = _require_zones()
    try:
        data = g.zip_place(zone_id, place_id)
    except FileNotFoundError as exc:
        raise HTTPException(404, str(exc))
    return StreamingResponse(
        io.BytesIO(data), media_type="application/zip",
        headers={
            "Content-Disposition": f'attachment; filename="zone_{zone_id}_place_{place_id}.zip"',
            "Content-Length": str(len(data)),
        },
    )


# ── "go here" navigation target ─────────────────────────────

@router.post("/{zone_id}/places/{place_id}/go")
async def go_to_place(zone_id: int, place_id: int):
    """Set this place as the active destination AND, if the place links a nav2
    place in this zone's (currently-localized) map, actually drive there.

    Two effects: (1) records nav_target so the Gemini child primes on the
    reference photo + announces the destination; (2) if drivable, dispatches a
    Nav2 goal (arbiter-gated, with arrival monitoring). A place with no nav link
    is announce-only, as before."""
    g = _require_zones()
    p = g.get_place(zone_id, place_id)
    if p is None:
        raise HTTPException(404, f"zone_{zone_id}/place_{place_id} not found")
    recognition_state.mutate(STATE_PATH,
                             nav_target_zone_id=zone_id,
                             nav_target_place_id=place_id)
    log.info("nav target set → zone_%d/place_%d (%s)", zone_id, place_id,
             p.name or "(unnamed)")
    zone = g.get_zone(zone_id)
    drive = await _maybe_drive_to_place(zone, p)
    return {"ok": True,
            "nav_target": {"zone_id": zone_id, "place_id": place_id,
                           "place_name": p.name},
            "drive": drive}


@router.post("/nav/clear")
async def clear_nav_target():
    recognition_state.mutate(STATE_PATH, nav_target_zone_id=0, nav_target_place_id=0)
    log.info("nav target cleared")
    return {"ok": True, "nav_target": None}


def _resolve_map_path(client, linked_map: str) -> Optional[str]:
    """Find the .db path for a zone's linked map name via the nav client."""
    want = (linked_map or "").strip().lower()
    want_stem = want[:-3] if want.endswith(".db") else want
    try:
        maps = client.list_maps() or []
    except Exception:
        return None
    for m in maps:
        nm = (m.get("name") or "").strip().lower()
        if nm == want or (nm[:-3] if nm.endswith(".db") else nm) == want_stem:
            return m.get("path")
    return None


@router.post("/{zone_id}/gemini_nav/start")
async def gemini_nav_start(zone_id: int):
    """Enter 'Gemini Nav' for a zone: localize the zone's map, turn on camera +
    face + zone recognition + movement, ensure the Gemini session is live, and
    greet the user so they can converse to navigate.

    The robot only ever runs ONE map; this loads the zone's map in localize-only
    mode (so it cannot fresh-map while driving), exactly as the user requires.
    """
    g = _require_zones()
    zone = g.get_zone(zone_id)
    if zone is None:
        raise HTTPException(404, f"zone_{zone_id} not found")
    linked_map = getattr(zone, "linked_map", None)
    if not linked_map:
        raise HTTPException(400, "This zone has no linked nav2 map — link one first.")

    # 1) Localize the zone's map (single bringup, mode 3 — no fresh mapping).
    loaded: dict = {"ok": False, "reason": "nav_unavailable"}
    try:
        from Project.Sanad.dashboard.routes import navigation as navmod
        client = getattr(navmod, "_CLIENT", None)
        if client is not None:
            db_path = await asyncio.to_thread(_resolve_map_path, client, linked_map)
            if db_path:
                loaded = await asyncio.to_thread(client.load_map, db_path)
            else:
                loaded = {"ok": False, "reason": "map_not_found", "map": linked_map}
    except Exception as exc:  # noqa: BLE001
        loaded = {"ok": False, "reason": "load_error", "detail": str(exc)[:160]}

    # 2) Camera + face + zone recognition + movement ON for the session.
    recognition_state.mutate(STATE_PATH,
                             vision_enabled=True, face_rec_enabled=True,
                             zone_rec_enabled=True, movement_enabled=True)
    _bump_zones_version()

    # 3) Ensure the Gemini session is live, then greet (zone + drivable places).
    session_started = False
    try:
        from Project.Sanad.main import live_sub
        if live_sub is not None:
            if hasattr(live_sub, "is_running") and not live_sub.is_running():
                await asyncio.to_thread(live_sub.start)
                session_started = True
            drivable = [p.name or p.nav_place for p in zone.places
                        if getattr(p, "nav_place", None)]
            zname = zone.name or f"zone {zone_id}"
            if drivable:
                placelist = ", ".join(str(x) for x in drivable)
                greet = (f"You are now in the '{zname}' zone. You can drive the "
                         f"user to: {placelist}. Greet the user warmly in your "
                         f"normal Khaleeji style and ask where they would like to go.")
            else:
                greet = (f"You are now in the '{zname}' zone, but no drivable "
                         f"places are linked to its map yet. Greet the user and "
                         f"say places still need to be linked before you can drive.")
            if hasattr(live_sub, "send_state"):
                live_sub.send_state("nav_zone", greet)
    except Exception as exc:  # noqa: BLE001
        log.warning("gemini_nav greet failed: %s", exc)

    return {"ok": True, "zone_id": zone_id, "zone": _zone_to_dict(zone),
            "loaded": loaded, "session_started": session_started}
