from datetime import date, datetime, timezone
from decimal import Decimal

from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session

from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.cases import service as cases_service
from api.v1.modules.crm.common.folios import next_folio
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.ops.shipments.models import Shipment

from ..concepts.models import Concept
from .dto import (
    InvoiceClientReviewInput,
    InvoiceCreate,
    InvoiceItemCreate,
    InvoiceItemUpdate,
    InvoiceUpdate,
    PaymentCreate,
)
from . import taxes_service
from .models import Invoice, InvoiceItem, Payment
from .pdf import build_invoice_pdf


def _exists(db: Session, model, _id, tenant_id, company_id) -> bool:
    if _id is None:
        return True
    return (
        db.query(model.id)
        .filter(model.id == _id, model.tenant_id == tenant_id, model.company_id == company_id, model.deleted_at.is_(None))
        .first()
        is not None
    )


def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
    for field, model, msg in [
        ("account_id", Account, "El cliente asociado no existe"),
        ("shipment_id", Shipment, "El embarque asociado no existe"),
        ("quote_id", Quote, "La cotización asociada no existe"),
    ]:
        if field in data and not _exists(db, model, data.get(field), tenant_id, company_id):
            raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)


def _recompute(db: Session, invoice: Invoice) -> None:
    subtotal = db.query(func.coalesce(func.sum(InvoiceItem.quantity * InvoiceItem.unit_amount), 0)).filter(
        InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None)
    ).scalar()
    paid = db.query(func.coalesce(func.sum(Payment.amount), 0)).filter(
        Payment.invoice_id == invoice.id, Payment.deleted_at.is_(None)
    ).scalar()
    subtotal = Decimal(subtotal or 0)
    rate = Decimal(invoice.tax_rate or 0)
    tax = (subtotal * rate / Decimal(100)).quantize(Decimal("0.01"))
    total = subtotal + tax
    paid = Decimal(paid or 0)
    invoice.subtotal = subtotal
    invoice.tax_amount = tax
    invoice.total = total
    invoice.paid_amount = paid
    invoice.balance = total - paid
    # Estado de cobranza (no toca borrador ni cancelada)
    if invoice.status in ("emitida", "enviada", "en_revision_cliente", "pagada"):
        if total > 0 and invoice.balance <= 0:
            invoice.status = "pagada"
            invoice.paid_at = datetime.now(timezone.utc)
        elif invoice.status == "pagada" and invoice.balance > 0:
            invoice.status = "enviada"
            invoice.paid_at = None


# ----- Invoices -----

def get_invoices(db, tenant_id, company_id, search=None, inv_status=None, account_id=None) -> list[Invoice]:
    q = db.query(Invoice).filter(Invoice.tenant_id == tenant_id, Invoice.company_id == company_id, Invoice.deleted_at.is_(None))
    if inv_status:
        q = q.filter(Invoice.status == inv_status)
    if account_id is not None:
        q = q.filter(Invoice.account_id == account_id)
    if search:
        q = q.filter(Invoice.reference.ilike(f"%{search}%"))
    return q.order_by(Invoice.created_at.desc()).all()


def get_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
    obj = db.query(Invoice).filter(
        Invoice.id == invoice_id, Invoice.tenant_id == tenant_id, Invoice.company_id == company_id, Invoice.deleted_at.is_(None)
    ).first()
    if not obj:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Factura no encontrada")
    return obj


def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=None) -> Invoice:
    data = payload.model_dump()
    _validate_refs(db, data, tenant_id, company_id)
    obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
    # Folio F... auto-generado (mensual) si no viene uno explícito
    if not obj.reference:
        obj.reference = next_folio(db, tenant_id, company_id, "F", None, with_direction=False)
    # Expediente heredado del embarque (si la factura se genera de uno)
    if obj.shipment_id and not obj.case_id:
        sh = db.query(Shipment).filter(Shipment.id == obj.shipment_id).first()
        if sh:
            obj.case_id = sh.case_id
    cases_service.advance_stage(db, obj.case_id, "facturacion")
    db.add(obj)
    db.flush()
    _recompute(db, obj)
    db.commit()
    db.refresh(obj)
    return obj


