"""Public memorial page service (INDL-46).

Assembles the full public memorial payload and handles candle increments and
public tribute listing/submission. Security posture mirrors the INDL-43
directions service: tenant scoping + publish gate + soft-delete filter enforced
here in the service, and only allowlisted fields leave this layer.
"""
from __future__ import annotations

from datetime import date
from typing import List, Optional, Tuple
from uuid import UUID

from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from src.apps.memorials.models.memorial import Memorial
from src.apps.memorials.models.tribute import Tribute
from src.apps.memorials.schemas.responses import resolve_image_url
from src.apps.plots.models.plot import Plot
from src.apps.records.models.record import Record
from src.apps.settings.models.qr_code import QRCode
from src.apps.tenants.models.account import Account
from src.core.exceptions import NotFoundError
from src.core.geo import estimate_walking_minutes, haversine_distance_m
from src.apps.public.schemas.memorial import (
    CemeteryProfile,
    FamilyContactInfo,
    MemorialPhotoItem,
    PlotCoordinates,
    PublicMemorialDetail,
    PublicTributeItem,
    QuickFacts,
    TimelineEntry,
)

# Mirrors the defaults the tenant admin's memorial edit form starts new memorials
# with (indelis-admin EditMemorialPage) — applied whenever visibility_config is
# unset so older memorials keep today's behaviour.
DEFAULT_VISIBILITY_CONFIG = {
    "show_dob": True,
    "show_dod": True,
    "show_family_contact": False,
    "allow_tributes": True,
}

DEFAULT_HOURS_GROUNDS = "Daily, 8:00 AM – dusk"


def _compute_age(dob: Optional[date], dod: Optional[date]) -> Optional[int]:
    if not dob or not dod:
        return None
    age = dod.year - dob.year - ((dod.month, dod.day) < (dob.month, dob.day))
    return age if age >= 0 else None


def _image_url(s3_key: Optional[str]) -> Optional[str]:
    """Return a relative proxy path for an S3 key. The frontend prefixes the API
    origin. Returns None for an empty key so the UI can render a fallback.

    Delegates to the shared resolver so already-absolute or already-proxied
    values pass through instead of becoming ``/api/public/images/https://...``.
    """
    return resolve_image_url(s3_key)


