"""CSV import, CSV export and QR generation (tasks 3.8, 3.9, 3.10).

Export is auth-gated and always audited: this is personal data leaving the system, and
FR-5.7 requires knowing who took what, when.
"""

import uuid
from collections.abc import Iterator, Sequence
from typing import Any

import segno
from fastapi import (
    APIRouter,
    Depends,
    File,
    Query,
    Request,
    Response,
    UploadFile,
)
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from app.config import get_settings
from app.db import get_session
from app.models import Event, Guest, Invitation, Rsvp
from app.models.enums import GuestSide, GuestSource, InvitationStatus
from app.services import audit, csv_io, rsvp_service, scope
from app.services.auth import CurrentAdmin, require
from app.services.policy import Action
from app.services.tokens import allocate_short_code, allocate_token

router = APIRouter(tags=["admin"])


def _client_ip(request: Request) -> str | None:
    fwd = request.headers.get("x-forwarded-for")
    return fwd.split(",")[0].strip() if fwd else (request.client.host if request.client else None)


# ---------------------------------------------------------------- CSV import


class ImportRowReport(BaseModel):
    line_number: int
    full_name: str
    errors: list[str]
    would_merge: bool = False


class ImportPreview(BaseModel):
    total_rows: int
    valid_rows: int
    invalid_rows: int
    duplicates: int
    preview: list[ImportRowReport]
    errors: list[ImportRowReport]


class ImportResult(BaseModel):
    imported: int
    merged: int
    skipped: int
    errors: list[ImportRowReport]


@router.post("/admin/events/{event_id}/guests/import/preview", response_model=ImportPreview)
async def preview_import(
    event_id: uuid.UUID,
    file: UploadFile = File(...),
    admin: CurrentAdmin = Depends(require(Action.IMPORT_GUESTS)),
    session: AsyncSession = Depends(get_session),
) -> ImportPreview:
    """Dry run: first 10 rows plus every error, before anything is written (FR-4.8).

    Duplicates are judged against this event's list only (design D11) — a phone already on
    another event's list is a different guest record, not a re-import.
    """
    # Scoped at preview as well as at commit: a spreadsheet must not be usable to probe
    # another owner's list for which of its contacts already exist (spec guest-management).
    await scope.require_event_access(session, admin, event_id)

    rows = csv_io.parse_import_csv(await file.read())

    duplicates = 0
    reports: list[ImportRowReport] = []
    for row in rows:
        would_merge = False
        if row.is_valid:
            existing = await rsvp_service.find_existing_guest(
                session, event_id, phone=row.phone, email=row.email
            )
            would_merge = existing is not None
            duplicates += 1 if would_merge else 0
        reports.append(
            ImportRowReport(
                line_number=row.line_number,
                full_name=row.full_name,
                errors=row.errors,
                would_merge=would_merge,
            )
        )

    return ImportPreview(
        total_rows=len(rows),
        valid_rows=sum(1 for r in rows if r.is_valid),
        invalid_rows=sum(1 for r in rows if not r.is_valid),
        duplicates=duplicates,
        preview=reports[:10],
        errors=[r for r in reports if r.errors],
    )


