"""
Public cemetery map service (INDL-56).

A read-only, data-minimized public projection over the same PostGIS data the
authenticated admin map (`plot_service.list_for_map`) uses. All sensitive
fields are dropped and burial identity is gated server-side:

  * A plot exposes an occupant name / birth-death years ONLY when the linked
    record is ``visibility_config == 'public'`` (and not soft-deleted).
  * ``memorial_slug`` / ``has_memorial`` are set ONLY when the memorial
    ``is_published``.

The client is untrusted: gates are enforced here, never by the UI hiding
fields. Missing tenant, disabled ``publicMap`` flag, or unknown plot all
resolve to a 404 (fail-closed, no enumeration oracle).
"""
from __future__ import annotations

import logging
from typing import Optional
from uuid import UUID

from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from src.apps.plots.models.plot import Plot
from src.apps.plots.services.plot_service import PlotService
from src.apps.plots.services.plot_status_service import PlotStatusService
from src.apps.records.models.record import Record
from src.apps.sections.models.section import Section
from src.apps.tenants.models.account import Account
from src.apps.public.schemas.map import (
    PublicMapPlotProperties,
    PublicMapSectionProperties,
    PublicMapStatus,
    PublicPlotDetailResponse,
)
from src.core.exceptions import NotFoundError
from src.core.geo import estimate_walking_minutes, haversine_distance_m
from src.core.utils.geometry import geometry_to_geojson

logger = logging.getLogger(__name__)

_FALLBACK_COLOR = "#94a3b8"
_MEMORIAL_EXCERPT_MAX = 280
# Feature-flag key; defaults to enabled so existing tenants keep the map unless
# an operator explicitly disables it via accounts.feature_flags.
_PUBLIC_MAP_FLAG = "publicMap"


def is_public_map_enabled(account: Optional[Account]) -> bool:
    """Read the publicMap feature flag (defaults to True when unset)."""
    if account is None:
        return False
    flags = account.feature_flags or {}
    return bool(flags.get(_PUBLIC_MAP_FLAG, True))


async def _get_account(db: AsyncSession, tenant_id: UUID) -> Optional[Account]:
    result = await db.execute(select(Account).where(Account.id == tenant_id))
    return result.scalar_one_or_none()


async def _account_map_context(db: AsyncSession, tenant_id: UUID) -> dict:
    """Center [lat, lng] + map_mode, mirroring the admin map bootstrap."""
    row = (
        await db.execute(
            text(
                "SELECT map_mode, "
                "ST_Y(centroid::geometry) AS lat, ST_X(centroid::geometry) AS lng "
                "FROM accounts WHERE id = :aid"
            ),
            {"aid": str(tenant_id)},
        )
    ).first()
    map_mode = row[0] if row and row[0] else "image"
    center = None
    if row and row[1] is not None:
        center = [float(row[1]), float(row[2])]
    return {"map_mode": map_mode, "center": center}


def _effective_price(plot: Plot) -> Optional[float]:
    price = plot.price_override
    if price is None and plot.plot_type is not None:
        price = plot.plot_type.default_price
    return float(price) if price is not None else None


def _plot_geometry(plot: Plot) -> Optional[dict]:
    geom = geometry_to_geojson(plot.geometry)
    if geom is None:
        geom = geometry_to_geojson(plot.centroid)
    if (
        geom is None
        and plot.latitude is not None
        and plot.longitude is not None
    ):
        geom = {
            "type": "Point",
            "coordinates": [float(plot.longitude), float(plot.latitude)],
        }
    return geom