def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id, user_id=None) -> Invoice:
    obj = get_invoice(db, invoice_id, tenant_id, company_id)
    data = payload.model_dump(exclude_unset=True)
    _validate_refs(db, data, tenant_id, company_id)
    _reject_stamping_mode_change(db, obj, data, tenant_id, company_id)
    for f, v in data.items():
        setattr(obj, f, v)
    obj.updated_by = user_id
    db.flush()
    if "tax_rate" in data:
        # El % global es la fuente del IVA derivado de cada partida: si cambia, se propaga.
        taxes_service.sync_invoice_taxes(db, obj)
    _recompute(db, obj)  # tax_rate pudo cambiar
    db.commit()
    db.refresh(obj)
    return obj


def _reject_stamping_mode_change(db, obj: Invoice, data: dict, tenant_id, company_id) -> None:
    """El modo de timbrado es inmutable una vez que la factura tiene timbre.

    Cambiarlo después falsearía el registro de con qué intención se emitió el comprobante: el
    CFDI ya existe ante el SAT con la validez que le dio el entorno donde se timbró, y ese
    hecho no se edita.
    """
    nuevo = data.get("stamping_mode")
    if nuevo is None or nuevo == obj.stamping_mode:
        return
    # Import diferido: stamping importa invoices, y al revés sería circular.
    from ..stamping.service import get_stamp  # noqa: PLC0415

    if get_stamp(db, obj.id, tenant_id, company_id):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=(
                "La factura ya está timbrada: el modo de timbrado no se puede cambiar "
                f"(sigue en {obj.stamping_mode!r})."
            ),
        )


def delete_invoice(db, invoice_id, tenant_id, company_id) -> None:
    obj = get_invoice(db, invoice_id, tenant_id, company_id)
    obj.deleted_at = datetime.now(timezone.utc)
    db.commit()


def _set_status(db, invoice_id, tenant_id, company_id, new_status, set_issue=False) -> Invoice:
    obj = get_invoice(db, invoice_id, tenant_id, company_id)
    obj.status = new_status
    if set_issue and not obj.issue_date:
        obj.issue_date = date.today()
    if new_status == "enviada":
        obj.sent_at = datetime.now(timezone.utc)
    db.commit()
    db.refresh(obj)
    return obj


def emit_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
    return _set_status(db, invoice_id, tenant_id, company_id, "emitida", set_issue=True)


def _build_pdf_bytes(db, invoice: Invoice, tenant_id, company_id) -> bytes:
    """Arma los bytes del PDF de la factura a partir de sus datos y conceptos."""
    items = get_items(db, invoice.id, tenant_id, company_id)
    account_name = None
    if invoice.account_id:
        acc = db.query(Account).filter(Account.id == invoice.account_id).first()
        account_name = acc.name if acc else None
    return build_invoice_pdf(
        folio=invoice.reference or f"FAC-{invoice.id}",
        issue_date=str(invoice.issue_date or ""),
        due_date=str(invoice.due_date or ""),
        account_name=account_name or "Cliente",
        currency=invoice.currency or "MXN",
        items=[
            {"concept": it.concept, "description": it.description, "quantity": it.quantity, "unit_amount": it.unit_amount}
            for it in items
        ],
        subtotal=invoice.subtotal,
        tax_rate=invoice.tax_rate,
        tax_amount=invoice.tax_amount,
        total=invoice.total,
        paid=invoice.paid_amount,
        balance=invoice.balance,
        bank_info=invoice.bank_info,
        notes=invoice.notes,
    )


def send_invoice(db, invoice_id, tenant_id, company_id, user_id=None) -> Invoice:
    """Envía la factura al cliente: genera el PDF, lo guarda en MinIO y marca 'enviada' (R-F-05)."""
    from core.storage_s3 import put_object_bytes  # import diferido: evita conectar en tests

    obj = get_invoice(db, invoice_id, tenant_id, company_id)
    if obj.status in ("borrador", "cancelada"):
        # La factura debe estar emitida antes de enviarse al cliente
        if obj.status == "cancelada":
            raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="La factura está cancelada")
        obj.status = "emitida"
        if not obj.issue_date:
            obj.issue_date = date.today()
        db.flush()
    pdf_bytes = _build_pdf_bytes(db, obj, tenant_id, company_id)
    key = f"tenants/{tenant_id}/companies/{company_id}/fin-invoices/{obj.id}/factura-{obj.reference or obj.id}.pdf"
    put_object_bytes(key, pdf_bytes, content_type="application/pdf")
    obj.pdf_file_key = key
    obj.status = "enviada"
    obj.sent_at = datetime.now(timezone.utc)
    if not obj.issue_date:
        obj.issue_date = date.today()
    obj.updated_by = user_id
    db.commit()
    db.refresh(obj)
    return obj


