"""
Invoice API router — full implementation for PRD-004-HWINV phases 2-4.

IMPORTANT route-ordering rules enforced here:
  - GET  /invoices/summary        (HWINV-104)   must be BEFORE /{invoice_literal}
  - GET  /invoices/next-number                  must be BEFORE /{invoice_literal}
  - POST /invoices/bulk-actions   (HWINV-303)   must be BEFORE /{invoice_literal}
  - GET  /invoices/analytics      (HWINV-302)   must be BEFORE /{invoice_literal}
"""

from __future__ import annotations

import logging
import math
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, Depends, Path, Query, Request
from io import BytesIO
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, Depends, HTTPException, Query, Path

logger = logging.getLogger(__name__)
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session

from src.core.config import settings
from src.core.database import get_db
from src.core.exceptions import APIException, NotFoundError
from src.apps.auth.utils.auth import get_current_merchant, get_current_active_user
from src.apps.merchants.schemas.merchant_common import MerchantSchema
from src.apps.invoices.schemas.invoice_requests import (
    InvoiceListFilterSchema,
    MerchantInvoiceCreateSchema,
    MerchantInvoiceUpdateSchema,
    InvoiceBulkActionSchema,
    InvoiceBulkActions,
    InvoicePublishRequest,
    InvoiceSendRequest,
    InvoiceReminderCreateRequest,
    InvoiceSummaryResponse,
    InvoiceBulkActionRequest,
    AttachmentAddRequest,
)
from src.apps.invoices.schemas.invoice_common import InvoiceSchema, InvoiceListSchema
from src.apps.invoices.schemas.invoice_reminder import InvoiceReminderSchema
from src.apps.invoices.services import get_invoice_by_literal, list_invoices, generate_invoice_literal
from src.apps.invoices import crud as invoice_crud
from src.core.utils.enums import (
    InvoiceStatusTypes,
    InvoiceActivityTypes,
    ReminderStatus,
    ReminderPreset,
)
from src.apps.base.utils.functions import generate_secure_id
from src.apps.invoices.services import get_invoice_by_literal, list_invoices
from src.apps.invoices.helpers.pdf_generator import invoice_pdf_generator

router = APIRouter()

# ─── Helpers ─────────────────────────────────────────────────────────────────

_INVOICE_LITERAL_PATH = Path(
    ...,
    pattern=r"^INV\d{1,17}$",
    max_length=20,
    description="Invoice literal identifier, e.g. INV000001",
)

_IMMUTABLE_STATUSES = {
    InvoiceStatusTypes.PAID,
    InvoiceStatusTypes.AUTHORIZED,
    InvoiceStatusTypes.PARTIALLY_PAID,
    InvoiceStatusTypes.CAPTURED,
}


def _assert_not_draft(invoice, operation: str = "this operation") -> None:
    """Raise 400 if the invoice is a DRAFT — drafts must not trigger live flows."""
    if invoice.status == InvoiceStatusTypes.DRAFT:
        raise APIException(
            message=f"Cannot perform '{operation}' on a draft invoice. Publish it first.",
            status_code=400,
        )


def _resolve_preset_scheduled_at(preset: str, due_date: Optional[datetime]) -> Optional[datetime]:
    """Convert a ReminderPreset to an absolute UTC datetime relative to due_date."""
    if due_date is None:
        return None

    offset_map = {
        ReminderPreset.ONE_DAY_BEFORE: timedelta(days=-1),
        ReminderPreset.THREE_DAYS_BEFORE: timedelta(days=-3),
        ReminderPreset.ONE_WEEK_BEFORE: timedelta(weeks=-1),
        ReminderPreset.ON_DUE_DATE: timedelta(0),
        ReminderPreset.ONE_DAY_AFTER: timedelta(days=1),
        ReminderPreset.THREE_DAYS_AFTER: timedelta(days=3),
        ReminderPreset.ONE_WEEK_AFTER: timedelta(weeks=1),
    }
    delta = offset_map.get(preset)
    if delta is None:
        return None

    base = due_date if due_date.tzinfo else due_date.replace(tzinfo=timezone.utc)
    return base + delta


# ─── 1. GET /invoices/summary  (HWINV-104) — must be before /{literal} ───────