def _identity_for(plot: Plot) -> dict:
    """Resolve the gated burial-identity fields for a plot.

    Returns only public-safe values. When the record is not public (or absent /
    soft-deleted) the name and years are ``None`` and the plot renders as a
    generic marker. ``memorial_slug`` requires a *published* memorial.
    """
    record = plot.record if (plot.record and plot.record.deleted_at is None) else None

    has_memorial = False
    memorial_slug: Optional[str] = None
    occupant_name: Optional[str] = None
    year_of_birth: Optional[int] = None
    year_of_death: Optional[int] = None

    # Identity of any kind (name, years, AND the memorial link) is exposed ONLY
    # for an explicitly public record. A record hidden by an operator must never
    # be re-identified via its memorial slug, even if that memorial was
    # previously published (AC-05 / T-05: "record not public → generic Occupied,
    # no View Memorial").
    if record is not None and record.visibility_config == "public":
        occupant_name = f"{record.first_name} {record.last_name}".strip() or None
        year_of_birth = record.date_of_birth.year if record.date_of_birth else None
        year_of_death = record.date_of_death.year if record.date_of_death else None

        memorial = getattr(record, "memorial", None)
        if memorial is not None and memorial.is_published:
            has_memorial = True
            memorial_slug = memorial.slug

    return {
        "has_memorial": has_memorial,
        "memorial_slug": memorial_slug,
        "occupant_name": occupant_name,
        "year_of_birth": year_of_birth,
        "year_of_death": year_of_death,
    }


def _public_plot_feature(plot: Plot, color_map: dict) -> dict:
    props = PublicMapPlotProperties(
        id=str(plot.id),
        plot_ref=plot.plot_ref,
        label=plot.label_manual or plot.plot_ref,
        status=plot.status,
        color=color_map.get(plot.status, _FALLBACK_COLOR),
        section_code=plot.section.code if plot.section else None,
        plot_type_name=plot.plot_type.name if plot.plot_type else None,
        price=_effective_price(plot),
        public_description=plot.public_description,
        latitude=float(plot.latitude) if plot.latitude is not None else None,
        longitude=float(plot.longitude) if plot.longitude is not None else None,
        **_identity_for(plot),
    )
    return {
        "type": "Feature",
        "geometry": _plot_geometry(plot),
        # model_dump enforces the allowlist — no extra keys can slip through.
        "properties": props.model_dump(mode="json"),
    }


async def list_public_map(db: AsyncSession, tenant_id: Optional[UUID]) -> dict:
    """Public map bootstrap. Raises NotFoundError (404) when unavailable."""
    if not tenant_id:
        raise NotFoundError("Cemetery map not found")

    account = await _get_account(db, tenant_id)
    if not is_public_map_enabled(account):
        raise NotFoundError("Cemetery map not found")

    ctx = await _account_map_context(db, tenant_id)

    statuses = await PlotStatusService(db).list_or_seed(tenant_id)
    color_map = {s.status_key: s.color_hex for s in statuses if s.status_key}

    plot_svc = PlotService(db)
    counts = await plot_svc.section_counts(tenant_id)

    # Sections (public: code/name/colour/counts + boundary GeoJSON so polygons
    # render — the existing /public/sections endpoint omits boundary).
    sections = (
        (await db.execute(select(Section).where(Section.tenant_id == tenant_id)))
        .scalars()
        .all()
    )
    section_features = []
    for s in sections:
        c = counts.get(
            s.id, {"total": 0, "available": 0, "occupied": 0, "reserved": 0}
        )
        occupied_reserved = c["occupied"] + c["reserved"]
        occupancy_pct = (
            round(occupied_reserved / c["total"] * 100, 1) if c["total"] else 0.0
        )
        props = PublicMapSectionProperties(
            id=str(s.id),
            code=s.code,
            name=s.name,
            display_color=s.display_color,
            total=c["total"],
            available=c["available"],
            occupancy_pct=occupancy_pct,
        )
        section_features.append(
            {
                "type": "Feature",
                "geometry": geometry_to_geojson(s.boundary),
                "properties": props.model_dump(mode="json"),
            }
        )

    # Plots (public props only; record + memorial eager-loaded for the gate).
    plots = (
        (
            await db.execute(
                select(Plot)
                .options(
                    selectinload(Plot.section),
                    selectinload(Plot.plot_type),
                    selectinload(Plot.record).selectinload(Record.memorial),
                )
                .where(Plot.tenant_id == tenant_id)
                .order_by(Plot.plot_ref.asc())
            )
        )
        .scalars()
        .all()
    )
    plot_features = [_public_plot_feature(p, color_map) for p in plots]

    return {
        "map_mode": ctx["map_mode"],
        "center": ctx["center"],
        # Cemetery outer boundary polygon (accounts.boundary) so the public map
        # can draw the grounds outline and fit to it (AC-01). Null for tenants
        # that have not drawn a boundary (image-mode) — client degrades to
        # marker/section bounds.
        "boundary": geometry_to_geojson(account.boundary) if account else None,
        "statuses": [
            PublicMapStatus(
                id=str(s.id),
                name=s.name,
                color_hex=s.color_hex,
                status_key=s.status_key,
                sort_order=s.sort_order or 0,
            ).model_dump(mode="json")
            for s in statuses
        ],
        "sections": {"type": "FeatureCollection", "features": section_features},
        "plots": {"type": "FeatureCollection", "features": plot_features},
    }