def get_invoice_pdf_url(db, invoice_id, tenant_id, company_id) -> str:
    """Devuelve una URL firmada fresca del PDF de la factura (las presignadas expiran)."""
    from core.storage_s3 import presigned_get_url

    obj = get_invoice(db, invoice_id, tenant_id, company_id)
    if not obj.pdf_file_key:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="La factura aún no tiene PDF; envíala al cliente para generarlo",
        )
    return presigned_get_url(obj.pdf_file_key)


def mark_client_review(db, invoice_id, tenant_id, company_id, user_id=None) -> Invoice:
    """Pone la factura en revisión del cliente (R-F-06)."""
    obj = get_invoice(db, invoice_id, tenant_id, company_id)
    if obj.status not in ("enviada", "en_revision_cliente"):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="Solo una factura enviada puede pasar a revisión del cliente",
        )
    obj.status = "en_revision_cliente"
    obj.updated_by = user_id
    db.commit()
    db.refresh(obj)
    return obj


def client_review_decision(
    db, invoice_id, payload: InvoiceClientReviewInput, tenant_id, company_id, user_id=None
) -> Invoice:
    """Registra la decisión de revisión del cliente: aprobada o con observaciones (R-F-06)."""
    obj = get_invoice(db, invoice_id, tenant_id, company_id)
    if obj.status not in ("enviada", "en_revision_cliente"):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="La factura debe estar enviada o en revisión para registrar la decisión del cliente",
        )
    obj.client_reviewed_at = datetime.now(timezone.utc)
    obj.client_approved = payload.approved
    obj.review_notes = payload.notes
    # Aprobada → lista para cobranza (enviada). Con observaciones → regresa a emitida para corregir.
    obj.status = "enviada" if payload.approved else "emitida"
    obj.updated_by = user_id
    db.commit()
    db.refresh(obj)
    return obj


def cancel_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
    return _set_status(db, invoice_id, tenant_id, company_id, "cancelada")


def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None) -> Invoice:
    """Genera la factura de un embarque, tomando los conceptos (venta) de su cotización.

    El disparador válido de la facturación es el cierre operativo del embarque
    (R-F-01): solo se factura un embarque en estado 'cerrada'. Los costos reales de
    la operación se arrastran a la factura (R-F-02).
    """
    shipment = db.query(Shipment).filter(
        Shipment.id == shipment_id, Shipment.tenant_id == tenant_id, Shipment.company_id == company_id, Shipment.deleted_at.is_(None)
    ).first()
    if not shipment:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embarque no encontrado")
    if shipment.status != "cerrada":
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="El embarque debe estar cerrado (cierre operativo) para facturarse",
        )
    existing = db.query(Invoice).filter(
        Invoice.shipment_id == shipment_id, Invoice.tenant_id == tenant_id,
        Invoice.company_id == company_id, Invoice.deleted_at.is_(None),
        Invoice.status != "cancelada",
    ).first()
    if existing:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="El embarque ya tiene una factura vigente",
        )

    quote = None
    if shipment.quote_id:
        quote = db.query(Quote).filter(Quote.id == shipment.quote_id).first()

    invoice = Invoice(
        reference=shipment.reference,
        case_id=shipment.case_id,
        shipment_id=shipment.id,
        quote_id=shipment.quote_id,
        account_id=shipment.account_id,
        currency=(shipment.cost_currency or (quote.currency if quote else "MXN")),
        ops_cost_total=shipment.actual_cost_total,
        status="borrador",
        tenant_id=tenant_id,
        company_id=company_id,
        created_by=user_id,
        updated_by=user_id,
    )
    db.add(invoice)
    db.flush()
    cases_service.advance_stage(db, shipment.case_id, "facturacion")

    if quote:
        q_items = db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)).all()
        for qi in q_items:
            db.add(InvoiceItem(
                invoice_id=invoice.id, concept=qi.concept, description=qi.description,
                quantity=qi.quantity, unit_amount=qi.unit_sale,
                tenant_id=tenant_id, company_id=company_id,
            ))
        db.flush()

    _recompute(db, invoice)
    db.commit()
    db.refresh(invoice)
    return invoice


# ----- Items -----

def get_items(db, invoice_id, tenant_id, company_id) -> list[InvoiceItem]:
    get_invoice(db, invoice_id, tenant_id, company_id)
    return db.query(InvoiceItem).filter(
        InvoiceItem.invoice_id == invoice_id, InvoiceItem.tenant_id == tenant_id,
        InvoiceItem.company_id == company_id, InvoiceItem.deleted_at.is_(None)
    ).order_by(InvoiceItem.id.asc()).all()