@router.post("/admin/events/{event_id}/guests/import", response_model=ImportResult)
async def commit_import(
    event_id: uuid.UUID,
    request: Request,
    file: UploadFile = File(...),
    admin: CurrentAdmin = Depends(require(Action.IMPORT_GUESTS)),
    session: AsyncSession = Depends(get_session),
) -> ImportResult:
    """Import a sheet into one event's guest list.

    The sheet's `events` column is ignored: the destination comes from the route, so a file
    cannot quietly place guests on a list the admin was not looking at (design D11).
    """
    event = await scope.scoped_event(session, admin, event_id)

    rows = csv_io.parse_import_csv(await file.read())

    imported = merged = skipped = 0
    errors: list[ImportRowReport] = []

    for row in rows:
        if not row.is_valid:
            skipped += 1
            errors.append(
                ImportRowReport(
                    line_number=row.line_number, full_name=row.full_name, errors=row.errors
                )
            )
            continue

        guest = await rsvp_service.find_existing_guest(
            session, event_id, phone=row.phone, email=row.email
        )
        if guest is None:
            guest = Guest(
                wedding_id=event.wedding_id,
                event_id=event_id,
                full_name=row.full_name,
                email=row.email,
                phone_e164=row.phone,
                whatsapp_phone_e164=row.whatsapp,
                side=GuestSide(row.side),
                group_tag=row.group_tags,
                preferred_locale=row.locale,
                preferred_channel=row.preferred_channel,
                invitation_type=row.invitation_type,
                source=GuestSource.CSV_IMPORT,
            )
            session.add(guest)
            await session.flush()
            imported += 1
        else:
            # Re-importing the same sheet updates rather than duplicating (FR-4.8).
            guest.full_name = row.full_name or guest.full_name
            if row.email:
                guest.email = row.email
            if row.phone:
                guest.phone_e164 = row.phone
            merged += 1

        exists = await session.scalar(
            select(Invitation.id)
            .where(Invitation.event_id == event_id, Invitation.guest_id == guest.id)
            .limit(1)
        )
        if not exists:
            session.add(
                Invitation(
                    event_id=event_id,
                    guest_id=guest.id,
                    token=await allocate_token(session),
                    short_code=await allocate_short_code(session),
                    max_guests=row.max_guests,
                )
            )

    audit.record(
        session,
        action=audit.Actions.GUEST_IMPORT,
        admin_user_id=admin.id,
        actor_email=admin.email,
        after={"imported": imported, "merged": merged, "skipped": skipped},
        ip=_client_ip(request),
    )
    return ImportResult(imported=imported, merged=merged, skipped=skipped, errors=errors)


# ---------------------------------------------------------------- CSV export


def _export_rows(records: Sequence[Any]) -> Iterator[list[Any]]:
    for guest, invitation, rsvp, event in records:
        yield [
            str(guest.id),
            guest.full_name,
            guest.email or "",
            guest.phone_e164 or "",
            guest.whatsapp_phone_e164 or "",
            str(guest.side),
            ",".join(guest.group_tag or []),
            event.title_en if event else "",
            str(invitation.status) if invitation else "",
            rsvp.party_size if rsvp else "",
            rsvp.adults if rsvp else "",
            rsvp.children if rsvp else "",
            rsvp.dietary_notes if rsvp else "",
            rsvp.message_to_couple if rsvp else "",
            str(invitation.invited_via) if invitation else "",
            invitation.invited_at.isoformat() if invitation and invitation.invited_at else "",
            invitation.opened_at.isoformat() if invitation and invitation.opened_at else "",
            invitation.responded_at.isoformat() if invitation and invitation.responded_at else "",
            invitation.cancelled_at.isoformat() if invitation and invitation.cancelled_at else "",
            "",  # last_reminder_sent_at — populated once Phase 4 records sends
            "",  # notes
        ]


@router.get("/admin/export")
async def export_csv(
    request: Request,
    # Filtered by event id, not event type. Several events may share a type now (design
    # D12), so a type-filtered export would silently merge two guest lists into one file.
    event_id: uuid.UUID | None = None,
    invitation_status: InvitationStatus | None = None,
    preset: str | None = Query(default=None, pattern="^(full|caterer)$"),
    admin: CurrentAdmin = Depends(require(Action.EXPORT_CSV)),
    session: AsyncSession = Depends(get_session),
) -> StreamingResponse:
    """Filtered, streamed, BOM-prefixed export. Always audited (FR-5.7)."""
    stmt = (
        select(Guest, Invitation, Rsvp, Event)
        .join(Invitation, Invitation.guest_id == Guest.id)
        .join(Event, Event.id == Invitation.event_id)
        .outerjoin(Rsvp, Rsvp.invitation_id == Invitation.id)
        # An unfiltered export is the widest read in the admin surface, so the scope goes in
        # before the optional filters rather than relying on one being supplied.
        .where(Guest.is_deleted.is_(False), scope.visible_events(admin))
        .order_by(Guest.full_name)
    )
    if event_id:
        stmt = stmt.where(Event.id == event_id)
    if invitation_status:
        stmt = stmt.where(Invitation.status == invitation_status)
    if preset == "caterer":
        # The caterer needs who is coming and what they eat — nothing else.
        stmt = stmt.where(Invitation.status == InvitationStatus.ACCEPTED)

    records = (await session.execute(stmt)).all()

    if preset == "caterer":
        columns = csv_io.CATERER_COLUMNS
        rows: Iterator[list[Any]] = (
            [g.full_name, r.party_size if r else 1, (r.dietary_notes if r else "") or ""]
            for g, _i, r, _e in records
        )
    else:
        columns = csv_io.EXPORT_COLUMNS
        rows = _export_rows(records)

    audit.record(
        session,
        action=audit.Actions.EXPORT_CSV,
        admin_user_id=admin.id,
        actor_email=admin.email,
        after={
            "event_id": str(event_id) if event_id else "all",
            "status": str(invitation_status) if invitation_status else "all",
            "preset": preset or "full",
            "row_count": len(records),
        },
        ip=_client_ip(request),
    )

    # The filename carries the event's slug rather than its id: a file called
    # `guests_9e8a7137-....csv` sitting in someone's downloads folder tells them nothing.
    event_slug = records[0][3].slug if event_id and records else None
    filename = csv_io.export_filename(
        event_slug, str(invitation_status) if invitation_status else None
    )
    return StreamingResponse(
        csv_io.stream_csv(columns, rows),
        media_type="text/csv; charset=utf-8",
        headers={
            "Content-Disposition": f'attachment; filename="{filename}"',
            "Cache-Control": "private, no-store",
        },
    )