def _entrance_coords(account: Optional[Account]) -> Optional[tuple[float, float]]:
    if account is None:
        return None
    cfg = account.config_json or {}
    lat = cfg.get("entrance_latitude")
    lng = cfg.get("entrance_longitude")
    if lat is None or lng is None:
        return None
    try:
        return float(lat), float(lng)
    except (TypeError, ValueError):
        return None


async def get_public_plot_detail(
    db: AsyncSession, tenant_id: Optional[UUID], plot_ref: str
) -> dict:
    """Single public plot detail. Fail-closed 404 on any miss."""
    if not tenant_id:
        raise NotFoundError("Plot not found")

    account = await _get_account(db, tenant_id)
    if not is_public_map_enabled(account):
        raise NotFoundError("Plot not found")

    plot = (
        (
            await db.execute(
                select(Plot)
                .options(
                    selectinload(Plot.section),
                    selectinload(Plot.plot_type),
                    selectinload(Plot.record).selectinload(Record.memorial),
                )
                .where(Plot.tenant_id == tenant_id, Plot.plot_ref == plot_ref)
            )
        )
        .scalars()
        .first()
    )
    if plot is None:
        raise NotFoundError("Plot not found")

    statuses = await PlotStatusService(db).list_or_seed(tenant_id)
    color_map = {s.status_key: s.color_hex for s in statuses if s.status_key}

    identity = _identity_for(plot)

    # Memorial excerpt (only when the memorial is published + record public).
    excerpt: Optional[str] = None
    photo_url: Optional[str] = None
    if identity["has_memorial"] and identity["occupant_name"]:
        record = plot.record
        memorial = getattr(record, "memorial", None)
        if memorial is not None:
            bio = memorial.biography_text or ""
            bio = bio.strip()
            if bio:
                excerpt = (
                    bio
                    if len(bio) <= _MEMORIAL_EXCERPT_MAX
                    else bio[:_MEMORIAL_EXCERPT_MAX].rstrip() + "…"
                )
        if record is not None and record.photo_url:
            photo_url = record.photo_url

    # Optional walking estimate from the cemetery entrance to the plot.
    walking_minutes: Optional[int] = None
    distance_m: Optional[float] = None
    entrance = _entrance_coords(account)
    if (
        entrance is not None
        and plot.latitude is not None
        and plot.longitude is not None
    ):
        distance_m = haversine_distance_m(
            entrance[0], entrance[1], float(plot.latitude), float(plot.longitude)
        )
        walking_minutes = estimate_walking_minutes(distance_m)

    detail = PublicPlotDetailResponse(
        plot_ref=plot.plot_ref,
        label=plot.label_manual or plot.plot_ref,
        status=plot.status,
        color=color_map.get(plot.status, _FALLBACK_COLOR),
        section_code=plot.section.code if plot.section else None,
        section_name=plot.section.name if plot.section else None,
        plot_type_name=plot.plot_type.name if plot.plot_type else None,
        price=_effective_price(plot),
        public_description=plot.public_description,
        latitude=float(plot.latitude) if plot.latitude is not None else None,
        longitude=float(plot.longitude) if plot.longitude is not None else None,
        memorial_excerpt=excerpt,
        memorial_photo_url=photo_url,
        walking_minutes=walking_minutes,
        distance_m=round(distance_m, 1) if distance_m is not None else None,
        **identity,
    )
    return detail.model_dump(mode="json")