def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem:
    obj = db.query(InvoiceItem).filter(
        InvoiceItem.id == item_id, InvoiceItem.tenant_id == tenant_id,
        InvoiceItem.company_id == company_id, InvoiceItem.deleted_at.is_(None)
    ).first()
    if not obj:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
    return obj


# Claves del SAT que la partida hereda del concepto del catálogo cuando no se envían.
_CONCEPT_INHERITED_FIELDS = ("product_service_id", "unit_of_measure_id", "tax_object_id")


def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None:
    """Completa la partida a partir del concepto del catálogo.

    Hereda dos cosas cuando el cliente no las manda:

    - ``concept``: el PDF de la factura sigue leyendo esa columna de texto libre, así
      que ahí va la descripción del concepto (recortada al largo de la columna).
    - Las claves fiscales (``product_service_id``, ``unit_of_measure_id``,
      ``tax_object_id``): sin ellas la partida capturada por catálogo quedaría
      incompleta para el CFDI. Lo que el cliente sí envía manda sobre el catálogo,
      para poder facturar una partida con una unidad distinta a la del concepto.
    """
    concept_id = data.get("concept_id")
    if concept_id is not None:
        catalog_concept = db.query(Concept).filter(
            Concept.id == concept_id, Concept.tenant_id == tenant_id,
            Concept.company_id == company_id, Concept.deleted_at.is_(None),
        ).first()
        if not catalog_concept:
            raise HTTPException(
                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
                detail="El concepto del catálogo no existe en esta empresa",
            )
        if not data.get("concept"):
            data["concept"] = catalog_concept.description[:60]
        for field in _CONCEPT_INHERITED_FIELDS:
            if data.get(field) is None:
                data[field] = getattr(catalog_concept, field)
    if not data.get("concept"):
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail="La partida requiere un concepto o una referencia al catálogo de conceptos",
        )


def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem:
    invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
    data = payload.model_dump()
    _resolve_item_concept(db, data, tenant_id, company_id)
    item = InvoiceItem(**data, tenant_id=tenant_id, company_id=company_id)
    db.add(item)
    db.flush()
    taxes_service.sync_item_taxes(db, item, invoice)
    _recompute(db, invoice)
    db.commit()
    db.refresh(item)
    return item


def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id) -> InvoiceItem:
    item = _get_item(db, item_id, tenant_id, company_id)
    data = payload.model_dump(exclude_unset=True)
    # Cambiar el concepto del catálogo revalida la referencia y vuelve a heredar
    # descripción y claves fiscales del concepto nuevo.
    if data.get("concept_id") is not None:
        _resolve_item_concept(db, data, tenant_id, company_id)
    for f, v in data.items():
        setattr(item, f, v)
    db.flush()
    invoice = get_invoice(db, item.invoice_id, tenant_id, company_id)
    taxes_service.sync_item_taxes(db, item, invoice)
    _recompute(db, invoice)
    db.commit()
    db.refresh(item)
    return item


def delete_item(db, item_id, tenant_id, company_id) -> None:
    item = _get_item(db, item_id, tenant_id, company_id)
    invoice_id = item.invoice_id
    item.deleted_at = datetime.now(timezone.utc)
    db.flush()
    _recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))
    db.commit()


# ----- Payments -----

def get_payments(db, invoice_id, tenant_id, company_id) -> list[Payment]:
    get_invoice(db, invoice_id, tenant_id, company_id)
    return db.query(Payment).filter(
        Payment.invoice_id == invoice_id, Payment.tenant_id == tenant_id,
        Payment.company_id == company_id, Payment.deleted_at.is_(None)
    ).order_by(Payment.id.asc()).all()


def create_payment(db, payload: PaymentCreate, tenant_id, company_id) -> Payment:
    invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
    pay = Payment(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
    db.add(pay)
    db.flush()
    _recompute(db, invoice)
    db.commit()
    db.refresh(pay)
    return pay


def delete_payment(db, payment_id, tenant_id, company_id) -> None:
    pay = db.query(Payment).filter(
        Payment.id == payment_id, Payment.tenant_id == tenant_id,
        Payment.company_id == company_id, Payment.deleted_at.is_(None)
    ).first()
    if not pay:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pago no encontrado")
    invoice_id = pay.invoice_id
    pay.deleted_at = datetime.now(timezone.utc)
    db.flush()
    _recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))
    db.commit()
