import logging
from datetime import datetime, time, timedelta
from io import BytesIO
from zipfile import ZIP_DEFLATED, ZipFile

from zipstream import ZipStream

from django.conf import settings
from django.db import IntegrityError, transaction
from django.http import FileResponse, Http404, HttpResponse, StreamingHttpResponse
from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view, inline_serializer
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.serializers import CharField, IntegerField, ListField
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet

from apps.boletas.models import Boleta, BoletaLog, FolioSecuencia
from apps.boletas.permissions import CanEditBoletas
from apps.boletas.serializers import (
    BoletaSerializer,
    DescargarBoletasRangoSerializer,
    EstadoHistorialSerializer,
    GenerarBoletasRequestSerializer,
    ImprimirBoletasMasivoRequestSerializer,
)
from apps.recepciones.models import RecepcionLog
from apps.boletas.tasks import enqueue_generate_pdfs, enqueue_send_emails
from apps.boletas.utils import build_folio, build_qr_text
from apps.catalogos.models import Contrato, Linea, Oficina, Productor, Zafra
from apps.usuarios.permissions import CanGenerateBoletas
from utils.audit import AuditFieldsViewSetMixin
from utils.office import extract_office_id
from utils.pagination import StandardResultsSetPagination

logger = logging.getLogger(__name__)


class HistorialEstadosView(APIView):
    """
    Historial global de cambios de estado de boletas y recepciones.

    Filtros disponibles (query params):
    - boleta_id: filtra por boleta (incluye sus logs y los de su recepción).
    - recepcion_id: filtra por recepción (incluye sus logs y los de su boleta).
    - usuario_id: filtra por usuario que realizó el cambio.
    - tipo: "boleta" | "recepcion".
    - fecha_desde / fecha_hasta: formato ISO (YYYY-MM-DD).
    """

    permission_classes = [CanEditBoletas]
    pagination_class = StandardResultsSetPagination

    @extend_schema(
        tags=["Boletas"],
        summary="Historial global de estados",
        description=(
            "Devuelve el historial de cambios de estado de boletas y recepciones, "
            "combinado y ordenado cronológicamente. Soporta filtros por boleta, "
            "recepción, usuario, tipo y rango de fechas."
        ),
        responses={200: OpenApiResponse(response=EstadoHistorialSerializer(many=True))},
    )
    def get(self, request):
        params = request.query_params

        boleta_id = params.get("boleta_id")
        recepcion_id = params.get("recepcion_id")
        usuario_id = params.get("usuario_id")
        tipo = params.get("tipo")
        fecha_desde = params.get("fecha_desde")
        fecha_hasta = params.get("fecha_hasta")

        boleta_logs = BoletaLog.objects.select_related("boleta", "usuario")
        recepcion_logs = RecepcionLog.objects.select_related("recepcion", "recepcion__boleta", "usuario")

        if boleta_id:
            boleta_logs = boleta_logs.filter(boleta_id=boleta_id)
            recepcion_logs = recepcion_logs.filter(recepcion__boleta_id=boleta_id)

        if recepcion_id:
            recepcion_logs = recepcion_logs.filter(recepcion_id=recepcion_id)
            boleta_logs = boleta_logs.filter(
                boleta_id__in=Recepcion.objects.filter(id=recepcion_id).values_list("boleta_id", flat=True)
            )

        if usuario_id:
            boleta_logs = boleta_logs.filter(usuario_id=usuario_id)
            recepcion_logs = recepcion_logs.filter(usuario_id=usuario_id)

        if fecha_desde:
            boleta_logs = boleta_logs.filter(fecha__date__gte=fecha_desde)
            recepcion_logs = recepcion_logs.filter(fecha__date__gte=fecha_desde)

        if fecha_hasta:
            boleta_logs = boleta_logs.filter(fecha__date__lte=fecha_hasta)
            recepcion_logs = recepcion_logs.filter(fecha__date__lte=fecha_hasta)

        if tipo == "boleta":
            recepcion_logs = RecepcionLog.objects.none()
        elif tipo == "recepcion":
            boleta_logs = BoletaLog.objects.none()

        historial = sorted(
            list(boleta_logs) + list(recepcion_logs),
            key=lambda log: (log.fecha, log.id),
            reverse=True,
        )

        paginator = self.pagination_class()
        page = paginator.paginate_queryset(historial, request, view=self)
        return paginator.get_paginated_response(EstadoHistorialSerializer(page, many=True).data)