@router.get("/summary", response_model=Dict[str, Any])
async def get_invoice_summary_endpoint(
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """
    Return invoice counts and amounts grouped by status (excludes DRAFT from totals).

    HWINV-104: Invoice Summary Stats
    """
    stats = invoice_crud.get_invoice_summary_stats(db, merchant.id)
    return {
        "data": stats,
        "status_code": 200,
        "success": True,
        "message": "Invoice summary fetched successfully",
    }


# ─── 2. GET /invoices/next-number ────────────────────────────────────────────

@router.get("/next-number", response_model=Dict[str, Any])
async def get_next_invoice_number_endpoint(
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """Return the next sequential invoice literal without reserving it."""
    next_literal = generate_invoice_literal(db)
    return {
        "data": {"next_literal": next_literal},
        "status_code": 200,
        "success": True,
        "message": "Next invoice number generated",
    }


# ─── 3. GET /invoices/analytics  (HWINV-302) — must be before /{literal} ─────

@router.get("/analytics", response_model=Dict[str, Any])
async def get_invoices_analytics_endpoint(
    period: str = Query("30d", pattern=r"^(30d|90d|12m)$"),
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """
    Time-series analytics for all invoices of a merchant.

    HWINV-302: period = 30d | 90d | 12m
    """
    from sqlalchemy import select, func, cast, Date, literal
    from src.apps.invoices.models.invoice import Invoice

    now = datetime.now(timezone.utc)
    if period == "30d":
        since = now - timedelta(days=30)
        trunc = "day"
    elif period == "90d":
        since = now - timedelta(days=90)
        trunc = "day"
    else:  # 12m
        since = now - timedelta(days=365)
        trunc = "month"

    # Use literal() so SQLAlchemy renders 'day'/'month' as SQL literal text,
    # not a bind parameter — PostgreSQL requires GROUP BY to match the SELECT expr exactly.
    trunc_expr = func.date_trunc(literal(trunc), Invoice.created_at)

    stmt = select(
        trunc_expr.label("period"),
        func.count(Invoice.id).label("count"),
        func.coalesce(func.sum(Invoice.amount), 0).label("amount"),
    ).where(
        Invoice.merchant_id == merchant.id,
        Invoice.deleted_at == None,
        Invoice.created_at >= since,
    ).group_by(trunc_expr).order_by(trunc_expr)

    rows = db.execute(stmt).all()
    series = [
        {
            "period": row.period.isoformat() if hasattr(row.period, "isoformat") else str(row.period),
            "count": row.count,
            "amount": float(row.amount),
        }
        for row in rows
    ]

    # Status breakdown
    status_stmt = select(
        Invoice.status,
        func.count(Invoice.id).label("count"),
        func.coalesce(func.sum(Invoice.amount), 0).label("amount"),
    ).where(
        Invoice.merchant_id == merchant.id,
        Invoice.deleted_at == None,
        Invoice.created_at >= since,
    ).group_by(Invoice.status)

    status_rows = db.execute(status_stmt).all()
    status_breakdown = [
        {"status": row.status, "count": row.count, "amount": float(row.amount)}
        for row in status_rows
    ]

    return {
        "data": {
            "period": period,
            "series": series,
            "status_breakdown": status_breakdown,
        },
        "status_code": 200,
        "success": True,
        "message": "Invoice analytics fetched successfully",
    }


# ─── 4. POST /invoices/bulk-actions  (HWINV-303) — must be before /{literal} ─

@router.post("/bulk-actions", response_model=Dict[str, Any])
async def bulk_action_endpoint(
    payload: InvoiceBulkActionRequest,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """
    Perform a bulk action (remove | cancel | resend) on up to 10 invoices.

    HWINV-303
    """
    processed: List[dict] = []
    skipped: List[dict] = []
    errors: List[dict] = []

    for literal in payload.ids:
        invoice = invoice_crud.get_invoice_by_literal(db, literal, merchant.id)
        if invoice is None:
            errors.append({"id": literal, "reason": "not found"})
            continue

        if payload.action == InvoiceBulkActions.remove:
            if invoice.status in _IMMUTABLE_STATUSES:
                skipped.append({"id": literal, "reason": "cannot remove paid/authorized invoice"})
                continue
            invoice_crud.soft_delete_invoice(db, invoice)
            invoice_crud.write_activity(
                db=db,
                invoice_id=invoice.id,
                activity_type=InvoiceActivityTypes.INVOICE_CANCELLED,
                description="Invoice removed via bulk action",
                actor_type="merchant",
                actor_id=getattr(current_user, "id", None),
            )
            processed.append({"id": literal, "action": "removed"})

        elif payload.action == InvoiceBulkActions.cancel:
            if invoice.status in _IMMUTABLE_STATUSES:
                skipped.append({"id": literal, "reason": "cannot cancel paid/authorized invoice"})
                continue
            invoice.status = InvoiceStatusTypes.CANCELLED
            invoice.updated_at = datetime.now(timezone.utc)
            db.flush()
            invoice_crud.write_activity(
                db=db,
                invoice_id=invoice.id,
                activity_type=InvoiceActivityTypes.INVOICE_CANCELLED,
                description="Invoice cancelled via bulk action",
                actor_type="merchant",
                actor_id=getattr(current_user, "id", None),
            )
            processed.append({"id": literal, "action": "cancelled"})

        elif payload.action == InvoiceBulkActions.resend:
            if invoice.status == InvoiceStatusTypes.DRAFT:
                skipped.append({"id": literal, "reason": "cannot resend draft invoice"})
                continue
            from src.apps.invoices.tasks.send_invoice import send_invoice_task
            send_invoice_task.delay(invoice.id)
            processed.append({"id": literal, "action": "resend_queued"})

    db.commit()

    return {
        "data": {
            "processed": processed,
            "skipped": skipped,
            "errors": errors,
        },
        "status_code": 200,
        "success": True,
        "message": f"Bulk action '{payload.action}' completed",
    }


# ─── 5. GET /invoices  (existing — keep as-is) ───────────────────────────────

@router.get("", response_model=Dict[str, Any])
async def list_invoices_endpoint(
    page: int = Query(default=1, ge=1),
    per_page: int = Query(default=10, ge=1, le=100),
    sort_by: Optional[List[str]] = Query(
        default=None,
        description="Sort fields. Prefix with '-' for descending. Eg: sort_by=created_at",
    ),
    filters: InvoiceListFilterSchema = Depends(),
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """List invoices for the authenticated merchant (existing endpoint)."""
    filters.sort_by = sort_by
    items, total = list_invoices(
        db=db,
        merchant_id=merchant.id,
        filters=filters,
        page=page,
        per_page=per_page,
    )
    serialized = [InvoiceListSchema.model_validate(item) for item in items]
    return {
        "data": {
            "result": [item.model_dump() for item in serialized],
            "total": total,
            "page": page,
            "per_page": per_page,
        },
        "status_code": 200,
        "success": True,
        "message": "Invoices fetched successfully",
    }


# ─── 6. POST /invoices  (HWINV-101 + HWINV-106) ──────────────────────────────

@router.post("", response_model=Dict[str, Any], status_code=201)
async def create_invoice_endpoint(
    payload: MerchantInvoiceCreateSchema,
    request: Request,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """
    Create a new invoice.

    HWINV-101 (non-draft) — calls create_payment_request() then create_invoice().
    HWINV-106 (draft)     — creates a minimal Invoice with status=DRAFT; NO payment
                             request, NO HPP link, NO notifications, NO Kafka events.
    """
    is_draft = payload.save_as_draft or payload.is_draft

    if is_draft:
        # ── DRAFT PATH ──────────────────────────────────────────────────────
        # Resolve optional customer
        customer_id_int: Optional[int] = None
        if payload.customer_id:
            from sqlalchemy import select
            from src.apps.customers.models.customer import Customer
            cust = db.execute(
                select(Customer).where(
                    Customer.customer_id == payload.customer_id,
                    Customer.merchant_id == merchant.id,
                    Customer.deleted_at == None,
                )
            ).scalar_one_or_none()
            if cust:
                customer_id_int = cust.id

        if customer_id_int is None:
            # Draft invoices need a placeholder customer (use 0 / nullable if schema allows)
            # The Invoice model requires customer_id FK; raise if not provided for drafts with
            # strict mode. For now, require customer_id on draft too but only as DB lookup.
            # If no customer found, we still need a valid FK — raise a helpful error.
            if payload.customer_id:
                raise APIException(
                    message="Customer not found. Please provide a valid customer_id.",
                    status_code=400,
                )
            raise APIException(
                message="customer_id is required to save a draft invoice.",
                status_code=400,
            )

        # Minimal payment_request placeholder for draft — create stub PR
        from src.apps.payment_requests.models.payment_request import PaymentRequest
        from src.apps.payment_requests.enums import PaymentRequestStatusTypes
        stub_pr = PaymentRequest(
            payment_request_id=generate_secure_id(prepend="pr", length=20),
            merchant_id=merchant.id,
            amount=payload.amount or 0,
            status=PaymentRequestStatusTypes.DRAFT,
            created_by_id=current_user.id,
            authorization_type=payload.authorization_type.value if payload.authorization_type else None,
            payment_frequency=payload.payment_frequency.value if payload.payment_frequency else None,
            currency=payload.currency.value if payload.currency else "usd",
            message=payload.message,
            enable_email=payload.enable_email if payload.enable_email is not None else True,
            enable_sms=payload.enable_sms if payload.enable_sms is not None else False,
            enable_email_receipt=payload.enable_email_receipt if payload.enable_email_receipt is not None else False,
            enable_sms_receipt=payload.enable_sms_receipt if payload.enable_sms_receipt is not None else False,
            # Persist additional invoice fields so drafts restore fully
            shipping_fee=float(payload.shipping_fee or 0),
            tax_type=payload.tax_type if payload.tax_type else None,
            invoice_terms=payload.invoice_terms if payload.invoice_terms else None,
            is_surcharge_enabled=payload.is_surcharge_enabled or False,
            surcharge_type=payload.surcharge_type.value if payload.surcharge_type else None,
            require_billing_address=payload.require_billing_address or False,
            require_cvv=payload.require_cvv or False,
            require_sms_authorization=payload.require_sms_authorization or False,
            require_shipping_address=payload.require_shipping_address or False,
            require_signature_authorization=payload.require_signature_authorization or False,
            payment_split_frequency=payload.payment_split_frequency,
            configure_adjustment=payload.configure_adjustment or False,
        )
        db.add(stub_pr)
        db.flush()

        # Resolve payer and approver contact IDs (string contact_id → integer FK)
        from sqlalchemy import select as _select
        from src.apps.customers.models.customer_contact import CustomerContact

        def _resolve_contact_id(contact_str: Optional[str]) -> Optional[int]:
            """Try contact_id string first, then fall back to integer id."""
            if not contact_str:
                return None
            rec = db.execute(
                _select(CustomerContact).where(CustomerContact.contact_id == contact_str)
            ).scalar_one_or_none()
            if rec:
                return rec.id
            # Fallback: maybe the frontend sent the integer id as a string
            try:
                int_id = int(contact_str)
                rec2 = db.execute(
                    _select(CustomerContact).where(CustomerContact.id == int_id)
                ).scalar_one_or_none()
                return rec2.id if rec2 else None
            except (ValueError, TypeError):
                return None

        payer_id_int = _resolve_contact_id(payload.payer_id)
        approver_id_int = _resolve_contact_id(payload.approver_id)

        from src.apps.invoices.services.invoice_services import create_invoice
        invoice = create_invoice(
            db=db,
            payment_request=stub_pr,
            merchant_id=merchant.id,
            customer_id=customer_id_int,
            amount=payload.amount or 0,
            status=InvoiceStatusTypes.DRAFT,
            payer_id=payer_id_int,
            approver_id=approver_id_int,
            due_date=payload.due_date,
            billing_date=payload.billing_date,
            reference=payload.reference,
            shipping_fee=float(payload.shipping_fee or 0),
            is_surcharge_enabled=payload.is_surcharge_enabled or False,
            surcharge_type=payload.surcharge_type.value if payload.surcharge_type else None,
            comments=None,
        )

        # Save line items to invoices_line_items
        if payload.line_items:
            from src.apps.invoices.models.invoice_line_items import InvoiceLineItems
            for li_data in payload.line_items:
                li_dict = li_data.model_dump() if hasattr(li_data, "model_dump") else dict(li_data)
                new_li = InvoiceLineItems(
                    invoice_id=invoice.id,
                    title=li_dict.get("title") or "",
                    description=li_dict.get("description") or "",
                    unit_price=float(li_dict.get("unit_price") or 0),
                    quantity=int(li_dict.get("quantity") or 1),
                    tax=float(li_dict.get("tax") or 0),
                    cost=float(li_dict.get("cost") or 0),
                    upcharge=float(li_dict.get("upcharge") or 0),
                    product_id=li_dict.get("product_id") or None,
                )
                db.add(new_li)
            db.flush()

        # Save split_config entries linked to the stub payment request
        if payload.split_config:
            from src.apps.payment_requests.models.split_payment_requests import SplitPaymentRequests
            for sc_data in payload.split_config:
                sc_dict = sc_data.model_dump() if hasattr(sc_data, "model_dump") else dict(sc_data)
                sp = SplitPaymentRequests(
                    payment_request_id=stub_pr.id,
                    sequence=sc_dict.get("sequence"),
                    split_type=sc_dict.get("split_type") or "amount",
                    split_value=float(sc_dict.get("split_value") or 0),
                    billing_date=sc_dict.get("billing_date"),
                    due_date=sc_dict.get("due_date"),
                )
                db.add(sp)
            db.flush()

        # Save recurring_config linked to the stub payment request
        if payload.recurring_config:
            from src.apps.payment_requests.models.recurring_payment_request import RecurringPaymentRequests
            rc_dict = payload.recurring_config.model_dump() if hasattr(payload.recurring_config, "model_dump") else dict(payload.recurring_config)
            rp = RecurringPaymentRequests(
                payment_request_id=stub_pr.id,
                prorate_first_payment=rc_dict.get("prorate_first_payment") or False,
                interval=rc_dict.get("interval") or "month",
                interval_value=int(rc_dict.get("interval_value") or 1),
                interval_time=rc_dict.get("interval_time"),
                start_date=rc_dict.get("start_date"),
                prorate_date=rc_dict.get("prorate_date"),
                repeat_type=rc_dict.get("repeat_type") or "interval",
                end_type=rc_dict.get("end_type") or "never",
                end_date=rc_dict.get("end_date"),
                pay_until_count=rc_dict.get("pay_until_count"),
            )
            db.add(rp)
            db.flush()

        # Save payment_adjustments (surcharge, discount, late fee) linked to stub PR
        if payload.payment_adjustments:
            from src.apps.payment_requests.models.payment_request_adjustments import PaymentRequestAdjustments
            adj_dict = (
                payload.payment_adjustments.model_dump()
                if hasattr(payload.payment_adjustments, "model_dump")
                else dict(payload.payment_adjustments)
            )
            new_adj = PaymentRequestAdjustments(
                payment_request_id=stub_pr.id,
                is_surcharged=adj_dict.get("is_surcharged") or False,
                surcharge_amount=float(adj_dict.get("surcharge_amount") or 0),
                adjustment_description=adj_dict.get("adjustment_description"),
                disclaimer=adj_dict.get("disclaimer"),
                is_discounted=adj_dict.get("is_discounted") or False,
                is_manual_discount=adj_dict.get("is_manual_discount") or False,
                discount_amount=float(adj_dict.get("discount_amount") or 0),
                discount_type=adj_dict.get("discount_type") or "amount",
                discount_name=adj_dict.get("discount_name"),
                has_late_fee=adj_dict.get("has_late_fee") or False,
                fee_amount=float(adj_dict.get("fee_amount") or 0),
                late_fee_type=adj_dict.get("late_fee_type") or "amount",
                fee_frequency=adj_dict.get("fee_frequency"),
                late_fee_delay=int(adj_dict.get("late_fee_delay") or 0),
                late_fee_delay_frequency=adj_dict.get("late_fee_delay_frequency"),
                cap_fee_amount=float(adj_dict.get("cap_fee_amount") or 0),
            )
            db.add(new_adj)
            db.flush()

        # Link attachments to the stub PR if provided
        if payload.attachments:
            from src.apps.files.models.file import File
            for file_id in payload.attachments:
                file_obj = db.get(File, int(file_id))
                if file_obj and file_obj not in stub_pr.attachments:
                    stub_pr.attachments.append(file_obj)
            db.flush()

        invoice_crud.write_activity(
            db=db,
            invoice_id=invoice.id,
            activity_type=InvoiceActivityTypes.INVOICE_DRAFT_SAVED,
            description="Draft invoice created",
            actor_type="merchant",
            actor_id=getattr(current_user, "id", None),
        )
        db.commit()
        # Re-fetch with eager loading so line_items, payer, customer are serialized correctly
        invoice = invoice_crud.get_invoice_by_literal(db, invoice.invoice_literal, merchant.id) or invoice

        return {
            "data": InvoiceSchema.model_validate(invoice).model_dump(),
            "status_code": 201,
            "success": True,
            "message": "Draft invoice saved successfully",
        }

    else:
        # ── LIVE PATH ────────────────────────────────────────────────────────
        # Convert MerchantInvoiceCreateSchema → MerchantPaymentRequestCreateSchema (same base)
        from src.apps.payment_requests.schemas.requests import MerchantPaymentRequestCreateSchema
        from src.apps.payment_requests.services import create_payment_request
        from src.apps.users.schemas.user_common import UserSchema

        # Strip None values so downstream validators use their defaults (e.g. line_items=[])
        pr_data = {k: v for k, v in payload.model_dump().items() if v is not None}
        # Inject invoice-specific defaults for fields required by payment_requests schema
        from src.apps.payment_requests.enums import (
            PaymentAuthorizationTypes,
            PaymentFrequencies,
        )
        pr_data.setdefault("authorization_type", PaymentAuthorizationTypes.PRE_AUTH)
        pr_data.setdefault("payment_frequency", PaymentFrequencies.ONE_TIME)
        pr_data.setdefault("shipping_fee", 0.0)
        pr_data.setdefault("currency", "usd")
        pr_payload = MerchantPaymentRequestCreateSchema.model_validate(pr_data)
        pr_response = await create_payment_request(
            db=db,
            merchant=merchant,
            current_user=UserSchema.model_validate(current_user),
            payload=pr_payload,
            request=request,
        )

        # create_payment_request already creates the Invoice internally.
        # Fetch it back via the payment request id.
        from sqlalchemy import select
        from src.apps.invoices.models.invoice import Invoice
        from src.apps.payment_requests.models.payment_request import PaymentRequest

        pr_literal = getattr(pr_response, "payment_request_literal", None)
        pr_id = getattr(pr_response, "id", None)

        invoice = None
        if pr_id:
            stmt = select(Invoice).where(Invoice.payment_request_id == pr_id, Invoice.deleted_at == None)
            invoice = db.execute(stmt).unique().scalar_one_or_none()

        if invoice is None and pr_id:
            # create_payment_request didn't create an invoice (e.g. no payment methods provided).
            # Create the invoice explicitly here.
            from src.apps.invoices.services.invoice_services import create_invoice as _create_invoice_svc
            from src.apps.customers.models.customer import Customer
            from src.apps.payment_requests.models.payment_request_customer import PaymentRequestCustomer
            pr_obj = db.execute(
                select(PaymentRequest).where(PaymentRequest.id == pr_id)
            ).scalar_one_or_none()
            # Resolve customer integer id from the payment_requests_customers junction
            live_customer_id_int = None
            if pr_obj:
                pr_cust = db.execute(
                    select(PaymentRequestCustomer).where(
                        PaymentRequestCustomer.payment_request_id == pr_obj.id
                    )
                ).scalar_one_or_none()
                if pr_cust:
                    live_customer_id_int = pr_cust.customer_id
                elif payload.customer_id:
                    cust = db.execute(
                        select(Customer).where(
                            Customer.customer_id == payload.customer_id,
                            Customer.merchant_id == merchant.id,
                            Customer.deleted_at == None,
                        )
                    ).scalar_one_or_none()
                    if cust:
                        live_customer_id_int = cust.id

            if pr_obj and live_customer_id_int:
                invoice = _create_invoice_svc(
                    db=db,
                    payment_request=pr_obj,
                    merchant_id=merchant.id,
                    customer_id=live_customer_id_int,
                    amount=float(pr_obj.amount),
                    status=InvoiceStatusTypes.CREATED,
                    due_date=pr_obj.due_date,
                    billing_date=pr_obj.billing_date,
                    reference=pr_obj.reference,
                    shipping_fee=float(pr_obj.shipping_fee or 0),
                    is_surcharge_enabled=pr_obj.is_surcharge_enabled,
                    surcharge_type=pr_obj.surcharge_type if pr_obj.surcharge_type else None,
                )
                if invoice:
                    # Write INVOICE_CREATED activity for invoices created directly via the
                    # invoice endpoint (no immediate charge). The payment-request services path
                    # writes its own activity when a charge succeeds, so we only write here.
                    invoice_crud.write_activity(
                        db=db,
                        invoice_id=invoice.id,
                        activity_type=InvoiceActivityTypes.INVOICE_CREATED,
                        description="Invoice created",
                        actor_type="merchant",
                        actor_id=getattr(current_user, "id", None),
                    )

                # For recurring invoices, link the subscription that was just created.
                from src.apps.payment_requests.enums import PaymentFrequencies as _PF
                if invoice and payload.payment_frequency == _PF.RECURRING:
                    from src.apps.subscriptions.models.subscription import Subscription as _Sub
                    _sub = db.execute(
                        select(_Sub).where(
                            _Sub.payment_request_id == pr_obj.id,
                            _Sub.deleted_at == None,
                        )
                    ).scalar_one_or_none()
                    if _sub and invoice.subscription_id is None:
                        invoice.subscription_id = _sub.id
                        db.flush()

        if invoice is None:
            return {
                "data": pr_response.model_dump() if hasattr(pr_response, "model_dump") else pr_response,
                "status_code": 201,
                "success": True,
                "message": "Invoice created successfully",
            }

        db.commit()
        return {
            "data": InvoiceSchema.model_validate(invoice).model_dump(),
            "status_code": 201,
            "success": True,
            "message": "Invoice created successfully",
        }


# ─── 7. GET /invoices/{invoice_literal} ──────────────────────────────────────

@router.get("/{invoice_literal}", response_model=Dict[str, Any])
async def get_invoice_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """Retrieve a single invoice by its literal. Existing endpoint."""
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")
    invoice_data = InvoiceSchema.model_validate(invoice).model_dump()
    # Fallback: populate line_items from the payment request when none stored on invoice
    if not invoice_data.get("line_items") and invoice.payment_request:
        pr_line_items = getattr(invoice.payment_request, "line_items", None)
        if pr_line_items:
            from src.apps.invoices.schemas.invoice_common import InvoicePaymentRequestLineItemSchema
            invoice_data["line_items"] = [
                InvoicePaymentRequestLineItemSchema.model_validate(li).model_dump()
                for li in pr_line_items
            ]
    return {
        "data": invoice_data,
        "status_code": 200,
        "success": True,
        "message": "Invoice fetched successfully",
    }


# ─── 8. PUT /invoices/{invoice_literal}  (HWINV-102) ─────────────────────────

@router.put("/{invoice_literal}", response_model=Dict[str, Any])
async def update_invoice_endpoint(
    payload: MerchantInvoiceUpdateSchema,
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """
    Update an invoice.

    - DRAFT: stays DRAFT after update; no Kafka events.
    - CREATED/PENDING: transitions to UPDATED; emits invoice.updated activity.
    - PAID/AUTHORIZED: blocked.

    HWINV-102
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    if invoice.status in _IMMUTABLE_STATUSES:
        raise APIException(
            message="Paid or authorized invoices cannot be updated.",
            status_code=403,
        )

    now = datetime.now(timezone.utc)

    # Apply field updates
    if payload.amount is not None:
        invoice.amount = payload.amount
    if payload.due_date is not None:
        invoice.due_date = payload.due_date
    if payload.billing_date is not None:
        invoice.billing_date = payload.billing_date
    if payload.reference is not None:
        invoice.reference = payload.reference
    if payload.shipping_fee is not None:
        invoice.shipping_fee = payload.shipping_fee
    if payload.is_surcharge_enabled is not None:
        invoice.is_surcharge_enabled = payload.is_surcharge_enabled
    if payload.surcharge_type is not None:
        invoice.surcharge_type = payload.surcharge_type.value if hasattr(payload.surcharge_type, "value") else payload.surcharge_type

    # Sync authorization_type and payment_frequency onto the linked payment_request
    if invoice.payment_request is not None:
        if payload.authorization_type is not None:
            invoice.payment_request.authorization_type = (
                payload.authorization_type.value if hasattr(payload.authorization_type, "value") else payload.authorization_type
            )
        if payload.payment_frequency is not None:
            invoice.payment_request.payment_frequency = (
                payload.payment_frequency.value if hasattr(payload.payment_frequency, "value") else payload.payment_frequency
            )
        if payload.currency is not None:
            invoice.payment_request.currency = (
                payload.currency.value if hasattr(payload.currency, "value") else payload.currency
            )
        if payload.amount is not None:
            invoice.payment_request.amount = payload.amount

        # Sync message
        if payload.message is not None:
            invoice.payment_request.message = payload.message

        # Sync additional stub fields for draft completeness
        if payload.tax_type is not None:
            invoice.payment_request.tax_type = payload.tax_type.value if hasattr(payload.tax_type, "value") else payload.tax_type
        if payload.invoice_terms is not None:
            invoice.payment_request.invoice_terms = payload.invoice_terms
        if payload.is_surcharge_enabled is not None:
            invoice.payment_request.is_surcharge_enabled = payload.is_surcharge_enabled
        if payload.surcharge_type is not None:
            invoice.payment_request.surcharge_type = payload.surcharge_type.value if hasattr(payload.surcharge_type, "value") else payload.surcharge_type
        if payload.payment_split_frequency is not None:
            invoice.payment_request.payment_split_frequency = payload.payment_split_frequency
        if payload.configure_adjustment is not None:
            invoice.payment_request.configure_adjustment = payload.configure_adjustment
        if payload.require_billing_address is not None:
            invoice.payment_request.require_billing_address = payload.require_billing_address
        if payload.require_cvv is not None:
            invoice.payment_request.require_cvv = payload.require_cvv
        if payload.require_sms_authorization is not None:
            invoice.payment_request.require_sms_authorization = payload.require_sms_authorization
        if payload.require_shipping_address is not None:
            invoice.payment_request.require_shipping_address = payload.require_shipping_address
        if payload.require_signature_authorization is not None:
            invoice.payment_request.require_signature_authorization = payload.require_signature_authorization

        # Sync notification flags
        if payload.enable_email is not None:
            invoice.payment_request.enable_email = payload.enable_email
        if payload.enable_sms is not None:
            invoice.payment_request.enable_sms = payload.enable_sms
        if payload.enable_email_receipt is not None:
            invoice.payment_request.enable_email_receipt = payload.enable_email_receipt
        if payload.enable_sms_receipt is not None:
            invoice.payment_request.enable_sms_receipt = payload.enable_sms_receipt

        # Replace payment_adjustments (delete-and-recreate)
        if payload.payment_adjustments is not None:
            from sqlalchemy import delete as _del_adj
            from src.apps.payment_requests.models.payment_request_adjustments import (
                PaymentRequestAdjustments as _PRA,
            )
            db.execute(_del_adj(_PRA).where(_PRA.payment_request_id == invoice.payment_request.id))
            adj_dict = (
                payload.payment_adjustments.model_dump()
                if hasattr(payload.payment_adjustments, "model_dump")
                else dict(payload.payment_adjustments)
            )
            db.add(_PRA(
                payment_request_id=invoice.payment_request.id,
                is_surcharged=bool(adj_dict.get("is_surcharged") or False),
                surcharge_amount=float(adj_dict.get("surcharge_amount") or 0),
                adjustment_description=adj_dict.get("adjustment_description"),
                disclaimer=adj_dict.get("disclaimer"),
                is_discounted=bool(adj_dict.get("is_discounted") or False),
                is_manual_discount=bool(adj_dict.get("is_manual_discount") or False),
                discount_amount=float(adj_dict.get("discount_amount") or 0),
                discount_type=adj_dict.get("discount_type") or "amount",
                discount_name=adj_dict.get("discount_name"),
                has_late_fee=bool(adj_dict.get("has_late_fee") or False),
                fee_amount=float(adj_dict.get("fee_amount") or 0),
                late_fee_type=adj_dict.get("late_fee_type") or "amount",
                fee_frequency=adj_dict.get("fee_frequency"),
                late_fee_delay=int(adj_dict.get("late_fee_delay") or 0),
                late_fee_delay_frequency=adj_dict.get("late_fee_delay_frequency"),
                cap_fee_amount=float(adj_dict.get("cap_fee_amount") or 0),
                discount_id=adj_dict.get("discount_id"),
            ))
            db.flush()

        # Replace attachments via ORM relationship
        if payload.attachments is not None:
            from src.apps.files.models.file import File as _File
            from sqlalchemy import select as _sel_f
            file_objs = db.execute(
                _sel_f(_File).where(_File.id.in_(payload.attachments))
            ).scalars().all()
            invoice.payment_request.attachments = list(file_objs)

    # Update payer / approver contact FKs
    from sqlalchemy import select as _sel
    from src.apps.customers.models.customer_contact import CustomerContact as _CC

    def _resolve_cc(contact_str: Optional[str]) -> Optional[int]:
        if not contact_str:
            return None
        rec = db.execute(_sel(_CC).where(_CC.contact_id == contact_str)).scalar_one_or_none()
        if rec:
            return rec.id
        try:
            int_id = int(contact_str)
            rec2 = db.execute(_sel(_CC).where(_CC.id == int_id)).scalar_one_or_none()
            return rec2.id if rec2 else None
        except (ValueError, TypeError):
            return None

    if payload.payer_id is not None:
        invoice.payer_id = None if payload.payer_id == "" else _resolve_cc(payload.payer_id)
    if payload.approver_id is not None:
        invoice.approver_id = None if payload.approver_id == "" else _resolve_cc(payload.approver_id)

    # Replace line items when provided
    if payload.line_items is not None:
        if len(payload.line_items) == 0:
            raise APIException(message="At least one line item is required.", status_code=400)
        from sqlalchemy import delete as _del
        from src.apps.invoices.models.invoice_line_items import InvoiceLineItems as _ILI
        db.execute(_del(_ILI).where(_ILI.invoice_id == invoice.id))
        for li_data in payload.line_items:
            li_dict = li_data.model_dump() if hasattr(li_data, "model_dump") else dict(li_data)
            new_li = _ILI(
                invoice_id=invoice.id,
                title=li_dict.get("title") or "",
                description=li_dict.get("description") or "",
                unit_price=float(li_dict.get("unit_price") or 0),
                quantity=int(li_dict.get("quantity") or 1),
                tax=float(li_dict.get("tax") or 0),
                cost=float(li_dict.get("cost") or 0),
                upcharge=float(li_dict.get("upcharge") or 0),
            )
            db.add(new_li)

    # Replace split_config when provided
    if payload.split_config is not None and invoice.payment_request is not None:
        from sqlalchemy import delete as _del2
        from src.apps.payment_requests.models.split_payment_requests import SplitPaymentRequests as _SP
        db.execute(_del2(_SP).where(_SP.payment_request_id == invoice.payment_request.id))
        for sc_data in payload.split_config:
            sc_dict = sc_data.model_dump() if hasattr(sc_data, "model_dump") else dict(sc_data)
            db.add(_SP(
                payment_request_id=invoice.payment_request.id,
                sequence=sc_dict.get("sequence"),
                split_type=sc_dict.get("split_type") or "amount",
                split_value=float(sc_dict.get("split_value") or 0),
                billing_date=sc_dict.get("billing_date"),
                due_date=sc_dict.get("due_date"),
            ))

    # Replace recurring_config when provided
    if payload.recurring_config is not None and invoice.payment_request is not None:
        from sqlalchemy import delete as _del3
        from src.apps.payment_requests.models.recurring_payment_request import RecurringPaymentRequests as _RP
        db.execute(_del3(_RP).where(_RP.payment_request_id == invoice.payment_request.id))
        rc_dict = payload.recurring_config.model_dump() if hasattr(payload.recurring_config, "model_dump") else dict(payload.recurring_config)
        db.add(_RP(
            payment_request_id=invoice.payment_request.id,
            prorate_first_payment=rc_dict.get("prorate_first_payment") or False,
            interval=rc_dict.get("interval") or "month",
            interval_value=int(rc_dict.get("interval_value") or 1),
            interval_time=rc_dict.get("interval_time"),
            start_date=rc_dict.get("start_date"),
            prorate_date=rc_dict.get("prorate_date"),
            repeat_type=rc_dict.get("repeat_type") or "interval",
            end_type=rc_dict.get("end_type") or "never",
            end_date=rc_dict.get("end_date"),
            pay_until_count=rc_dict.get("pay_until_count"),
        ))

    # Link payment methods to the stub PR when provided (enables charge on publish)
    if payload.payment_methods is not None and invoice.payment_request is not None:
        from src.apps.payment_methods.services import save_payment_method as _save_pm
        from src.apps.payment_methods.models.payment_methods import PaymentMethod as _PMModel
        from src.apps.customers.models.customer import Customer as _CustPM
        from sqlalchemy import select as _sel_pm

        _cust_pm = db.execute(
            _sel_pm(_CustPM).where(
                _CustPM.id == invoice.customer_id,
                _CustPM.deleted_at.is_(None),
            )
        ).scalar_one_or_none()

        if _cust_pm:
            _payer_pm = None
            if invoice.payer_id:
                from src.apps.customers.models.customer_contact import CustomerContact as _CCPM
                _payer_pm = db.execute(
                    _sel_pm(_CCPM).where(_CCPM.id == invoice.payer_id)
                ).scalar_one_or_none()

            for _pm_data in payload.payment_methods:
                _pm_token = getattr(_pm_data, "payment_method_token", None)
                if not _pm_token:
                    continue
                _pm_id = getattr(_pm_data, "payment_method_id", None)
                _card_det = getattr(_pm_data, "card_details", None)
                _ach_det = getattr(_pm_data, "ach_details", None)
                try:
                    _pm_response = await _save_pm(
                        session=db,
                        payment_method_token=_pm_token,
                        merchant_id=merchant.id,
                        payment_method_id=_pm_id,
                        customer_id=_cust_pm.id,
                        payer_id=_payer_pm.id if _payer_pm else None,
                        payment_request_id=invoice.payment_request.id,
                        card_details=_card_det,
                        ach_details=_ach_det,
                    )
                    # save_payment_method returns the existing PM on idempotency hit
                    # without updating payment_request_id — ensure FK is always set.
                    if _pm_response and getattr(_pm_response, "id", None):
                        _pm_orm = db.execute(
                            _sel_pm(_PMModel).where(_PMModel.id == _pm_response.id)
                        ).scalar_one_or_none()
                        if _pm_orm and _pm_orm.payment_request_id != invoice.payment_request.id:
                            _pm_orm.payment_request_id = invoice.payment_request.id
                            db.flush()
                except Exception as _pm_err:
                    logger.warning(
                        "Failed to link payment method to stub PR during invoice update %s: %s",
                        invoice_literal,
                        _pm_err,
                    )

    invoice.updated_at = now

    is_non_draft = invoice.status != InvoiceStatusTypes.DRAFT

    if is_non_draft:
        invoice.status = InvoiceStatusTypes.UPDATED
        # Sync PaymentRequest status to UPDATED for non-draft invoices
        if invoice.payment_request is not None:
            from src.apps.payment_requests.enums import PaymentRequestStatusTypes
            invoice.payment_request.status = PaymentRequestStatusTypes.UPDATED.value
            invoice.payment_request.updated_at = now
        db.flush()
        invoice_crud.write_activity(
            db=db,
            invoice_id=invoice.id,
            activity_type=InvoiceActivityTypes.INVOICE_UPDATED,
            description="Invoice updated by merchant",
            actor_type="merchant",
            actor_id=getattr(current_user, "id", None),
        )
    else:
        db.flush()
        invoice_crud.write_activity(
            db=db,
            invoice_id=invoice.id,
            activity_type=InvoiceActivityTypes.INVOICE_DRAFT_SAVED,
            description="Draft invoice updated",
            actor_type="merchant",
            actor_id=getattr(current_user, "id", None),
        )

    db.commit()
    # Re-fetch with eager loading to ensure line_items and relations are serialized correctly
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id) or invoice

    # Emit invoice.updated Kafka event for non-draft updates
    if is_non_draft:
        try:
            from src.events.base import BaseEvent
            from src.events.dispatcher import EventDispatcher
            await EventDispatcher.dispatch(
                BaseEvent(
                    event_type="invoice.updated",
                    data={
                        "invoice_id": invoice.id,
                        "invoice_literal": invoice.invoice_literal,
                        "merchant_id": invoice.merchant_id,
                        "customer_id": invoice.customer_id,
                        "amount": float(invoice.amount or 0),
                        "due_date": invoice.due_date.isoformat() if invoice.due_date else None,
                        "payment_request_id": invoice.payment_request_id,
                        "actor_type": "merchant",
                        "actor_id": getattr(current_user, "id", None),
                    },
                )
            )
        except Exception as _evt_exc:
            import logging as _log
            _log.getLogger(__name__).warning("invoice.updated event dispatch failed: %s", _evt_exc)

    return {
        "data": InvoiceSchema.model_validate(invoice).model_dump(),
        "status_code": 200,
        "success": True,
        "message": "Invoice updated successfully",
    }


# ─── 9. DELETE /invoices/{invoice_literal}  (HWINV-103) ─────────────────────

@router.delete("/{invoice_literal}", response_model=Dict[str, Any])
async def delete_invoice_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """
    Soft-delete an invoice.

    - DRAFT: always deletable.
    - Non-DRAFT: deletable unless PAID or AUTHORIZED.

    HWINV-103
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    if invoice.status in _IMMUTABLE_STATUSES:
        raise APIException(
            message="Paid or authorized invoices cannot be deleted.",
            status_code=403,
        )

    invoice_crud.soft_delete_invoice(db, invoice)
    invoice_crud.write_activity(
        db=db,
        invoice_id=invoice.id,
        activity_type=InvoiceActivityTypes.INVOICE_CANCELLED,
        description="Invoice deleted (soft) by merchant",
        actor_type="merchant",
        actor_id=getattr(current_user, "id", None),
    )
    db.commit()

    return {
        "data": {"deleted": True, "invoice_literal": invoice_literal},
        "status_code": 200,
        "success": True,
        "message": "Invoice deleted successfully",
    }


# ─── 10. POST /invoices/{invoice_literal}/publish  (HWINV-107) ───────────────

@router.post("/{invoice_literal}/publish", response_model=Dict[str, Any])
async def publish_invoice_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    payload: Optional[InvoicePublishRequest] = None,
    request: Request = None,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """
    Publish a draft invoice — mirrors the live-creation path for both PRE_AUTH
    and REQUEST_AUTH authorization types.

    - REQUEST_AUTH: creates PaymentRequestLink (HPP token) + authorization record,
      transitions invoice → AWAITING_APPROVAL.
    - PRE_AUTH: creates authorization record (is_verified=True); if the linked PR
      has payment methods and billing date is not in the future, submits the charge
      immediately (invoice → PAID); otherwise defers (invoice → PENDING).

    If payload.send=True, enqueues the send_invoice Celery task immediately after
    all transitions complete.

    HWINV-107
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    if invoice.status != InvoiceStatusTypes.DRAFT:
        raise APIException(
            message="Only draft invoices can be published.",
            status_code=409,
        )

    # Validate required fields for publication
    if not invoice.amount or invoice.amount <= 0:
        raise APIException(message="Invoice amount must be greater than zero to publish.", status_code=400)
    if not invoice.customer_id:
        raise APIException(message="A customer must be assigned before publishing.", status_code=400)
    if not invoice.line_items or len(invoice.line_items) == 0:
        raise APIException(message="At least one line item is required to publish an invoice.", status_code=400)

    now = datetime.now(timezone.utc)
    # Transition the linked PaymentRequest out of DRAFT
    hpp_token = None
    is_request_auth = False
    pr = None
    if invoice.payment_request is not None:
        from src.apps.payment_requests.enums import PaymentAuthorizationTypes, PaymentRequestStatusTypes
        pr = invoice.payment_request
        is_request_auth = getattr(pr, "authorization_type", None) == PaymentAuthorizationTypes.REQUEST_AUTH.value

        if is_request_auth:
            pr.status = PaymentRequestStatusTypes.WAITING.value
        else:
            # PRE_AUTH: honour billing/due date — defer if in the future
            _charge_date = pr.billing_date or pr.due_date
            _today = now.date()
            _is_future = False
            if _charge_date is not None:
                _charge_date_d = _charge_date.date() if hasattr(_charge_date, "date") else _charge_date
                _is_future = _charge_date_d > _today
            pr.status = PaymentRequestStatusTypes.PENDING.value if _is_future else PaymentRequestStatusTypes.CREATED.value
        pr.updated_at = now

    # Determine invoice target status
    if is_request_auth:
        new_status = InvoiceStatusTypes.AWAITING_APPROVAL
    elif invoice.payment_request_id:
        new_status = InvoiceStatusTypes.PENDING
    else:
        new_status = InvoiceStatusTypes.CREATED
    invoice.status = new_status
    invoice.updated_at = now

    # REQUEST_AUTH: create HPP link so the customer can pay via the hosted page
    if pr is not None and is_request_auth:
        import secrets
        from src.apps.payment_requests.models.payment_request_links import PaymentRequestLinks
        hpp_token = secrets.token_urlsafe(32)
        hpp_link_record = PaymentRequestLinks(
            token=hpp_token,
            status="PENDING",
            is_expired=False,
            payment_request_id=pr.id,
            invoice_id=invoice.id,
            start_date=now,
            end_date=now + timedelta(hours=settings.HPP_LINK_EXPIRY_HOURS),
        )
        db.add(hpp_link_record)

    db.flush()

    # ── Create subscription for recurring invoices (mirrors create_payment_request path) ──
    _pub_subscription = None
    if pr is not None and getattr(pr, "payment_frequency", None) == "recurring":
        from src.apps.payment_requests.models.recurring_payment_request import (
            RecurringPaymentRequests as _RPR2,
        )
        from src.apps.subscriptions.models.subscription import Subscription as _Sub2
        from src.apps.subscriptions.enums import (
            SubscriptionStatus as _SubStatus2,
            SubscriptionActivityTypes as _SubActTypes2,
        )
        from src.apps.subscriptions import crud as _sub_crud2
        from src.apps.subscriptions.helpers.billing_date import (
            compute_next_billing_date as _cnbd2,
        )
        from sqlalchemy import select as _sel_sub

        _existing_sub2 = db.execute(
            _sel_sub(_Sub2).where(
                _Sub2.payment_request_id == pr.id,
                _Sub2.deleted_at == None,
            )
        ).scalar_one_or_none()

        if _existing_sub2 is not None:
            _pub_subscription = _existing_sub2
            if invoice.subscription_id is None:
                invoice.subscription_id = _existing_sub2.id
                if invoice.sequence_id is None:
                    invoice.sequence_id = 1
                db.flush()
        else:
            _rec2 = db.execute(
                _sel_sub(_RPR2).where(_RPR2.payment_request_id == pr.id)
            ).scalar_one_or_none()
            if _rec2:
                try:
                    from sqlalchemy.exc import IntegrityError as _IE2

                    # Compute first billing date
                    _start2 = getattr(_rec2, "start_date", None)
                    _prorate2 = getattr(_rec2, "prorate_first_payment", False)
                    _prorate_date2 = getattr(_rec2, "prorate_date", None)
                    if _prorate2 and _prorate_date2:
                        _first_billing2 = _prorate_date2
                    elif _start2:
                        _first_billing2 = _start2
                    else:
                        _first_billing2 = now

                    if _first_billing2 and getattr(_first_billing2, "tzinfo", None) is None:
                        _first_billing2 = _first_billing2.replace(tzinfo=timezone.utc)

                    # Initial status: ACTIVE for pre_auth, INITIALIZING for request_auth
                    _init_status2 = (
                        _SubStatus2.INITIALIZING if is_request_auth else _SubStatus2.ACTIVE
                    )

                    # Compute next billing date for subsequent cycles
                    _interval2 = getattr(_rec2, "interval", "month")
                    _interval_val2 = getattr(_rec2, "interval_value", 1)
                    _next_billing2 = _cnbd2(_interval2, _interval_val2, _first_billing2)

                    for _attempt2 in range(5):
                        try:
                            _sp2 = db.begin_nested()
                            _sub2_lit = _sub_crud2.generate_subscription_literal(db)
                            _sub2_id = _sub_crud2.generate_subscription_id()
                            _pub_subscription = _Sub2(
                                subscription_id=_sub2_id,
                                subscription_literal=_sub2_lit,
                                name=None,
                                status=_init_status2,
                                merchant_id=pr.merchant_id,
                                customer_id=invoice.customer_id,
                                payment_request_id=pr.id,
                                next_billing_date=_next_billing2,
                                total_billed=float(invoice.amount or 0),
                                total_paid=0.0,
                                invoices_generated=1,
                                invoices_paid=0,
                                dunning_retry_count=0,
                            )
                            db.add(_pub_subscription)
                            db.flush()
                            _sp2.commit()
                            break
                        except _IE2:
                            _sp2.rollback()
                            if _attempt2 == 4:
                                raise

                    # Link draft invoice to subscription as first billing cycle
                    invoice.subscription_id = _pub_subscription.id
                    invoice.sequence_id = 1
                    db.flush()

                    _sub_crud2.write_activity(
                        db=db,
                        subscription_id=_pub_subscription.id,
                        activity_type=_SubActTypes2.CREATED,
                        description=f"Subscription {_sub2_lit} created on invoice publish",
                        actor_type="merchant",
                        actor_id=getattr(current_user, "id", None),
                        metadata={"payment_request_id": pr.id},
                    )
                    logger.info(
                        "Created subscription %s during publish of invoice %s",
                        _sub2_lit,
                        invoice_literal,
                    )
                except Exception as _sub2_err:
                    logger.error(
                        "Failed to create subscription during publish of %s: %s",
                        invoice_literal,
                        _sub2_err,
                        exc_info=True,
                    )

    # Create authorization record — mirrors what create_payment_request() does in the live path
    if pr is not None:
        try:
            import types as _types
            from sqlalchemy import select as _sel
            from src.apps.payment_requests.services import create_authorization_record
            from src.apps.customers.models.customer import Customer as _Customer
            from src.apps.customers.models.customer_contact import CustomerContact as _Contact
            from src.apps.users.schemas.user_common import UserSchema

            _customer = db.execute(
                _sel(_Customer).where(
                    _Customer.id == invoice.customer_id,
                    _Customer.deleted_at.is_(None),
                )
            ).scalar_one_or_none()
            _payer = None
            if invoice.payer_id:
                _payer = db.execute(
                    _sel(_Contact).where(_Contact.id == invoice.payer_id)
                ).scalar_one_or_none()

            if _customer:
                _proxy_payload = _types.SimpleNamespace(
                    require_sms_authorization=getattr(pr, "require_sms_authorization", False) or False,
                    require_signature_authorization=getattr(pr, "require_signature_authorization", False) or False,
                )
                await create_authorization_record(
                    db=db,
                    payment_request=pr,
                    payer=_payer,
                    merchant=merchant,
                    customer=_customer,
                    current_user=UserSchema.model_validate(current_user),
                    payload=_proxy_payload,
                    request=request,
                )
        except Exception as _auth_exc:
            logger.warning("Failed to create authorization record during publish of %s: %s", invoice_literal, _auth_exc)

    # PRE_AUTH with payment methods and non-deferred billing: submit charge immediately
    charge_succeeded = False
    if pr is not None and not is_request_auth and pr.payment_methods and len(pr.payment_methods) > 0:
        _charge_date2 = pr.billing_date or pr.due_date
        _is_future2 = False
        if _charge_date2 is not None:
            _d2 = _charge_date2.date() if hasattr(_charge_date2, "date") else _charge_date2
            _is_future2 = _d2 > now.date()

        if not _is_future2:
            try:
                from src.apps.payment_requests.services import create_transaction
                from src.core.payment_provider import get_provider_client
                from src.core.utils.enums import (
                    TransactionStatusTypes,
                    TransactionCategories,
                    TransactionTypes,
                    TransactionSourceTypes,
                )
                from src.apps.customers.models.customer import Customer as _Cust2

                _cust2 = db.execute(
                    _sel(_Cust2).where(
                        _Cust2.id == invoice.customer_id,
                        _Cust2.deleted_at.is_(None),
                    )
                ).scalar_one_or_none()

                pm = pr.payment_methods[0]
                payrix_token = None
                if hasattr(pm, "card_details") and pm.card_details:
                    payrix_token = getattr(pm.card_details, "reference_id", None)
                elif hasattr(pm, "ach_details") and pm.ach_details:
                    payrix_token = getattr(pm.ach_details, "reference_id", None)

                # Fallback: the PM stub may not have been hydrated yet (async listener
                # hasn't run). Use the raw iframe token passed directly in the publish
                # payload so the charge fires immediately on the draft → paid flow.
                if not payrix_token and payload and payload.payment_method_token:
                    payrix_token = payload.payment_method_token

                if payrix_token and _cust2:
                    pm_type = getattr(pm, "method", None) or (
                        payload.payment_method_type if payload else None
                    ) or "card"
                    charge_result = await get_provider_client(session=db).submit_charge(
                        amount=float(pr.amount),
                        currency=pr.currency.lower() if pr.currency else "usd",
                        customer_id=_cust2.id,
                        merchant_id=merchant.id,
                        token=payrix_token,
                        payment_method_type=pm_type,
                        capture=True,
                    )

                    if charge_result and charge_result.get("status") == "succeeded":
                        charge_succeeded = True
                        pr.status = PaymentRequestStatusTypes.PAID.value
                        invoice.status = InvoiceStatusTypes.PAID
                        invoice.paid_amount = float(invoice.amount or 0)
                        invoice.paid_date = now
                        new_status = InvoiceStatusTypes.PAID

                        txn_id = charge_result.get("transaction_id") or generate_secure_id(prepend="txn", length=20)
                        billing_name = (
                            f"{_cust2.first_name or ''} {_cust2.last_name or ''}".strip()
                            or getattr(_cust2, "business_legal_name", None)
                            or None
                        )
                        transaction = create_transaction(
                            db,
                            txn_id=txn_id,
                            txn_amount=float(pr.amount),
                            currency=pr.currency.lower() if pr.currency else "usd",
                            txn_status=TransactionStatusTypes.PAID,
                            txn_type=pm_type,
                            txn_source="invoice",
                            category=TransactionCategories.CHARGE,
                            transaction_type=TransactionTypes.PAYMENT_TERMINAL,
                            txn_metadata=charge_result,
                            payment_request_id=pr.id,
                            merchant_id=merchant.id,
                            customer_id=_cust2.id,
                            payment_method_id=pm.id,
                            billing_name=billing_name,
                        )
                        invoice.transactions.append(transaction)
                        # Update subscription stats on successful charge
                        if _pub_subscription is not None:
                            _pub_subscription.invoices_paid = (_pub_subscription.invoices_paid or 0) + 1
                            _pub_subscription.total_paid = (
                                (_pub_subscription.total_paid or 0.0) + float(invoice.amount or 0)
                            )
                        db.flush()
                    else:
                        pr.status = PaymentRequestStatusTypes.PROCESSING.value
                        logger.warning(
                            "Charge did not succeed during publish of %s: %s",
                            invoice_literal,
                            charge_result,
                        )
            except Exception as _charge_exc:
                logger.error("Error submitting charge during publish of %s: %s", invoice_literal, _charge_exc)

    invoice_crud.write_activity(
        db=db,
        invoice_id=invoice.id,
        activity_type=InvoiceActivityTypes.INVOICE_PUBLISHED,
        description=f"Invoice published (status → {new_status.name})",
        actor_type="merchant",
        actor_id=getattr(current_user, "id", None),
    )
    db.commit()
    db.refresh(invoice)

    if not charge_succeeded and payload and payload.send:
        from src.apps.invoices.tasks.send_invoice import send_invoice_task
        send_invoice_task.delay(invoice.id)

    # Emit invoice.created Kafka event
    try:
        from src.events.base import BaseEvent
        from src.events.dispatcher import EventDispatcher
        await EventDispatcher.dispatch(
            BaseEvent(
                event_type="invoice.created",
                data={
                    "invoice_id": invoice.id,
                    "invoice_literal": invoice.invoice_literal,
                    "merchant_id": invoice.merchant_id,
                    "customer_id": invoice.customer_id,
                    "amount": float(invoice.amount or 0),
                    "currency": getattr(invoice.payment_request, "currency", "USD") if invoice.payment_request else "USD",
                    "due_date": invoice.due_date.isoformat() if invoice.due_date else None,
                    "payment_request_id": invoice.payment_request_id,
                    "hpp_token": hpp_token,
                    "actor_type": "merchant",
                    "actor_id": getattr(current_user, "id", None),
                },
            )
        )
    except Exception as _evt_exc:
        logger.warning("invoice.created event dispatch failed: %s", _evt_exc)

    return {
        "data": InvoiceSchema.model_validate(invoice).model_dump(),
        "status_code": 200,
        "success": True,
        "message": "Invoice published successfully",
    }


# ─── 11. POST /invoices/{invoice_literal}/close  (HWINV-103) ─────────────────

@router.post("/{invoice_literal}/close", response_model=Dict[str, Any])
async def close_invoice_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """
    Cancel / close an invoice.  Blocked for PAID and AUTHORIZED invoices.

    HWINV-103
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    if invoice.status in _IMMUTABLE_STATUSES:
        raise APIException(
            message="Paid or authorized invoices cannot be closed.",
            status_code=400,
        )

    invoice.status = InvoiceStatusTypes.CANCELLED
    invoice.updated_at = datetime.now(timezone.utc)
    db.flush()

    invoice_crud.write_activity(
        db=db,
        invoice_id=invoice.id,
        activity_type=InvoiceActivityTypes.INVOICE_CLOSED,
        description="Invoice closed by merchant",
        actor_type="merchant",
        actor_id=getattr(current_user, "id", None),
    )
    db.commit()
    db.refresh(invoice)

    return {
        "data": InvoiceSchema.model_validate(invoice).model_dump(),
        "status_code": 200,
        "success": True,
        "message": "Invoice closed successfully",
    }


# ─── 12. POST /invoices/{invoice_literal}/send  (HWINV-202) ──────────────────

@router.post("/{invoice_literal}/send", response_model=Dict[str, Any])
async def send_invoice_endpoint(
    payload: InvoiceSendRequest,
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """
    Manually trigger invoice delivery. Draft invoices are blocked.

    HWINV-202
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    # Auto-publish draft invoices before sending
    if invoice.status == InvoiceStatusTypes.DRAFT:
        if not invoice.amount or invoice.amount <= 0:
            raise APIException(message="Invoice amount must be greater than zero before sending.", status_code=400)
        if not invoice.customer_id:
            raise APIException(message="A customer must be assigned before sending.", status_code=400)
        now = datetime.now(timezone.utc)
        invoice.status = InvoiceStatusTypes.PENDING if invoice.payment_request_id else InvoiceStatusTypes.CREATED
        invoice.updated_at = now
        db.flush()
        invoice_crud.write_activity(
            db=db,
            invoice_id=invoice.id,
            activity_type="invoice.published",
            description="Invoice auto-published on send.",
            actor_id=current_user.id if current_user else None,
        )

    from src.apps.invoices.tasks.send_invoice import send_invoice_task
    send_invoice_task.delay(invoice.id, payload.channels, payload.message)
    db.commit()

    return {
        "data": {"queued": True, "invoice_literal": invoice_literal},
        "status_code": 200,
        "success": True,
        "message": "Invoice send has been queued",
    }


# ─── 13. GET /invoices/{invoice_literal}/pdf  (HWINV-201) ────────────────────

@router.get("/{invoice_literal}/pdf")
async def get_invoice_pdf_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> StreamingResponse:
    """
    Stream the invoice as a PDF download.

    HWINV-201
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    from src.apps.invoices.helpers.pdf_generator import InvoicePDFGenerator

    gen = InvoicePDFGenerator()
    pdf_bytes = gen.generate_pdf(
        invoice=invoice,
        merchant=invoice.merchant,
        customer=invoice.customer,
        line_items=invoice.invoice_line_items or [],
    )

    invoice_crud.write_activity(
        db=db,
        invoice_id=invoice.id,
        activity_type=InvoiceActivityTypes.INVOICE_PDF_GENERATED,
        description="Invoice PDF downloaded",
        actor_type="merchant",
        actor_id=None,
    )
    db.commit()

    filename = f"{invoice_literal}.pdf"

    import io
    return StreamingResponse(
        content=io.BytesIO(pdf_bytes),
        media_type="application/pdf",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


# ─── 14. GET /invoices/{invoice_literal}/activities  (HWINV-203) ─────────────

@router.get("/{invoice_literal}/activities", response_model=Dict[str, Any])
async def get_invoice_activities_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    page: int = Query(default=1, ge=1),
    per_page: int = Query(default=20, ge=1, le=100),
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """
    Paginated activity log for an invoice.

    HWINV-203
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    activities, total = invoice_crud.list_invoice_activities(db, invoice.id, page, per_page)

    # Batch-resolve user names for merchant-actor activities
    from sqlalchemy import select as _sa_select
    from src.apps.users.models.user import User as _User

    merchant_actor_ids = {
        a.actor_id for a in activities if a.actor_type == "merchant" and a.actor_id
    }
    user_name_map: dict = {}
    if merchant_actor_ids:
        user_rows = db.execute(
            _sa_select(_User.id, _User.full_name, _User.username).where(
                _User.id.in_(merchant_actor_ids)
            )
        ).all()
        user_name_map = {row.id: (row.full_name or row.username) for row in user_rows}

    items = [
        {
            "id": a.id,
            "invoice_id": a.invoice_id,
            "activity_type": a.activity_type,
            "description": a.description,
            "actor_type": a.actor_type,
            "actor_id": a.actor_id,
            "actor_name": (
                user_name_map.get(a.actor_id, merchant.name)
                if a.actor_type == "merchant"
                else merchant.name
                if a.actor_type == "system"
                else None
            ),
            "metadata": a.metadata_,
            "created_at": a.created_at,
        }
        for a in activities
    ]

    return {
        "data": {
            "result": items,
            "total": total,
            "page": page,
            "per_page": per_page,
        },
        "status_code": 200,
        "success": True,
        "message": "Invoice activities fetched successfully",
    }


# ─── 15. GET /invoices/{invoice_literal}/reminders  (HWINV-301) ──────────────

@router.get("/{invoice_literal}/reminders", response_model=Dict[str, Any])
async def get_invoice_reminders_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """List all reminders for an invoice. HWINV-301."""
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    reminders = invoice_crud.get_reminders_for_invoice(db, invoice.id, merchant.id)
    serialized = [InvoiceReminderSchema.model_validate(r) for r in reminders]

    return {
        "data": [r.model_dump() for r in serialized],
        "status_code": 200,
        "success": True,
        "message": "Reminders fetched successfully",
    }


# ─── 16. POST /invoices/{invoice_literal}/reminders  (HWINV-301) ─────────────

@router.post("/{invoice_literal}/reminders", response_model=Dict[str, Any], status_code=201)
async def create_invoice_reminder_endpoint(
    payload: InvoiceReminderCreateRequest,
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """
    Schedule a reminder for an invoice.

    Draft invoices are blocked.
    Either 'preset' or 'scheduled_at' must be provided (mutually exclusive).

    HWINV-301
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    _assert_not_draft(invoice, "schedule reminder")

    # Resolve scheduled_at
    if payload.preset:
        scheduled_at = _resolve_preset_scheduled_at(payload.preset, invoice.due_date)
        if scheduled_at is None:
            raise APIException(
                message="Cannot resolve preset: invoice has no due_date.",
                status_code=400,
            )
    else:
        scheduled_at = payload.scheduled_at

    from src.apps.base.models.reminder import Reminder

    reminder = Reminder(
        reminder_id=generate_secure_id(prepend="rem", length=16),
        invoice_id=invoice.id,
        merchant_id=merchant.id,
        preset=payload.preset.value if payload.preset else None,
        scheduled_at=scheduled_at,
        channel=payload.channel.value,
        reminder_status=ReminderStatus.PENDING.value,
    )
    db.add(reminder)
    db.flush()

    invoice_crud.write_activity(
        db=db,
        invoice_id=invoice.id,
        activity_type=InvoiceActivityTypes.INVOICE_REMINDER_SCHEDULED,
        description=f"Reminder scheduled (channel={payload.channel.value})",
        actor_type="merchant",
        actor_id=getattr(current_user, "id", None),
        metadata={"reminder_id": reminder.reminder_id, "scheduled_at": scheduled_at.isoformat() if scheduled_at else None},
    )
    db.commit()
    db.refresh(reminder)

    return {
        "data": InvoiceReminderSchema.model_validate(reminder).model_dump(),
        "status_code": 201,
        "success": True,
        "message": "Reminder scheduled successfully",
    }


# ─── 17. DELETE /invoices/{invoice_literal}/reminders/{reminder_id} ──────────

@router.delete("/{invoice_literal}/reminders/{reminder_id}", response_model=Dict[str, Any])
async def delete_invoice_reminder_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    reminder_id: str = Path(..., description="reminder_id (rem_xxx) of the reminder to cancel"),
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """Cancel and soft-delete a scheduled reminder. HWINV-301."""
    from sqlalchemy import select
    from src.apps.base.models.reminder import Reminder

    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    reminder = db.execute(
        select(Reminder).where(
            Reminder.reminder_id == reminder_id,
            Reminder.invoice_id == invoice.id,
            Reminder.merchant_id == merchant.id,
            Reminder.deleted_at == None,
        )
    ).scalar_one_or_none()

    if reminder is None:
        raise NotFoundError(message="Reminder not found.")

    now = datetime.now(timezone.utc)
    reminder.reminder_status = ReminderStatus.CANCELLED.value
    reminder.deleted_at = now
    reminder.updated_at = now
    db.flush()
    db.commit()

    return {
        "data": {"deleted": True, "reminder_id": reminder_id},
        "status_code": 200,
        "success": True,
        "message": "Reminder cancelled successfully",
    }


# ─── 18. GET /invoices/{invoice_literal}/transactions ────────────────────────

@router.get("/{invoice_literal}/transactions", response_model=Dict[str, Any])
async def get_invoice_transactions_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """Return transactions linked to an invoice."""
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    transactions = invoice_crud.get_invoice_transactions(db, invoice.id)

    items = []
    for t in transactions:
        row = {}
        for col in t.__table__.columns:
            row[col.name] = getattr(t, col.name)
        items.append(row)

    return {
        "data": items,
        "status_code": 200,
        "success": True,
        "message": "Invoice transactions fetched successfully",
    }


# ─── 19. GET /invoices/{invoice_literal}/subscription ────────────────────────

@router.get("/{invoice_literal}/subscription", response_model=Dict[str, Any])
async def get_invoice_subscription_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """Return the subscription linked to an invoice (if any)."""
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    if not invoice.subscription_id:
        return {
            "data": None,
            "status_code": 200,
            "success": True,
            "message": "No subscription linked to this invoice",
        }

    from src.apps.subscriptions.models.subscription import Subscription
    from src.apps.subscriptions import services as sub_svc
    from sqlalchemy import select as sa_select

    sub = db.execute(
        sa_select(Subscription).where(Subscription.id == invoice.subscription_id)
    ).scalar_one_or_none()

    if not sub:
        return {
            "data": None,
            "status_code": 200,
            "success": True,
            "message": "Subscription not found",
        }

    return {
        "data": sub_svc.build_list_item(sub),
        "status_code": 200,
        "success": True,
        "message": "Invoice subscription fetched successfully",
    }


# ─── 20. GET /invoices/{invoice_literal}/authorizations  (HWINV-205) ─────────

@router.get("/{invoice_literal}/authorizations", response_model=Dict[str, Any])
async def get_invoice_authorizations_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """
    List authorization records for an invoice.

    HWINV-205
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    authorizations = invoice_crud.get_invoice_authorizations(db, invoice.id, merchant_id=merchant.id)

    items = []
    for a in authorizations:
        row = {}
        for col in a.__table__.columns:
            val = getattr(a, col.name)
            # Use the hybrid property for auth_metadata to ensure correct
            # deserialization regardless of whether the DB stores it as
            # a JSON string or a dict.
            if col.name == "auth_metadata":
                val = a.additional_info
            row[col.name] = val
        items.append(row)

    return {
        "data": items,
        "status_code": 200,
        "success": True,
        "message": "Invoice authorizations fetched successfully",
    }


# ─── 20. GET /invoices/{invoice_literal}/authorizations/pdf  (HWINV-205) ─────

@router.get("/{invoice_literal}/authorizations/pdf")
async def get_authorization_certificate_pdf_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> StreamingResponse:
    """
    Stream the authorization certificate as a PDF download.

    HWINV-205
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    authorizations = invoice_crud.get_invoice_authorizations(db, invoice.id, merchant_id=merchant.id)
    if not authorizations:
        raise NotFoundError(message="No authorization found for this invoice.")

    auth = authorizations[0]

    from src.apps.invoices.helpers.auth_certificate import (
        AuthCertificatePDFGenerator,
        generate_cert_hash,
    )

    authorized_at = getattr(auth, "authorization_date", None) or getattr(auth, "created_at", None) or datetime.now(timezone.utc)
    auth_type = getattr(auth, "authorization_type", "UNKNOWN") or "UNKNOWN"

    cert_hash = generate_cert_hash(
        invoice_literal=invoice.invoice_literal or invoice.invoice_id,
        authorized_at=authorized_at if authorized_at.tzinfo else authorized_at.replace(tzinfo=timezone.utc),
        authorization_type=str(auth_type),
        merchant_id=merchant.id,
    )

    gen = AuthCertificatePDFGenerator()
    pdf_bytes = gen.generate_pdf(
        invoice=invoice,
        merchant=invoice.merchant,
        customer=invoice.customer,
        authorization=auth,
        cert_hash=cert_hash,
    )

    import io
    return StreamingResponse(
        content=io.BytesIO(pdf_bytes),
        media_type="application/pdf",
        headers={"Content-Disposition": f'attachment; filename="{invoice_literal}_auth_cert.pdf"'},
    )


# ─── 21. GET /invoices/{invoice_literal}/attachments  (HWINV-105) ────────────

@router.get("/{invoice_literal}/attachments", response_model=Dict[str, Any])
async def get_invoice_attachments_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """List file attachments for an invoice. HWINV-105."""
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    attachments = invoice_crud.get_invoice_attachments(db, invoice.id)

    from src.apps.files.schemas.file_common import FileResponseSchema
    items = [FileResponseSchema.model_validate(f).model_dump() for f in attachments]

    return {
        "data": items,
        "status_code": 200,
        "success": True,
        "message": "Invoice attachments fetched successfully",
    }


# ─── 22. POST /invoices/{invoice_literal}/attachments  (HWINV-105) ───────────

@router.post("/{invoice_literal}/attachments", response_model=Dict[str, Any], status_code=201)
async def add_invoice_attachment_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    body: AttachmentAddRequest = ...,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """
    Attach an already-uploaded file to an invoice.

    HWINV-105: The file must have been uploaded via POST /files first.
    The file must be owned by the authenticated merchant (via created_by_id
    on the File record matching the current user, who belongs to the merchant).
    """
    file_id = body.file_id

    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    from sqlalchemy import select, insert
    from src.apps.files.models.file import File
    from src.apps.invoices.models.invoice import invoice_attachments_map

    # Ownership check: the file must have been created by a user belonging to
    # the authenticated merchant. File.created_by_id references users.id; we
    # validate it matches the current user (who was authenticated under merchant).
    file_record = db.execute(
        select(File).where(
            File.id == file_id,
            File.created_by_id == current_user.id,
        )
    ).scalar_one_or_none()
    if file_record is None:
        raise NotFoundError(message="File not found.")

    # Check not already attached
    existing = db.execute(
        select(invoice_attachments_map).where(
            invoice_attachments_map.c.invoice_id == invoice.id,
            invoice_attachments_map.c.file_id == file_id,
        )
    ).first()

    if existing is None:
        db.execute(
            insert(invoice_attachments_map).values(invoice_id=invoice.id, file_id=file_id)
        )
        db.flush()

        invoice_crud.write_activity(
            db=db,
            invoice_id=invoice.id,
            activity_type=InvoiceActivityTypes.INVOICE_ATTACHMENT_ADDED,
            description=f"File id={file_id} attached",
            actor_type="merchant",
            actor_id=getattr(current_user, "id", None),
            metadata={"file_id": file_id},
        )
        db.commit()

    return {
        "data": {"attached": True, "file_id": file_id},
        "status_code": 201,
        "success": True,
        "message": "Attachment added successfully",
    }


# ─── 23. DELETE /invoices/{invoice_literal}/attachments/{file_id}  (HWINV-105)

@router.delete("/{invoice_literal}/attachments/{file_id}", response_model=Dict[str, Any])
async def remove_invoice_attachment_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    file_id: int = Path(..., description="ID of the File record to detach"),
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """Remove an attachment from an invoice. HWINV-105."""
    from sqlalchemy import delete as sql_delete
    from src.apps.invoices.models.invoice import invoice_attachments_map

    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    # Remove from invoice_attachments_map
    db.execute(
        sql_delete(invoice_attachments_map).where(
            invoice_attachments_map.c.invoice_id == invoice.id,
            invoice_attachments_map.c.file_id == file_id,
        )
    )

    # Also remove from payment_request.attachments (payment_requests_files) if linked
    if invoice.payment_request_id:
        from src.apps.payment_requests.models.payment_request import payment_request_attachments_map
        db.execute(
            sql_delete(payment_request_attachments_map).where(
                payment_request_attachments_map.c.payment_request_id == invoice.payment_request_id,
                payment_request_attachments_map.c.file_id == file_id,
            )
        )

    db.flush()

    invoice_crud.write_activity(
        db=db,
        invoice_id=invoice.id,
        activity_type=InvoiceActivityTypes.INVOICE_ATTACHMENT_REMOVED,
        description=f"File id={file_id} detached",
        actor_type="merchant",
        actor_id=getattr(current_user, "id", None),
        metadata={"file_id": file_id},
    )
    db.commit()

    return {
        "data": {"removed": True, "file_id": file_id},
        "status_code": 200,
        "success": True,
        "message": "Attachment removed successfully",
    }


# ─── 24. POST /invoices/{invoice_literal}/duplicate  (HWINV-304) ─────────────

@router.post("/{invoice_literal}/duplicate", response_model=Dict[str, Any], status_code=201)
async def duplicate_invoice_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """
    Duplicate an invoice.

    - New invoice has status=DRAFT with a fresh literal.
    - Due date is recalculated: now + (original_due_date - original_billing_date).
    - Line items are copied.
    - Emits invoice.duplicated activity on the original and invoice.draft_saved on the new.

    HWINV-304
    """
    original = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if original is None:
        raise NotFoundError(message="Invoice not found.")

    now = datetime.now(timezone.utc)

    # Calculate offset
    new_due_date: Optional[datetime] = None
    if original.due_date and original.billing_date:
        days_delta = (original.due_date - original.billing_date).days
        new_due_date = now + timedelta(days=days_delta)
    elif original.due_date:
        new_due_date = now + timedelta(days=30)  # default 30 days from today

    # Create a fresh stub PaymentRequest for the duplicate so it does NOT share
    # the original's payment_request_id (sharing would allow cross-invoice payment
    # linkage and is a security/integrity risk).
    from src.apps.payment_requests.models.payment_request import PaymentRequest
    from src.apps.payment_requests.enums import PaymentRequestStatusTypes

    original_currency = "usd"
    if original.payment_request and hasattr(original.payment_request, "currency"):
        original_currency = original.payment_request.currency or "usd"

    original_auth_type = None
    original_payment_freq = None
    if original.payment_request:
        original_auth_type = getattr(original.payment_request, "authorization_type", None)
        original_payment_freq = getattr(original.payment_request, "payment_frequency", None)

    stub_pr = PaymentRequest(
        payment_request_id=generate_secure_id(prepend="pr", length=20),
        merchant_id=original.merchant_id,
        amount=original.amount,
        status=PaymentRequestStatusTypes.DRAFT,
        currency=original_currency,
        created_by_id=current_user.id,
        authorization_type=original_auth_type,
        payment_frequency=original_payment_freq,
    )
    db.add(stub_pr)
    db.flush()

    from src.apps.invoices.services.invoice_services import create_invoice
    new_invoice = create_invoice(
        db=db,
        payment_request=stub_pr,
        merchant_id=merchant.id,
        customer_id=original.customer_id,
        amount=original.amount,
        status=InvoiceStatusTypes.DRAFT,
        payer_id=original.payer_id,
        approver_id=original.approver_id,
        due_date=new_due_date,
        billing_date=now,
        reference=original.reference,
        shipping_fee=original.shipping_fee or 0.0,
        tax_fee=original.tax_fee or 0.0,
        is_surcharge_enabled=original.is_surcharge_enabled,
        surcharge_type=original.surcharge_type,
        comments=original.comments,
    )

    # Copy line items
    from src.apps.invoices.models.invoice_line_items import InvoiceLineItems
    for li in original.invoice_line_items or []:
        new_li = InvoiceLineItems(
            invoice_id=new_invoice.id,
            title=li.title,
            description=li.description,
            unit_price=li.unit_price,
            quantity=li.quantity,
            tax=li.tax,
            display_order=li.display_order,
            discount=li.discount,
            discount_type=li.discount_type,
            cost=li.cost,
            upcharge=li.upcharge,
            product_id=li.product_id,
        )
        db.add(new_li)

    db.flush()

    # Activities
    invoice_crud.write_activity(
        db=db,
        invoice_id=original.id,
        activity_type=InvoiceActivityTypes.INVOICE_DUPLICATED,
        description=f"Invoice duplicated → {new_invoice.invoice_literal}",
        actor_type="merchant",
        actor_id=getattr(current_user, "id", None),
        metadata={"new_invoice_literal": new_invoice.invoice_literal},
    )
    invoice_crud.write_activity(
        db=db,
        invoice_id=new_invoice.id,
        activity_type=InvoiceActivityTypes.INVOICE_DRAFT_SAVED,
        description=f"Draft created as duplicate of {invoice_literal}",
        actor_type="merchant",
        actor_id=getattr(current_user, "id", None),
        metadata={"source_invoice_literal": invoice_literal},
    )
    db.commit()
    db.refresh(new_invoice)

    return {
        "data": InvoiceSchema.model_validate(new_invoice).model_dump(),
        "status_code": 201,
        "success": True,
        "message": "Invoice duplicated successfully",
    }


# ─── 25. GET /invoices/{invoice_literal}/analytics  (HWINV-302) ──────────────

@router.get("/{invoice_literal}/analytics", response_model=Dict[str, Any])
async def get_invoice_analytics_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """
    Per-invoice analytics: revenue, cost, profit, margin, transaction summary.

    HWINV-302
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    # Transaction summary — revenue is actual money collected across all transactions
    # (supports split payments and recurring instalments)
    transactions = invoice_crud.get_invoice_transactions(db, invoice.id)
    txn_count = len(transactions)
    revenue = round(sum(float(getattr(t, "txn_amount", 0) or 0) for t in transactions), 2)
    avg_transaction = round(revenue / txn_count if txn_count > 0 else 0.0, 2)

    # Cost = sum of (cost_per_unit × qty) for each line item.
    # Priority: item.cost > linked product.unit_price > 0.
    # Never fall back to item.unit_price — that is the sell price, not cost.
    line_items = getattr(invoice, "invoice_line_items", []) or []
    cost = round(
        sum(
            float(
                item.cost
                if item.cost
                else (
                    (item.product.unit_price or 0.0)
                    if item.product_id and item.product
                    else 0.0
                )
            )
            * int(item.quantity or 1)
            for item in line_items
        ),
        2,
    )

    # Profit = Revenue - Cost
    profit = round(revenue - cost, 2)

    return {
        "data": {
            "invoice_literal": invoice_literal,
            "revenue": revenue,
            "cost": cost,
            "profit": profit,
            "transaction_count": txn_count,
            "average_transaction": avg_transaction,
        },
        "status_code": 200,
        "success": True,
        "message": "Invoice analytics fetched successfully",
    }


# ─── 26. GET /invoices/{invoice_literal}/hpp-link  (HWINV-204) ───────────────

@router.get("/{invoice_literal}/hpp-link", response_model=Dict[str, Any])
async def get_hpp_link_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
) -> Dict[str, Any]:
    """
    Return the Hosted Payment Page link for an invoice.

    Validates that the payment_request.authorization_type == REQUEST_AUTHORIZATION.

    HWINV-204
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    _assert_not_draft(invoice, "get HPP link")

    from src.apps.payment_requests.enums import PaymentAuthorizationTypes
    pr = invoice.payment_request
    if pr is None:
        raise APIException(message="This invoice has no associated payment request.", status_code=400)

    if getattr(pr, "authorization_type", None) != PaymentAuthorizationTypes.REQUEST_AUTH:
        raise APIException(
            message="HPP links are only available for invoices with authorization_type=request_auth.",
            status_code=400,
        )

    # Find the active PENDING link (same pattern as hpp/services.py)
    links = invoice.payment_links or []
    active_link = next((lnk for lnk in links if lnk.status == "PENDING" and lnk.token), None)

    # If no active link exists, generate one on-the-fly (invoice published before
    # HPP link creation was wired up, or all previous links expired/used/revoked).
    if not active_link:
        import secrets
        from src.apps.payment_requests.models.payment_request_links import PaymentRequestLinks

        now = datetime.now(timezone.utc)
        active_link = PaymentRequestLinks(
            token=secrets.token_urlsafe(32),
            status="PENDING",
            is_expired=False,
            payment_request_id=pr.id,
            invoice_id=invoice.id,
            start_date=now,
            end_date=now + timedelta(hours=settings.HPP_LINK_EXPIRY_HOURS),
        )
        db.add(active_link)
        db.commit()
        db.refresh(active_link)

    hpp_url = f"{settings.hpp_frontend_base_url}/hpp?token={active_link.token}"

    return {
        "data": {"hpp_url": hpp_url, "invoice_literal": invoice_literal},
        "status_code": 200,
        "success": True,
        "message": "HPP link fetched successfully",
    }


# ─── 27. POST /invoices/{invoice_literal}/resend-hpp-link  (HWINV-204) ───────

@router.post("/{invoice_literal}/resend-hpp-link", response_model=Dict[str, Any])
async def resend_hpp_link_endpoint(
    invoice_literal: str = _INVOICE_LITERAL_PATH,
    db: Session = Depends(get_db),
    merchant: MerchantSchema = Depends(get_current_merchant),
    current_user=Depends(get_current_active_user),
) -> Dict[str, Any]:
    """
    Resend the HPP link to the invoice customer.

    HWINV-204
    """
    invoice = invoice_crud.get_invoice_by_literal(db, invoice_literal, merchant.id)
    if invoice is None:
        raise NotFoundError(message="Invoice not found.")

    _assert_not_draft(invoice, "resend HPP link")

    # Enqueue notification
    from src.apps.invoices.tasks.send_invoice import send_invoice_task
    send_invoice_task.delay(invoice.id)

    invoice_crud.write_activity(
        db=db,
        invoice_id=invoice.id,
        activity_type=InvoiceActivityTypes.INVOICE_SENT,
        description="HPP link resent",
        actor_type="merchant",
        actor_id=getattr(current_user, "id", None),
    )
    db.commit()

    return {
        "data": {"queued": True},
        "status_code": 200,
        "success": True,
        "message": "HPP link resend has been queued",
    }


# Duplicate GET /{invoice_literal}/pdf removed — canonical endpoint is at line 1163 above.
