"""Staff-uploaded proposal document service — INDL-56.

Handles a staff-supplied proposal PDF attached to a proposal (surfaced in the
Opportunity drawer once the linked opportunity reaches proposal_accepted or
later). This is a distinct artifact from ``Proposal.pdf_s3_key`` (the
system-generated proposal PDF produced when the proposal is sent) — see
``proposals.uploaded_document_s3_key``. Mirrors
``contract_document_service.py``'s pattern for the signed contract document.
"""
from __future__ import annotations

import asyncio
import logging
import uuid
from datetime import datetime, timezone
from uuid import UUID

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

from src.core.config import settings
from src.core.exceptions import NotFoundError, ValidationError
from src.apps.sales.models.proposal import Proposal

logger = logging.getLogger(__name__)

MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024  # 20 MB
ALLOWED_MIME_TYPE = "application/pdf"
PDF_MAGIC_BYTES = b"%PDF"


def _make_s3_client():
    return boto3.client(
        "s3",
        region_name=settings.AWS_REGION,
        aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
        aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
    )


def _build_uploaded_document_key(tenant_id: UUID, proposal_id: UUID) -> str:
    return f"tenant/{tenant_id}/proposals/{proposal_id}/uploaded/{uuid.uuid4()}.pdf"


class ProposalDocumentService:

    @staticmethod
    async def upload_document(
        db: AsyncSession,
        tenant_id: str,
        proposal_id: UUID,
        current_user,
        file_content: bytes,
        filename: str,
        mime_type: str,
    ) -> Proposal:
        """Proxy-upload the staff-supplied proposal PDF to S3.

        Rejects if the file is not a PDF (checked by both declared
        content-type and sniffed magic bytes) or if it exceeds the 20 MB
        cap. Replaces (and deletes) any prior uploaded document for this
        proposal.

        Deliberately does not gate on ``proposal.status`` — the drawer only
        shows the upload control once the linked *opportunity* has reached
        proposal_accepted or later, but staff can advance an opportunity's
        stage independently of calling the Proposal's own accept endpoint
        (e.g. via the drawer's stage dropdown), so the two can legitimately
        be out of sync. Mirrors the opportunity-stage-based gating already
        applied client-side rather than re-deriving it from Proposal.status.
        """
        locked = await db.execute(
            select(Proposal)
            .where(
                Proposal.id == proposal_id,
                Proposal.tenant_id == tenant_id,
                Proposal.deleted_at.is_(None),
            )
            .with_for_update()
        )
        proposal = locked.scalar_one_or_none()
        if not proposal:
            raise NotFoundError("Proposal not found.")

        if mime_type != ALLOWED_MIME_TYPE:
            raise ValidationError(
                message=f"Unsupported file type '{mime_type}'. Only PDF files are allowed."
            )

        if not file_content.startswith(PDF_MAGIC_BYTES):
            raise ValidationError(
                message="File content does not look like a valid PDF."
            )

        if len(file_content) > MAX_FILE_SIZE_BYTES:
            raise ValidationError(message="File size exceeds the 20 MB limit.")

        s3 = _make_s3_client()
        prior_key = proposal.uploaded_document_s3_key

        new_key = _build_uploaded_document_key(tenant_id, proposal.id)
        await asyncio.to_thread(
            s3.put_object,
            Bucket=settings.S3_BUCKET,
            Key=new_key,
            Body=file_content,
            ContentType=ALLOWED_MIME_TYPE,
        )

        proposal.uploaded_document_s3_key = new_key
        proposal.uploaded_document_uploaded_at = datetime.now(timezone.utc)
        proposal.uploaded_document_uploaded_by = current_user.id
        await db.flush()
        logger.info(
            "[upload_document] Proposal %s document %s by user %s.",
            proposal.id,
            "replaced" if prior_key else "uploaded",
            current_user.id,
        )

        if prior_key:
            try:
                await asyncio.to_thread(
                    s3.delete_object,
                    Bucket=settings.S3_BUCKET,
                    Key=prior_key,
                )
            except Exception as exc:  # noqa: BLE001
                logger.warning(
                    "[upload_document] Failed to delete prior document for "
                    "proposal %s (key=%s): %s",
                    proposal.id,
                    prior_key,
                    exc,
                )

        return proposal

    @staticmethod
    async def get_document_url(
        db: AsyncSession,
        tenant_id: str,
        proposal_id: UUID,
        current_user,
    ) -> str:
        """Return a short-lived presigned S3 GET URL for the uploaded proposal document."""
        result = await db.execute(
            select(Proposal).where(
                Proposal.id == proposal_id,
                Proposal.tenant_id == tenant_id,
                Proposal.deleted_at.is_(None),
            )
        )
        proposal = result.scalar_one_or_none()
        if not proposal or not proposal.uploaded_document_s3_key:
            raise NotFoundError("Uploaded document not found.")

        s3 = _make_s3_client()
        presigned_url: str = await asyncio.to_thread(
            s3.generate_presigned_url,
            "get_object",
            Params={
                "Bucket": settings.S3_BUCKET,
                "Key": proposal.uploaded_document_s3_key,
            },
            ExpiresIn=600,
        )
        return presigned_url
