"""Keeps a plot's status and its linked record's interment type in lockstep.

Product rule: the plot status shown on the Cemetery Map ("Initial status" on
the Edit Plot page) and the "Interment type" on the record linked to that
plot are two views of the same lifecycle stage and must never drift apart:

    plot status   <-->  record interment_type
    hold          <-->  pre_need_no_service_yet   ("On hold")
    reserved      <-->  burial                     ("Reserved")
    occupied      <-->  cremation_interred          ("Interred")

The mapping mirrors indelis-admin's `src/utils/intermentType.ts`
(`INTERMENT_TYPE_PLOT_STATUS`), which already drives the Add/Edit Record
plot search filter — this module is what actually enforces it server-side.
Whichever side changes first — burial info saved on the record, or the
plot's status edited from the map — drives the other. Reaching reserved or
occupied additionally attaches the plot's signed contract (if any) to the
record's Documents tab, since that's the point staff need the paperwork
visible on the record.
"""
from decimal import Decimal
from typing import Optional
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from src.apps.documents.models.document import Document
from src.apps.plots.models.plot import Plot
from src.apps.records.models.burial_info import BurialInfo
from src.apps.records.models.record import Record
from src.apps.sales.models.contract import Contract
from src.core.constants import PlotStatus
from src.core.exceptions import ConflictError

PLOT_STATUS_TO_INTERMENT_TYPE = {
    PlotStatus.HOLD.value: "pre_need_no_service_yet",
    PlotStatus.RESERVED.value: "burial",
    PlotStatus.OCCUPIED.value: "cremation_interred",
}
INTERMENT_TYPE_TO_PLOT_STATUS = {v: k for k, v in PLOT_STATUS_TO_INTERMENT_TYPE.items()}

# Statuses at which a signed contract for the plot should surface on the
# record's Documents tab (mirrors the "already claimed" stages — never at
# hold/pre-need, since nothing may be signed yet).
_DOCUMENT_TRIGGER_STATUSES = {PlotStatus.RESERVED.value, PlotStatus.OCCUPIED.value}


async def has_active_signed_contract(db: AsyncSession, tenant_id: UUID, plot_id: UUID) -> bool:
    """Whether a signed, non-deleted contract still claims this plot —
    independent of any record. Used so unlinking a record never vacates a
    plot the sales pipeline still legitimately has reserved/occupied.

    A plot can have more than one signed contract over its lifetime (e.g. a
    companion/second interment) — ``.limit(1)`` since this only needs to know
    whether at least one exists, not how many.
    """
    result = await db.execute(
        select(Contract.id)
        .where(
            Contract.tenant_id == tenant_id,
            Contract.plot_id == plot_id,
            Contract.deleted_at.is_(None),
            Contract.status == "signed",
        )
        .limit(1)
    )
    return result.scalar_one_or_none() is not None


async def _active_record_for_plot(db: AsyncSession, tenant_id: UUID, plot_id: UUID) -> Optional[Record]:
    result = await db.execute(
        select(Record).where(
            Record.tenant_id == tenant_id,
            Record.plot_id == plot_id,
            Record.deleted_at.is_(None),
        )
    )
    return result.scalar_one_or_none()


async def sync_interment_type_from_plot_status(
    db: AsyncSession,
    tenant_id: UUID,
    plot_id: UUID,
    plot_status: str,
    current_user=None,
) -> None:
    """Plot status changed (Edit Plot page, /status endpoint) -> mirror it onto
    the linked record's burial_info.interment_type, if a record is linked."""
    interment_type = PLOT_STATUS_TO_INTERMENT_TYPE.get(plot_status)
    if interment_type is None:
        return
    record = await _active_record_for_plot(db, tenant_id, plot_id)
    if record is None:
        return

    result = await db.execute(select(BurialInfo).where(BurialInfo.record_id == record.id))
    burial = result.scalar_one_or_none()
    if burial is not None:
        if burial.interment_type == interment_type:
            return
        burial.interment_type = interment_type
    else:
        burial = BurialInfo(record_id=record.id, tenant_id=tenant_id, interment_type=interment_type)
        db.add(burial)
    await db.flush()

    if plot_status in _DOCUMENT_TRIGGER_STATUSES:
        await _attach_contract_document(db, tenant_id, plot_id, record.id, current_user)


async def guard_or_delete_record_on_vacate(
    db: AsyncSession,
    tenant_id: UUID,
    plot_id: UUID,
    confirmed: bool,
    current_user=None,
    request=None,
) -> None:
    """Setting a plot back to vacant ("Available") while it still has an
    active linked record would silently orphan that record's plot reference —
    the record would keep showing a plot the map no longer reflects as
    claimed. Instead, require the caller to explicitly confirm, then
    cascade-delete the record via the same soft-delete path the Records page
    uses. Raises ConflictError (409, PLOT_VACATE_DELETES_RECORD) when a
    record is linked and confirmation wasn't given; no-ops when there's
    nothing linked to lose.
    """
    record = await _active_record_for_plot(db, tenant_id, plot_id)
    if record is None:
        return

    if not confirmed:
        name = f"{record.first_name} {record.last_name}".strip()
        raise ConflictError(
            f"This plot has an active record ({name}) linked to it. Setting "
            "the status to Available will permanently delete that record. "
            "Confirm to proceed.",
            error_code="PLOT_VACATE_DELETES_RECORD",
        )

    # Local import: record_service.py imports this module, so importing it back
    # at module load time would create a circular import.
    from src.apps.records.services.record_service import RecordService

    await RecordService(db).soft_delete(
        record.id, tenant_id, current_user=current_user, request=request,
    )