# ---------------------------------------------------------------- QR codes


@router.get("/admin/qr/{event_slug}")
async def event_qr(
    event_slug: str,
    format: str = Query(default="svg", pattern="^(svg|png)$"),
    admin: CurrentAdmin = Depends(require(Action.GENERATE_QR)),
    session: AsyncSession = Depends(get_session),
) -> Response:
    """Per-event open-link QR at error-correction H (spec qr-codes FR-7.1, FR-7.2).

    Level H tolerates roughly 30% damage, which is what lets a decorative print — or a
    card that has been in someone's pocket — still scan.
    """
    # By slug rather than id, but the same rule: a slug is guessable from a printed card,
    # and this endpoint would otherwise confirm which events exist.
    event = await session.scalar(
        select(Event).where(Event.slug == event_slug, scope.visible_events(admin))
    )
    if event is None:
        raise scope.not_found()

    url = f"{get_settings().app_base_url.rstrip('/')}/e/{event.slug}"
    qr = segno.make(url, error="h")

    import io as _io

    buf = _io.BytesIO()
    if format == "svg":
        qr.save(buf, kind="svg", scale=10, border=4)
        media = "image/svg+xml"
    else:
        qr.save(buf, kind="png", scale=32, border=4)  # ~1024px
        media = "image/png"

    return Response(
        content=buf.getvalue(),
        media_type=media,
        headers={
            "Content-Disposition": f'attachment; filename="qr-{event.slug}.{format}"',
            # Minimum print size 2.5cm x 2.5cm — surfaced to the UI rather than buried in docs.
            "X-Min-Print-Size": "25mm",
        },
    )


@router.get("/admin/qr/guest/{invitation_id}")
async def guest_qr(
    invitation_id: uuid.UUID,
    format: str = Query(default="svg", pattern="^(svg|png)$"),
    admin: CurrentAdmin = Depends(require(Action.GENERATE_QR)),
    session: AsyncSession = Depends(get_session),
) -> Response:
    """Per-guest QR encoding /i/{token} for personalised printed cards (FR-7.3).

    SVG is the print format. PNG exists because the common case is not printing at all —
    it is an admin sending one guest their code over WhatsApp, and WhatsApp will not
    render an SVG.
    """
    invitation = await session.scalar(
        select(Invitation)
        .where(Invitation.id == invitation_id, scope.invitation_in_scope(admin))
        .options(selectinload(Invitation.guest))
    )
    if invitation is None:
        # A QR encodes the guest's bearer token; handing one out is handing out the
        # invitation itself.
        raise scope.not_found()

    url = f"{get_settings().app_base_url.rstrip('/')}/i/{invitation.token}"
    import io as _io

    buf = _io.BytesIO()
    qr = segno.make(url, error="h")
    if format == "svg":
        qr.save(buf, kind="svg", scale=10, border=4)
        media = "image/svg+xml"
    else:
        qr.save(buf, kind="png", scale=32, border=4)  # ~1024px
        media = "image/png"

    return Response(
        content=buf.getvalue(),
        media_type=media,
        headers={
            "Content-Disposition": (
                f'attachment; filename="qr-guest-{invitation.short_code}.{format}"'
            ),
            # Personalised: this encodes a bearer token, so it must not be cached anywhere.
            "Cache-Control": "private, no-store",
        },
    )