def _build_boletas_zip_bytes(*, boletas, force_regenerate: bool, persist: bool) -> bytes:
    from apps.boletas.pdf import attach_boleta_pdf, build_boleta_pdf_bytes

    buf = BytesIO()
    with ZipFile(buf, "w", compression=ZIP_DEFLATED) as zf:
        for boleta in boletas:
            pdf_bytes = None

            if boleta.pdf_generado and not force_regenerate:
                try:
                    with boleta.pdf_generado.open("rb") as f:
                        pdf_bytes = f.read()
                except FileNotFoundError:
                    pdf_bytes = None

            if pdf_bytes is None:
                pdf_bytes = build_boleta_pdf_bytes(boleta)
                if persist:
                    attach_boleta_pdf(boleta, pdf_bytes)

            filename = f"boleta_{boleta.folio}_{boleta.id}.pdf"
            zf.writestr(filename, pdf_bytes)

    return buf.getvalue()


def _boleta_pdf_data_iter(boleta, *, force_regenerate: bool, persist: bool):
    """Generador lazy que produce los bytes del PDF de una boleta."""
    from apps.boletas.pdf import attach_boleta_pdf, build_boleta_pdf_bytes

    pdf_bytes = None

    if boleta.pdf_generado and not force_regenerate:
        try:
            with boleta.pdf_generado.open("rb") as f:
                while True:
                    chunk = f.read(8192)
                    if not chunk:
                        break
                    yield chunk
            return
        except FileNotFoundError:
            pdf_bytes = None

    if pdf_bytes is None:
        pdf_bytes = build_boleta_pdf_bytes(boleta)
        if persist:
            attach_boleta_pdf(boleta, pdf_bytes)

    yield pdf_bytes


def _stream_boletas_zip(*, boletas, force_regenerate: bool, persist: bool):
    """Genera un ZIP en streaming; los PDFs se construyen a medida que se consume el stream."""
    zs = ZipStream(compress_type=ZIP_DEFLATED, compress_level=6)
    for boleta in boletas:
        filename = f"boleta_{boleta.folio}_{boleta.id}.pdf"
        zs.add(
            _boleta_pdf_data_iter(boleta, force_regenerate=force_regenerate, persist=persist),
            filename,
        )
    yield from zs