async def get_public_memorial(
    db: AsyncSession, slug: str, tenant_id: UUID | str
) -> PublicMemorialDetail:
    """Load a published memorial and assemble the full public page payload.

    Raises NotFoundError for a missing tenant, unknown/cross-tenant slug,
    unpublished memorial, or a soft-deleted linked record (all mapped to 404 by
    the router to avoid an enumeration oracle).
    """
    if not tenant_id:
        raise NotFoundError("Memorial not found")

    result = await db.execute(
        select(Memorial)
        .options(
            selectinload(Memorial.record).selectinload(Record.plot).selectinload(Plot.section),
            selectinload(Memorial.record).selectinload(Record.family_contacts),
            selectinload(Memorial.photos),
            selectinload(Memorial.timeline_events),
            selectinload(Memorial.tributes),
        )
        .where(
            and_(
                Memorial.tenant_id == tenant_id,
                Memorial.slug == slug,
                Memorial.is_published.is_(True),
            )
        )
    )
    memorial = result.scalar_one_or_none()
    if memorial is None:
        raise NotFoundError("Memorial not found")

    record = memorial.record
    if record is None or getattr(record, "deleted_at", None) is not None:
        raise NotFoundError("Memorial not found")

    account = (
        await db.execute(select(Account).where(Account.id == tenant_id))
    ).scalar_one_or_none()

    plot = record.plot
    section = plot.section if plot else None

    # ── Display name ────────────────────────────────────────────────────────
    display_name = memorial.display_name or " ".join(
        p for p in [record.first_name, record.middle_name, record.last_name] if p
    ).strip()

    # ── Biography ────────────────────────────────────────────────────────────
    # The staff checkbox (biography_ai_generated) decides which version is
    # public. It also drives the AI-attribution note below, so the two can never
    # disagree — AI text is never rendered without the credit (INDL-46 AC-12).
    biography = (
        memorial.ai_biography_text
        if memorial.biography_ai_generated
        else memorial.biography_text
    )

    # ── Plot coordinates + walking estimate ─────────────────────────────────
    plot_coordinates: Optional[PlotCoordinates] = None
    if plot is not None:
        lat = plot.latitude
        lng = plot.longitude
        has_gps = haversine_distance_m(lat, lng, lat, lng) is not None
        walking_min: Optional[int] = None
        distance_m: Optional[int] = None
        if has_gps and account is not None:
            cfg = account.config_json or {}
            ent_lat = cfg.get("entrance_latitude")
            ent_lng = cfg.get("entrance_longitude")
            dist = haversine_distance_m(ent_lat, ent_lng, float(lat), float(lng))
            if dist is not None:
                distance_m = int(round(dist))
                walking_min = estimate_walking_minutes(dist)
        plot_coordinates = PlotCoordinates(
            latitude=float(lat) if has_gps else None,
            longitude=float(lng) if has_gps else None,
            has_gps=has_gps,
            walking_time_min=walking_min,
            distance_m=distance_m,
        )

    # ── Timeline (reuse existing memorial_timeline_events) ──────────────────
    timeline: List[TimelineEntry] = []
    for ev in sorted(memorial.timeline_events, key=lambda e: e.sort_order):
        year = ev.event_date.year if ev.event_date else None
        timeline.append(TimelineEntry(year=year, heading=ev.title, body=ev.description))

    # ── Photos ──────────────────────────────────────────────────────────────
    photos = [
        MemorialPhotoItem(
            id=p.id,
            url=_image_url(p.s3_key) or "",
            caption=p.caption,
            sort_order=p.sort_order,
        )
        for p in sorted(memorial.photos, key=lambda p: p.sort_order)
    ]

    approved = [t for t in memorial.tributes if t.status == "approved"]

    # ── Visibility config (INDL-44 memorial edit form) ──────────────────────
    # A memorial created before this setting existed has visibility_config=None;
    # fall back to the same defaults the admin edit form seeds new memorials
    # with so behaviour doesn't change for those rows.
    vc = {**DEFAULT_VISIBILITY_CONFIG, **(memorial.visibility_config or {})}
    show_dob = bool(vc.get("show_dob", True))
    show_dod = bool(vc.get("show_dod", True))
    allow_tributes = bool(vc.get("allow_tributes", True))

    visible_dob = record.date_of_birth if show_dob else None
    visible_dod = record.date_of_death if show_dod else None
    # Age at death implies both dates — only expose it when both are shown.
    age_at_death = _compute_age(record.date_of_birth, record.date_of_death) if (show_dob and show_dod) else None

    family_contact: Optional[FamilyContactInfo] = None
    if bool(vc.get("show_family_contact", False)):
        contacts = list(getattr(record, "family_contacts", None) or [])
        primary = next((c for c in contacts if c.is_primary), contacts[0] if contacts else None)
        if primary is not None:
            name = f"{primary.first_name} {primary.last_name}".strip()
            if name:
                family_contact = FamilyContactInfo(name=name, phone=primary.phone, email=primary.email)

    # ── QR code (INDL-11): the "plot" QR for this memorial's plot ───────────
    # Keyed on plot_ref, not the memorial slug: qr_codes.qr_type is constrained
    # to entrance|section|plot|headstone|contract, so the "memorial" type this
    # once queried could never exist and qr_code_url was always None. The plot
    # QR encodes {base}/map?plot={plot_ref}, which is what "Scan to share"
    # offers. _image_url returns None while svg_s3_key/pdf_s3_key are still
    # NULL, so a queued-but-unrendered QR correctly falls back to the
    # placeholder in ShareCard.
    qr_code_url: Optional[str] = None
    if plot is not None and plot.plot_ref:
        qr = (
            await db.execute(
                select(QRCode).where(
                    QRCode.tenant_id == tenant_id,
                    QRCode.qr_type == "plot",
                    QRCode.reference_id == plot.plot_ref,
                    QRCode.is_active.is_(True),
                )
            )
        ).scalar_one_or_none()
        if qr is not None:
            qr_code_url = _image_url(qr.svg_s3_key or qr.pdf_s3_key)

    # ── Cemetery profile ────────────────────────────────────────────────────
    cfg = (account.config_json or {}) if account else {}
    cemetery = CemeteryProfile(
        name=account.organization_name if account else None,
        address=getattr(account, "address", None) if account else None,
        phone=getattr(account, "contact_phone", None) if account else None,
        email=getattr(account, "contact_email", None) if account else None,
        hours_grounds=(cfg.get("visiting_hours") or DEFAULT_HOURS_GROUNDS),
        hours_office=cfg.get("office_hours"),
    )

    return PublicMemorialDetail(
        id=memorial.id,
        slug=memorial.slug,
        is_published=memorial.is_published,
        display_name=display_name,
        maiden_name=record.maiden_name,
        date_of_birth=visible_dob,
        date_of_death=visible_dod,
        age_at_death=age_at_death,
        plot_ref=plot.plot_ref if plot else None,
        section_code=section.code if section else None,
        biography=biography,
        biography_ai_generated=bool(memorial.biography_ai_generated),
        candle_count=memorial.candle_count or 0,
        photo_count=len(memorial.photos),
        tribute_count=len(approved),
        qr_code_url=qr_code_url,
        quick_facts=QuickFacts(
            birthplace=record.birthplace,
            city_of_residence=record.city_of_residence,
            occupation=record.occupation,
            service_date=memorial.service_date,
            service_location=memorial.service_location,
        ),
        plot_coordinates=plot_coordinates,
        timeline=timeline,
        photos=photos,
        cemetery=cemetery,
        family_contact=family_contact,
        allow_tributes=allow_tributes,
    )


