from __future__ import annotations

import logging
from datetime import datetime, timedelta, timezone
from decimal import ROUND_HALF_UP, Decimal
from uuid import UUID

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

from src.core.constants import OpportunityStage, ProposalStatus
from src.core.exceptions import ConflictError, NotFoundError, UnprocessableError
from src.apps.sales.models.proposal import Proposal, ProposalLineItem
from src.apps.sales.models.opportunity import Opportunity

logger = logging.getLogger(__name__)


class ProposalService:

    @staticmethod
    async def create(
        db: AsyncSession,
        tenant_id: str,
        data: dict,
        current_user,
    ) -> Proposal:
        line_items_data = data.pop("line_items", [])

        # Generate quote number: P-{YEAR}-{4-digit-seq}
        year = datetime.now(timezone.utc).year
        count = (
            await db.execute(
                select(func.count()).select_from(Proposal).where(
                    Proposal.tenant_id == tenant_id
                )
            )
        ).scalar_one()
        quote_number = f"P-{year}-{(count + 1):04d}"

        # Compute totals
        subtotal = sum(
            (Decimal(str(item["quantity"])) * Decimal(str(item["unit_price"])))
            for item in line_items_data
        ) if line_items_data else Decimal("0")
        tax_rate = Decimal("0.13")
        tax_amount = (subtotal * tax_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        total_amount = subtotal + tax_amount

        proposal = Proposal(
            tenant_id=tenant_id,
            quote_number=quote_number,
            status=ProposalStatus.DRAFT.value,
            subtotal=subtotal,
            tax_rate=tax_rate,
            tax_amount=tax_amount,
            total_amount=total_amount,
            **data,
        )
        db.add(proposal)
        await db.flush()

        for idx, item in enumerate(line_items_data):
            qty = Decimal(str(item["quantity"]))
            unit_price = Decimal(str(item["unit_price"]))
            line_total = qty * unit_price
            li = ProposalLineItem(
                proposal_id=proposal.id,
                tenant_id=tenant_id,
                sort_order=idx,
                description=item["description"],
                quantity=qty,
                unit_price=unit_price,
                line_total=line_total,
            )
            db.add(li)

        await db.flush()
        result = await db.execute(
            select(Proposal)
            .options(selectinload(Proposal.line_items))
            .where(Proposal.id == proposal.id)
        )
        return result.scalar_one()

    @staticmethod
    async def get_by_id(
        db: AsyncSession,
        tenant_id: str,
        proposal_id: UUID,
    ) -> Proposal:
        result = await db.execute(
            select(Proposal)
            .options(selectinload(Proposal.line_items))
            .where(
                Proposal.id == proposal_id,
                Proposal.tenant_id == tenant_id,
                Proposal.deleted_at.is_(None),
            )
        )
        proposal = result.scalar_one_or_none()
        if not proposal:
            raise NotFoundError("Proposal not found")
        return proposal

    @staticmethod
    async def _dispatch_proposal(db: AsyncSession, tenant_id, proposal, trigger) -> None:
        """Render + send a proposal through the templated dispatch engine (INDL-54)."""
        from src.apps.tenants.models.account import Account
        from src.core.email_dispatch import cemetery_context, email_dispatch_service

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

        subtotal = float(proposal.subtotal or 0)
        total = float(proposal.total_amount or 0)
        tax_amount = total - subtotal
        line_items_text = "\n".join(
            f"  {li.description} x {int(li.quantity)}  =  ${float(li.quantity * li.unit_price):,.2f}"
            for li in proposal.line_items
        )
        expiry_note = (
            f"This quote expires on {proposal.expires_at.strftime('%B %d, %Y')}."
            if proposal.expires_at
            else f"Valid for {proposal.valid_days} days."
        )
        await email_dispatch_service.send(
            db,
            trigger_key=trigger,
            tenant_id=tenant_id,
            to=proposal.to_email,
            context={
                "cover_note": proposal.cover_note or "",
                "quote_number": proposal.quote_number or "",
                "line_items": line_items_text,
                "subtotal": f"${subtotal:,.2f}",
                "tax_amount": f"${tax_amount:,.2f}",
                "total_amount": f"${total:,.2f}",
                "expiry_note": expiry_note,
                **cemetery_context(account),
            },
            cc=proposal.cc_email or None,
        )

    @staticmethod
    async def send(
        db: AsyncSession,
        tenant_id: str,
        proposal_id: UUID,
        current_user,
    ) -> Proposal:
        proposal = await ProposalService.get_by_id(db, tenant_id, proposal_id)

        if proposal.status != ProposalStatus.DRAFT.value:
            raise ConflictError("Proposal has already been sent")

        now = datetime.now(timezone.utc)
        proposal.status = ProposalStatus.SENT.value
        proposal.sent_at = now
        proposal.expires_at = now + timedelta(days=proposal.valid_days)

        # Advance linked opportunity to proposal_sent
        if proposal.opportunity_id:
            result = await db.execute(
                select(Opportunity).where(
                    Opportunity.id == proposal.opportunity_id,
                    Opportunity.tenant_id == tenant_id,
                    Opportunity.deleted_at.is_(None),
                )
            )
            opp = result.scalar_one_or_none()
            if opp:
                opp.stage = OpportunityStage.PROPOSAL_SENT.value
                opp.stage_entered_at = now

        # PDF generation — best-effort; do not fail the send if S3/SES unavailable
        try:
            from src.apps.sales.pdf.generator import generate_and_store_proposal_pdf
            pdf_key = await generate_and_store_proposal_pdf(proposal, db, tenant_id)
            if pdf_key:
                proposal.pdf_s3_key = pdf_key
        except Exception as exc:
            logger.warning("Proposal PDF generation skipped: %s", exc)

        await db.flush()
        result = await db.execute(
            select(Proposal)
            .options(selectinload(Proposal.line_items))
            .where(Proposal.id == proposal.id)
        )
        proposal = result.scalar_one()

        # Send proposal email via the dispatch engine (fail-open — never blocks the send)
        from src.core.constants import EmailTriggerKey
        await ProposalService._dispatch_proposal(
            db, tenant_id, proposal, EmailTriggerKey.PROPOSAL_SENT
        )
        logger.info("Proposal email dispatched: proposal=%s to=%s", proposal.id, proposal.to_email)

        return proposal

    @staticmethod
    async def mark_accepted(
        db: AsyncSession,
        tenant_id: str,
        proposal_id: UUID,
        current_user,
    ) -> Proposal:
        proposal = await ProposalService.get_by_id(db, tenant_id, proposal_id)

        if proposal.status != ProposalStatus.SENT.value:
            raise UnprocessableError("Only sent proposals can be accepted")

        now = datetime.now(timezone.utc)
        proposal.status = ProposalStatus.ACCEPTED.value
        proposal.accepted_at = now

        # Advance linked opportunity to proposal_accepted (not contract_signed)
        if proposal.opportunity_id:
            result = await db.execute(
                select(Opportunity).where(
                    Opportunity.id == proposal.opportunity_id,
                    Opportunity.tenant_id == tenant_id,
                    Opportunity.deleted_at.is_(None),
                )
            )
            opp = result.scalar_one_or_none()
            if opp:
                opp.stage = OpportunityStage.PROPOSAL_ACCEPTED.value
                opp.stage_entered_at = now

        await db.flush()
        result = await db.execute(
            select(Proposal)
            .options(selectinload(Proposal.line_items))
            .where(Proposal.id == proposal.id)
        )
        return result.scalar_one()

    @staticmethod
    async def decline(
        db: AsyncSession,
        tenant_id: str,
        proposal_id: UUID,
        current_user,
    ) -> Proposal:
        proposal = await ProposalService.get_by_id(db, tenant_id, proposal_id)

        if proposal.status != ProposalStatus.SENT.value:
            raise UnprocessableError("Only sent proposals can be declined")

        now = datetime.now(timezone.utc)
        proposal.status = ProposalStatus.DECLINED.value
        proposal.declined_at = now

        await db.flush()
        result = await db.execute(
            select(Proposal)
            .options(selectinload(Proposal.line_items))
            .where(Proposal.id == proposal.id)
        )
        return result.scalar_one()

    @staticmethod
    async def resend(
        db: AsyncSession,
        tenant_id: str,
        proposal_id: UUID,
        current_user,
    ) -> Proposal:
        proposal = await ProposalService.get_by_id(db, tenant_id, proposal_id)

        # Cannot resend if expired or declined
        now = datetime.now(timezone.utc)
        is_expired = (
            proposal.status == ProposalStatus.SENT.value
            and proposal.expires_at is not None
            and proposal.expires_at.replace(tzinfo=timezone.utc if proposal.expires_at.tzinfo is None else proposal.expires_at.tzinfo) < now
        )

        if proposal.status == ProposalStatus.DECLINED.value or is_expired:
            raise UnprocessableError("Cannot resend a proposal that is expired or declined")

        if proposal.status != ProposalStatus.SENT.value:
            raise UnprocessableError("Cannot resend a proposal that is not in sent state")

        # Regenerate PDF if missing
        if not proposal.pdf_s3_key:
            try:
                from src.apps.sales.pdf.generator import generate_and_store_proposal_pdf
                pdf_key = await generate_and_store_proposal_pdf(proposal, db, tenant_id)
                if pdf_key:
                    proposal.pdf_s3_key = pdf_key
            except Exception as exc:
                logger.warning("Proposal PDF regeneration skipped on resend: %s", exc)

        # Refresh expiry
        proposal.expires_at = now + timedelta(days=proposal.valid_days)

        await db.flush()
        result = await db.execute(
            select(Proposal)
            .options(selectinload(Proposal.line_items))
            .where(Proposal.id == proposal.id)
        )
        proposal = result.scalar_one()

        # Re-send proposal email via the dispatch engine (fail-open)
        from src.core.constants import EmailTriggerKey
        await ProposalService._dispatch_proposal(
            db, tenant_id, proposal, EmailTriggerKey.PROPOSAL_RESENT
        )
        logger.info("Proposal resent email dispatched: proposal=%s to=%s", proposal.id, proposal.to_email)

        return proposal