# Invoice statuses that still represent money the buyer owes — cancelling an
# already-`paid` invoice would be wrong (they paid it; the plot became
# reserved/occupied because of that payment), so this deliberately excludes it.
_CANCELLABLE_INVOICE_STATUSES = {"outstanding", "partial", "overdue"}


async def cancel_unpaid_invoices_for_plot(
    db: AsyncSession,
    tenant_id: UUID,
    plot_id: UUID,
    current_user=None,
    request=None,
) -> None:
    """The plot is leaving hold/reserved back to vacant — the sale fell
    through (Mark as Lost) or staff manually reverted it — so any invoice
    still owed on a signed contract for this plot is no longer collectable
    and is cancelled (e.g. a deposit-50 contract's second, unpaid instalment).
    Independent of whether a record is linked — see
    ``guard_or_delete_record_on_vacate`` for that, unrelated concern.
    Never touches an invoice that's already been paid.
    """
    # Local imports: avoids a plots->billing (and ->sales, already used above
    # for Contract) import cycle at module load time — same reasoning as
    # RecordService above.
    from src.apps.billing.models.invoice import Invoice
    from src.apps.site_admin.services.audit_service import AuditService

    result = await db.execute(
        select(Invoice)
        .join(Contract, Contract.id == Invoice.contract_id)
        .where(
            Invoice.tenant_id == tenant_id,
            Contract.plot_id == plot_id,
            Contract.status == "signed",
            Contract.deleted_at.is_(None),
            Invoice.status.in_(_CANCELLABLE_INVOICE_STATUSES),
        )
    )
    invoices = result.scalars().all()
    if not invoices:
        return

    for invoice in invoices:
        old_status = invoice.status
        invoice.status = "canceled"
        # Nothing more will be collected on it — zero the remaining balance so
        # reports/exports summing balance_due don't keep counting it as owed.
        invoice.balance_due = Decimal("0")
        if current_user and request:
            await AuditService.log(
                db, "Invoice", invoice.id, "cancel", current_user, request,
                old_value={"status": old_status},
                new_value={"status": "canceled"},
            )
    await db.flush()


async def sync_plot_status_from_interment_type(
    db: AsyncSession,
    tenant_id: UUID,
    plot_id: UUID,
    interment_type: Optional[str],
    current_user=None,
) -> None:
    """Record's burial_info.interment_type changed -> mirror it onto the
    linked plot's status, if the plot isn't already in that stage."""
    if not interment_type:
        return
    new_status = INTERMENT_TYPE_TO_PLOT_STATUS.get(interment_type)
    if new_status is None:
        return

    result = await db.execute(select(Plot).where(Plot.id == plot_id, Plot.tenant_id == tenant_id))
    plot = result.scalar_one_or_none()
    if plot is None or plot.status == new_status:
        return
    plot.status = new_status
    await db.flush()

    if new_status in _DOCUMENT_TRIGGER_STATUSES:
        record = await _active_record_for_plot(db, tenant_id, plot_id)
        if record is not None:
            await _attach_contract_document(db, tenant_id, plot_id, record.id, current_user)


async def _attach_contract_document(
    db: AsyncSession, tenant_id: UUID, plot_id: UUID, record_id: UUID, current_user,
) -> None:
    """Attach the plot's most recent signed contract to the record's
    Documents tab, if one exists and isn't already attached.

    References the contract's existing S3 object rather than re-uploading —
    safe because contracts and the document vault share the same bucket/region
    by default (``S3_BUCKET``/``S3_DOCUMENTS_BUCKET`` in core/config.py both
    default to the same bucket; a tenant that truly separates them would need
    a real copy here instead).
    """
    result = await db.execute(
        select(Contract)
        .where(
            Contract.tenant_id == tenant_id,
            Contract.plot_id == plot_id,
            Contract.deleted_at.is_(None),
            Contract.status == "signed",
        )
        .order_by(Contract.signed_at.desc().nullslast(), Contract.created_at.desc())
        .limit(1)
    )
    contract = result.scalar_one_or_none()
    if contract is None:
        return

    s3_key = contract.signed_document_s3_key or contract.pdf_s3_key
    if not s3_key:
        return

    existing = await db.execute(
        select(Document.id).where(
            Document.tenant_id == tenant_id,
            Document.entity_type == "record",
            Document.entity_id == record_id,
            Document.s3_key == s3_key,
            Document.deleted_at.is_(None),
        )
    )
    if existing.scalar_one_or_none() is not None:
        return

    uploader = contract.signed_document_uploaded_by or contract.created_by
    if uploader is None and current_user is not None:
        uploader = current_user.id
    if uploader is None:
        return

    db.add(Document(
        tenant_id=tenant_id,
        entity_type="record",
        entity_id=record_id,
        s3_key=s3_key,
        filename=f"Contract {contract.contract_number}.pdf",
        mime_type="application/pdf",
        category="contract",
        version=1,
        uploaded_by=uploader,
        upload_confirmed=True,
    ))
    await db.flush()