async def _resolve_published_memorial(
    db: AsyncSession, slug: str, tenant_id: UUID | str
) -> Memorial:
    """Resolve a published, tenant-scoped memorial whose linked record is not
    soft-deleted. Mirrors the gate in get_public_memorial / the directions
    service so candle + tribute endpoints stay consistent with the page."""
    if not tenant_id:
        raise NotFoundError("Memorial not found")
    memorial = (
        await db.execute(
            select(Memorial)
            .options(selectinload(Memorial.record))
            .where(
                and_(
                    Memorial.tenant_id == tenant_id,
                    Memorial.slug == slug,
                    Memorial.is_published.is_(True),
                )
            )
        )
    ).scalar_one_or_none()
    if memorial is None:
        raise NotFoundError("Memorial not found")
    record = memorial.record
    if record is None or getattr(record, "deleted_at", None) is not None:
        raise NotFoundError("Memorial not found")
    return memorial


async def increment_candle(
    db: AsyncSession, slug: str, tenant_id: UUID | str
) -> int:
    """Increment candle_count by 1 and return the new total.

    Uses an atomic ``UPDATE ... SET candle_count = candle_count + 1 RETURNING``
    so concurrent visitors on different IPs cannot lose updates (the per-IP
    rate limit does not serialise cross-IP concurrency)."""
    memorial = await _resolve_published_memorial(db, slug, tenant_id)
    new_count = (
        await db.execute(
            update(Memorial)
            .where(Memorial.id == memorial.id)
            .values(candle_count=Memorial.candle_count + 1)
            .returning(Memorial.candle_count)
        )
    ).scalar_one()
    await db.flush()
    return new_count


async def list_approved_tributes(
    db: AsyncSession,
    slug: str,
    tenant_id: UUID | str,
    page: int = 1,
    page_size: int = 6,
) -> Tuple[List[PublicTributeItem], int]:
    """Return a page of approved tributes (newest first) plus the total count."""
    memorial = await _resolve_published_memorial(db, slug, tenant_id)

    base = and_(
        Tribute.tenant_id == tenant_id,
        Tribute.memorial_id == memorial.id,
        Tribute.status == "approved",
    )
    total = (
        await db.execute(select(func.count(Tribute.id)).where(base))
    ).scalar_one()

    offset = (page - 1) * page_size
    rows = (
        await db.execute(
            select(Tribute)
            .where(base)
            .order_by(Tribute.submitted_at.desc())
            .offset(offset)
            .limit(page_size)
        )
    ).scalars().all()

    items = [
        PublicTributeItem(
            id=t.id,
            submitter_name=t.submitter_name,
            relationship=t.relationship_type,
            message=t.message,
            photo_url=_image_url(t.photo_url) if t.photo_url else None,
            created_at=t.submitted_at,
        )
        for t in rows
    ]
    return items, total