@extend_schema_view(
    list=extend_schema(tags=["Boletas"]),
    retrieve=extend_schema(tags=["Boletas"]),
    create=extend_schema(tags=["Boletas"]),
    update=extend_schema(tags=["Boletas"]),
    partial_update=extend_schema(tags=["Boletas"]),
    destroy=extend_schema(tags=["Boletas"]),
)
class BoletaViewSet(AuditFieldsViewSetMixin, ModelViewSet):
    permission_classes = [CanEditBoletas]
    queryset = (
        Boleta.objects.select_related("zafra", "productor", "contrato", "variedad", "campo", "linea")
        .prefetch_related("contrato__variedades", "contrato__variedades__variedad")
        .all()
        .order_by("-id")
    )
    serializer_class = BoletaSerializer
    filterset_fields = ["zafra", "productor", "contrato", "variedad", "campo", "linea", "estado", "enviada_email"]
    search_fields = ["=folio", "productor__nombre", "productor__rfc", "contrato__numero_contrato"]
    ordering_fields = ["id", "folio", "fecha_emision"]
    pagination_class = StandardResultsSetPagination

    def get_queryset(self):
        queryset = self.queryset
        if self.action == "list" and not self.request.query_params.get("estado"):
            queryset = queryset.exclude(estado=Boleta.ESTADO_CERRADA)
        return queryset

    def perform_create(self, serializer):
        office_id = extract_office_id(self.request)
        if office_id is None:
            raise ValidationError({"office_id": "office_id es requerido."})

        user = getattr(self.request, "user", None)
        folio = serializer.validated_data.get("folio")
        zafra = serializer.validated_data.get("zafra")
        productor = serializer.validated_data.get("productor")

        if folio is None:
            if zafra is None:
                raise ValidationError({"zafra": "zafra es requerido."})
            if productor is None:
                raise ValidationError({"productor": "productor es requerido."})

            zafra_year = zafra.fecha_inicio.year
            oficina_codigo = Oficina.objects.filter(pk=office_id).values_list("codigo", flat=True).first()
            with transaction.atomic():
                seq = None
                for _ in range(2):
                    try:
                        seq, _ = FolioSecuencia.objects.select_for_update().get_or_create(
                            productor=productor,
                            zafra=zafra,
                            defaults={"ultimo_folio": 0, "created_by": user, "updated_by": user},
                        )
                        break
                    except IntegrityError:
                        continue

                if seq is None:
                    raise ValidationError({"detail": "No se pudo inicializar la secuencia de folios para el productor/zafra."})

                seq.ultimo_folio += 1
                consecutivo = seq.ultimo_folio
                seq.updated_by = user
                seq.save(update_fields=["ultimo_folio", "updated_by", "updated_at"])

                folio = build_folio(oficina_codigo, productor.id, zafra_year, consecutivo)
                serializer.save(oficina_id=office_id, created_by=user, updated_by=user, folio=folio)
        else:
            serializer.save(oficina_id=office_id, created_by=user, updated_by=user)

    @extend_schema(
        tags=["Boletas"],
        summary="Descargar PDF de la boleta",
        description="Devuelve el PDF asociado a la boleta. Si no existe en 'pdf_generado', lo genera al vuelo.",
        responses={
            200: OpenApiResponse(response=OpenApiTypes.BINARY, description="Archivo"),
            404: OpenApiResponse(description="No encontrado"),
        },
    )
    @action(detail=True, methods=["get"], url_path="pdf")
    def pdf(self, request, pk=None):
        boleta = self.get_object()

        force_regenerate = str(request.query_params.get("regenerate", "")).lower() in {"1", "true", "yes"}
        persist = str(request.query_params.get("save", "")).lower() in {"1", "true", "yes"}

        if boleta.pdf_generado and not force_regenerate:
            try:
                resp = FileResponse(boleta.pdf_generado.open("rb"), content_type="application/pdf")
            except FileNotFoundError as e:
                raise Http404() from e
            resp["Content-Disposition"] = f"inline; filename=\"boleta_{boleta.folio}.pdf\""
            return resp

        from apps.boletas.pdf import attach_boleta_pdf, build_boleta_pdf_bytes

        try:
            pdf_bytes = build_boleta_pdf_bytes(boleta)
        except Exception as exc:
            logger.exception("No se pudo generar el PDF de la boleta id=%s folio=%s", boleta.id, boleta.folio)
            body = {"detail": "No se pudo generar el PDF."}
            if settings.DEBUG:
                body["error"] = f"{type(exc).__name__}: {exc}"
            return Response(body, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
        if persist:
            attach_boleta_pdf(boleta, pdf_bytes)

        resp = HttpResponse(pdf_bytes, content_type="application/pdf")
        resp["Content-Disposition"] = f"inline; filename=\"boleta_{boleta.folio}.pdf\""
        return resp

    @extend_schema(
        tags=["Boletas"],
        summary="Historial de estados de boleta y recepción",
        description=(
            "Devuelve la línea de tiempo de cambios de estado de la boleta y su recepción "
            "asociada, incluyendo usuario, estados anterior/nuevo y detalles."
        ),
        responses={
            200: OpenApiResponse(response=EstadoHistorialSerializer(many=True)),
            403: OpenApiResponse(description="Sin permisos"),
            404: OpenApiResponse(description="No encontrado"),
        },
    )
    @action(detail=True, methods=["get"], url_path="historial-estados")
    def historial_estados(self, request, pk=None):
        boleta = self.get_object()
        recepcion = getattr(boleta, "recepcion", None)

        boleta_logs = list(BoletaLog.objects.filter(boleta=boleta))
        recepcion_logs = list(RecepcionLog.objects.filter(recepcion=recepcion)) if recepcion else []

        historial = sorted(
            boleta_logs + recepcion_logs,
            key=lambda log: (log.fecha, log.id),
            reverse=True,
        )

        return Response(EstadoHistorialSerializer(historial, many=True).data)

    @extend_schema(
        tags=["Boletas"],
        summary="Impresión masiva (ZIP de PDFs)",
        description=(
            "Genera (si hace falta) los PDFs de las boletas indicadas y devuelve un archivo ZIP con todos."
        ),
        request=ImprimirBoletasMasivoRequestSerializer,
        responses={
            200: OpenApiResponse(response=OpenApiTypes.BINARY, description="Archivo ZIP"),
            400: OpenApiResponse(response=OpenApiTypes.OBJECT, description="Solicitud inválida"),
            403: OpenApiResponse(description="Sin permisos"),
        },
    )
    @action(detail=False, methods=["post"], url_path="imprimir-masivo")
    def imprimir_masivo(self, request):
        office_id = extract_office_id(request)
        if office_id is None:
            raise ValidationError({"office_id": "office_id es requerido."})

        serializer = ImprimirBoletasMasivoRequestSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        boleta_ids = serializer.validated_data["boleta_ids"]
        force_regenerate = serializer.validated_data.get("regenerate", False)
        persist = serializer.validated_data.get("save", False)

        qs = (
            Boleta.objects.select_related("zafra", "productor", "contrato", "variedad", "campo", "linea")
            .filter(id__in=boleta_ids, oficina_id=office_id)
            .order_by("id")
        )
        found_ids = set(qs.values_list("id", flat=True))
        missing = sorted(set(boleta_ids) - found_ids)
        if missing:
            raise ValidationError({"detail": "Algunas boletas no existen o no pertenecen a la oficina.", "missing": missing})

        zip_bytes = _build_boletas_zip_bytes(
            boletas=qs,
            force_regenerate=force_regenerate,
            persist=persist,
        )
        resp = HttpResponse(zip_bytes, content_type="application/zip")
        resp["Content-Disposition"] = "attachment; filename=\"boletas_pdfs.zip\""
        return resp

    @extend_schema(
        tags=["Boletas"],
        summary="Descargar ZIP de boletas por rango de fechas (streaming)",
        description=(
            "Genera un ZIP con los PDFs de las boletas cuya fecha_emision esté entre "
            "fecha_inicio y fecha_fin (YYYY-MM-DD), filtradas por la oficina indicada. "
            "El ZIP se genera y transmite en streaming a medida que se consume."
        ),
        responses={
            200: OpenApiResponse(response=OpenApiTypes.BINARY, description="Archivo ZIP"),
            400: OpenApiResponse(response=OpenApiTypes.OBJECT, description="Solicitud inválida"),
            403: OpenApiResponse(description="Sin permisos"),
        },
    )
    @action(detail=False, methods=["get"], url_path="descargar-rango")
    def descargar_rango(self, request):
        office_id = extract_office_id(request)
        if office_id is None:
            raise ValidationError({"office_id": "office_id es requerido."})

        serializer = DescargarBoletasRangoSerializer(data=request.query_params)
        serializer.is_valid(raise_exception=True)
        fecha_inicio = serializer.validated_data["fecha_inicio"]
        fecha_fin = serializer.validated_data["fecha_fin"]

        start = timezone.make_aware(datetime.combine(fecha_inicio, time.min), is_dst=False)
        end = timezone.make_aware(datetime.combine(fecha_fin + timedelta(days=1), time.min), is_dst=False)

        qs = (
            Boleta.objects.select_related("zafra", "productor", "contrato", "variedad", "campo", "linea")
            .filter(oficina_id=office_id, fecha_emision__gte=start, fecha_emision__lt=end)
            .order_by("id")
        )

        force_regenerate = str(request.query_params.get("regenerate", "")).lower() in {"1", "true", "yes"}
        persist = str(request.query_params.get("save", "")).lower() in {"1", "true", "yes"}

        response = StreamingHttpResponse(
            _stream_boletas_zip(boletas=qs, force_regenerate=force_regenerate, persist=persist),
            content_type="application/zip",
        )
        response["Content-Disposition"] = (
            f"attachment; filename=\"boletas_rango_{fecha_inicio}_{fecha_fin}.zip\""
        )
        return response


class GenerarBoletasView(APIView):
    permission_classes = [IsAuthenticated, CanGenerateBoletas]

    @extend_schema(
        tags=["Boletas"],
        request=GenerarBoletasRequestSerializer,
        responses={
            201: OpenApiResponse(
                response=inline_serializer(
                    name="GenerarBoletasResponse",
                    fields={
                        "zafra_id": IntegerField(),
                        "cantidad_boletas": IntegerField(),
                        "folio_inicio": CharField(allow_null=True, allow_blank=True),
                        "folio_fin": CharField(allow_null=True, allow_blank=True),
                        "boleta_ids": ListField(child=IntegerField()),
                    },
                )
            )
        },
    )
    def post(self, request):
        office_id = extract_office_id(request)
        if office_id is None:
            raise ValidationError({"office_id": "office_id es requerido."})

        serializer = GenerarBoletasRequestSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        data = serializer.validated_data

        try:
            zafra = Zafra.objects.get(pk=data["zafra_id"])
        except Zafra.DoesNotExist:
            return Response({"detail": "La zafra no existe."}, status=status.HTTP_400_BAD_REQUEST)

        if getattr(zafra, "oficina_id", None) is not None and zafra.oficina_id != office_id:
            return Response(
                {"detail": "La zafra no pertenece a la oficina indicada."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        cantidad = data["cantidad_boletas_por_productor"]
        generar_qr = data["generar_qr"]
        enviar_email = data["enviar_email"]
        imprimir_pdf = data["imprimir_pdf"]
        linea_id = data.get("linea_id")
        linea_nombre = ""
        if linea_id:
            linea_nombre = Linea.objects.filter(pk=linea_id).values_list("nombre", flat=True).first() or ""

        contratos = []
        productores = []
        if data.get("contrato_ids"):
            ids = data["contrato_ids"]
            qs = (
                Contrato.objects.select_related("productor")
                .filter(id__in=ids, zafra=zafra, activo=True, oficina_id=office_id)
                .order_by("id")
            )
            found_ids = set(qs.values_list("id", flat=True))
            missing = sorted(set(ids) - found_ids)
            if missing:
                return Response(
                    {"detail": "Algunos contratos son inválidos o no están activos en la zafra.", "missing": missing},
                    status=status.HTTP_400_BAD_REQUEST,
                )
            contratos = list(qs)

        if data.get("productor_ids"):
            productor_ids = data["productor_ids"]
            qs = Productor.objects.filter(id__in=productor_ids, activo=True)
            found_ids = set(qs.values_list("id", flat=True))
            missing_productores = sorted(set(productor_ids) - found_ids)
            if missing_productores:
                return Response(
                    {"detail": "Algunos productores no existen o están inactivos.", "missing": missing_productores},
                    status=status.HTTP_400_BAD_REQUEST,
                )
            productores_por_id = {productor.id: productor for productor in qs}
            productores = [productores_por_id[productor_id] for productor_id in productor_ids]

        user = request.user

        with transaction.atomic():
            asignaciones = []
            for contrato in contratos:
                primera_variedad = (
                    contrato.variedades.select_related("variedad")
                    .order_by("id")
                    .first()
                )
                variedad = primera_variedad.variedad if primera_variedad else None
                asignaciones.append((contrato.productor, contrato, variedad))
            asignaciones.extend((productor, None, None) for productor in productores)

            cantidad_por_productor: dict[int, int] = {}
            for productor, _, _ in asignaciones:
                cantidad_por_productor[productor.id] = (
                    cantidad_por_productor.get(productor.id, 0) + cantidad
                )

            seq_por_productor: dict[int, FolioSecuencia] = {}
            for productor_id in sorted(cantidad_por_productor.keys()):
                seq = None
                for _ in range(2):
                    try:
                        seq, _ = FolioSecuencia.objects.select_for_update().get_or_create(
                            productor_id=productor_id,
                            zafra=zafra,
                            defaults={"ultimo_folio": 0, "created_by": user, "updated_by": user},
                        )
                        break
                    except IntegrityError:
                        continue
                if seq is None:
                    return Response(
                        {"detail": "No se pudo inicializar la secuencia de folios para el productor/zafra."},
                        status=status.HTTP_500_INTERNAL_SERVER_ERROR,
                    )
                seq_por_productor[productor_id] = seq

            zafra_year = zafra.fecha_inicio.year
            oficina_codigo = Oficina.objects.filter(pk=office_id).values_list("codigo", flat=True).first()
            folios_por_productor: dict[int, list[str]] = {}
            for productor_id, seq in seq_por_productor.items():
                total = cantidad_por_productor[productor_id]
                seq.ultimo_folio += total
                consecutivos = list(range(seq.ultimo_folio - total + 1, seq.ultimo_folio + 1))
                folios_por_productor[productor_id] = [
                    build_folio(oficina_codigo, productor_id, zafra_year, c) for c in consecutivos
                ]
                seq.updated_by = user
                seq.save(update_fields=["ultimo_folio", "updated_by", "updated_at"])

            folio_iter_por_productor = {pid: iter(folios) for pid, folios in folios_por_productor.items()}

            now = timezone.now()

            nuevas_boletas = []
            for productor, contrato, variedad in asignaciones:
                for _ in range(cantidad):
                    folio = next(folio_iter_por_productor[productor.id])
                    qr_text = build_qr_text(zafra.id, folio) if generar_qr else ""

                    nuevas_boletas.append(
                        Boleta(
                            zafra=zafra,
                            oficina_id=office_id,
                            productor=productor,
                            contrato=contrato,
                            variedad=variedad,
                            linea_id=linea_id,
                            linea_nombre=linea_nombre,
                            folio=folio,
                            estado=Boleta.ESTADO_GENERADA,
                            qr_texto=qr_text,
                            created_by=user,
                            updated_by=user,
                            created_at=now,
                            updated_at=now,
                        )
                    )

            Boleta.objects.bulk_create(nuevas_boletas)

            folios_creados = [b.folio for b in nuevas_boletas]
            folio_inicio = min(folios_creados) if folios_creados else None
            folio_fin = max(folios_creados) if folios_creados else None

            created_ids = list(
                Boleta.objects.filter(
                    zafra=zafra, folio__in=folios_creados, oficina_id=office_id
                ).values_list("id", flat=True)
            )

        if enviar_email and created_ids:
            enqueue_send_emails(created_ids)
        if imprimir_pdf and created_ids:
            enqueue_generate_pdfs(created_ids)

        return Response(
            {
                "zafra_id": zafra.id,
                "cantidad_boletas": len(created_ids),
                "folio_inicio": folio_inicio if created_ids else None,
                "folio_fin": folio_fin if created_ids else None,
                "boleta_ids": created_ids,
            },
            status=status.HTTP_201_CREATED,
        )


class GenerarBoletasZipView(GenerarBoletasView):
    @extend_schema(
        tags=["Boletas"],
        summary="Generar boletas y descargar ZIP de PDFs",
        description=(
            "Genera boletas igual que /boletas/generar/ pero devuelve un ZIP con los PDFs de las boletas creadas."
        ),
        request=GenerarBoletasRequestSerializer,
        responses={
            200: OpenApiResponse(response=OpenApiTypes.BINARY, description="Archivo ZIP"),
            201: OpenApiResponse(response=OpenApiTypes.BINARY, description="Archivo ZIP"),
            400: OpenApiResponse(response=OpenApiTypes.OBJECT, description="Solicitud inválida"),
            403: OpenApiResponse(description="Sin permisos"),
        },
    )
    def post(self, request):
        resp = super().post(request)
        if resp.status_code != 201:
            return resp

        created_ids = resp.data.get("boleta_ids") or []
        if not created_ids:
            return Response({"detail": "No se generaron boletas."}, status=status.HTTP_400_BAD_REQUEST)

        office_id = extract_office_id(request)
        if office_id is None:
            raise ValidationError({"office_id": "office_id es requerido."})

        qs = (
            Boleta.objects.select_related("zafra", "productor", "contrato", "variedad", "campo", "linea")
            .filter(id__in=created_ids, oficina_id=office_id)
            .order_by("id")
        )

        zip_bytes = _build_boletas_zip_bytes(boletas=qs, force_regenerate=False, persist=False)
        out = HttpResponse(zip_bytes, content_type="application/zip")
        out["Content-Disposition"] = "attachment; filename=\"boletas_pdfs.zip\""
        return out
