"""Admin streaming ZIP backup: full PDF, official DOCX/PDF, evidence — manifest + SHA-256 verify."""

from __future__ import annotations

import hashlib
import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, Iterable, Iterator, List, Optional

import boto3
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from zipstream import ZipStream

from src.initiative_db.application_storage import (
    EVIDENCE_ROLE_RESEARCH,
    EVIDENCE_ROLE_TECHNICAL,
    EVIDENCE_ROLE_TEXTBOOK,
    ROLE_OFFICIAL_FORM_DOCX,
    ROLE_OFFICIAL_FORM_PDF,
    STORAGE_EXTERNAL_URL,
    STORAGE_FILESYSTEM,
    STORAGE_MINIO_ATTACHMENTS,
    STORAGE_MINIO_EXPORTS,
    effective_storage_kind,
)
from src.initiative_db.backup_naming import official_form_pdf_backup_zip_path
from src.initiative_db.models import ApplicationArtifact, Initiative
from src.minio.storage import S3Settings, _sanitize_filename

logger = logging.getLogger(__name__)


class BackupIntegrityError(Exception):
    """SHA-256 mismatch while packing an artifact into the backup ZIP."""


def _sync_s3_client(settings: S3Settings):
    return boto3.client(
        "s3",
        endpoint_url=settings.s3_endpoint_url,
        aws_access_key_id=settings.s3_access_key,
        aws_secret_access_key=settings.s3_secret_key,
        region_name=settings.s3_region,
        config=BotoConfig(signature_version="s3v4"),
    )


def _iter_chunks_s3(settings: S3Settings, bucket: str, key: str, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
    s3 = _sync_s3_client(settings)
    try:
        obj = s3.get_object(Bucket=bucket, Key=key)
    except ClientError as exc:
        code = exc.response.get("Error", {}).get("Code", "")
        if code in ("404", "NoSuchKey"):
            raise FileNotFoundError(f"s3://{bucket}/{key}") from exc
        raise
    body = obj["Body"]
    try:
        while True:
            chunk = body.read(chunk_size)
            if not chunk:
                break
            yield chunk
    finally:
        body.close()


def _submitted_initiatives_dir() -> Path:
    return Path(
        os.getenv(
            "SUBMITTED_INITIATIVES_DIR",
            str((Path(__file__).resolve().parents[3] / "fe0" / "public" / "submitted-initiatives").resolve()),
        )
    )


def _filesystem_path_for_uri(storage_uri: str) -> Path:
    base = _submitted_initiatives_dir().resolve()
    u = storage_uri.strip()
    prefix = "/submitted-initiatives/"
    if u.startswith(prefix):
        rel = u[len(prefix) :].lstrip("/")
    elif u.startswith("/"):
        raw = u.lstrip("/")
        if raw.startswith("submitted-initiatives/"):
            rel = raw[len("submitted-initiatives/") :].lstrip("/")
        else:
            rel = raw
    else:
        rel = u.lstrip("/")

    candidate = (base / rel).resolve()
    try:
        candidate.relative_to(base)
    except ValueError as exc:
        raise ValueError(f"Invalid backup path outside submitted-initiatives: {storage_uri}") from exc
    return candidate


def _iter_file_chunks(path: Path, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
    with open(path, "rb") as handle:
        while True:
            chunk = handle.read(chunk_size)
            if not chunk:
                break
            yield chunk


def _guarded_chunk_iter(
    chunks: Iterable[bytes],
    expected_sha256: Optional[str],
    role: str,
    arcname: str,
) -> Iterator[bytes]:
    h = hashlib.sha256()
    for chunk in chunks:
        h.update(chunk)
        yield chunk
    digest = h.hexdigest()
    if expected_sha256 and digest.lower() != str(expected_sha256).lower():
        raise BackupIntegrityError(
            f"SHA-256 mismatch for {role} ({arcname}): stored={expected_sha256[:16]}… verified={digest[:16]}…"
        )


def _zip_path_for_artifact(art: ApplicationArtifact) -> str:
    role = art.role
    if role == "full_pdf":
        return "submitted/full-package.pdf"
    if role == ROLE_OFFICIAL_FORM_DOCX:
        return "submitted/official-form.docx"
    if role == ROLE_OFFICIAL_FORM_PDF:
        return "submitted/official-form.pdf"
    if role == EVIDENCE_ROLE_RESEARCH:
        name = _sanitize_filename(art.original_name or "evidence")
        return f"evidence/research/{name}"
    if role == EVIDENCE_ROLE_TEXTBOOK:
        name = _sanitize_filename(art.original_name or "evidence")
        return f"evidence/textbook/{name}"
    if role == EVIDENCE_ROLE_TECHNICAL:
        name = _sanitize_filename(art.original_name or "evidence")
        return f"evidence/technical/{name}"
    safe = _sanitize_filename(art.original_name or role)
    return f"other/{role}/{safe}"


def _synthesize_official_form_pdf_bytes(obm: Dict[str, Any]) -> bytes:
    """Same pipeline as ``POST /api/v1/docx/preview-application-form-pdf`` and submit-time persist."""
    from src.be01.docx_to_pdf import convert_docx_bytes_to_pdf
    from src.be01.fill_application_form import fill_application_form_docx
    from src.be01.official_to_data_blank import official_to_data_blank

    ctx = official_to_data_blank(dict(obm))
    docx_bytes = fill_application_form_docx(ctx)
    return convert_docx_bytes_to_pdf(
        docx_bytes,
        relax_justified_softbreaks=True,
        strip_table_row_heights=False,
    )


def _iter_memory_chunks(data: bytes, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
    for i in range(0, len(data), chunk_size):
        yield data[i : i + chunk_size]


def build_backup_zipstream(
    *,
    settings: S3Settings,
    initiative: Initiative,
    application_id: str,
    case_code: str,
    artifacts: List[ApplicationArtifact],
    review_doc_json: Optional[Dict[str, Any]],
    owner_id: Optional[str],
    submitted_at: Optional[str],
) -> ZipStream:
    z = ZipStream()
    manifest_files: List[Dict[str, Any]] = []

    official_obm: Optional[Dict[str, Any]] = None
    if isinstance(review_doc_json, dict):
        raw_obm = review_doc_json.get("officialBieuMau")
        if isinstance(raw_obm, dict) and len(raw_obm) > 0:
            official_obm = raw_obm

    synth_pdf: Optional[bytes] = None
    synth_arc: Optional[str] = None
    if official_obm is not None:
        try:
            synth_pdf = _synthesize_official_form_pdf_bytes(official_obm)
            synth_arc = official_form_pdf_backup_zip_path(official_obm) or "submitted/official-form.pdf"
        except Exception:
            logger.exception(
                "Backup: failed to synthesize official form PDF from officialBieuMau; falling back to stored artifact if any"
            )
            synth_pdf = None
            synth_arc = None

    for art in sorted(artifacts, key=lambda a: _zip_path_for_artifact(a)):
        role = art.role
        if role == ROLE_OFFICIAL_FORM_PDF and synth_pdf is not None:
            continue
        uri = (art.storage_uri or "").strip()
        if not uri:
            continue
        sk = effective_storage_kind(role, uri, art.storage_kind)
        arcname = _zip_path_for_artifact(art)
        if role == ROLE_OFFICIAL_FORM_PDF and official_obm is not None:
            custom_pdf_path = official_form_pdf_backup_zip_path(official_obm)
            if custom_pdf_path:
                arcname = custom_pdf_path

        if sk == STORAGE_EXTERNAL_URL:
            manifest_files.append(
                {
                    "role": role,
                    "zip_path": arcname,
                    "original_name": art.original_name,
                    "mime_type": art.mime_type,
                    "byte_size": art.byte_size,
                    "stored_sha256": art.sha256,
                    "verified_sha256": None,
                    "storage_kind": sk,
                    "skipped": True,
                    "skip_reason": "external_url_not_packed",
                }
            )
            continue

        raw_iter: Optional[Iterator[bytes]] = None
        if sk == STORAGE_FILESYSTEM:
            path = _filesystem_path_for_uri(uri)
            if not path.is_file():
                logger.warning("Backup skip missing filesystem object role=%s path=%s", role, path)
                manifest_files.append(
                    {
                        "role": role,
                        "zip_path": arcname,
                        "original_name": art.original_name,
                        "mime_type": art.mime_type,
                        "byte_size": art.byte_size,
                        "stored_sha256": art.sha256,
                        "verified_sha256": None,
                        "storage_kind": sk,
                        "skipped": True,
                        "skip_reason": "filesystem_missing",
                    }
                )
                continue
            raw_iter = _iter_file_chunks(path)
        elif sk in (STORAGE_MINIO_EXPORTS, STORAGE_MINIO_ATTACHMENTS):
            bucket = (
                settings.s3_bucket_exports if sk == STORAGE_MINIO_EXPORTS else settings.s3_bucket_attachments
            )
            key = uri
            try:
                raw_iter = _iter_chunks_s3(settings, bucket, key)
            except FileNotFoundError:
                logger.warning("Backup skip missing MinIO object role=%s bucket=%s key=%s", role, bucket, key[:64])
                manifest_files.append(
                    {
                        "role": role,
                        "zip_path": arcname,
                        "original_name": art.original_name,
                        "mime_type": art.mime_type,
                        "byte_size": art.byte_size,
                        "stored_sha256": art.sha256,
                        "verified_sha256": None,
                        "storage_kind": sk,
                        "skipped": True,
                        "skip_reason": "minio_missing",
                    }
                )
                continue
        else:
            continue

        chunk_iter: Iterator[bytes] = (
            _guarded_chunk_iter(raw_iter, art.sha256, role, arcname) if art.sha256 else raw_iter
        )
        z.add(chunk_iter, arcname)

        manifest_files.append(
            {
                "role": role,
                "zip_path": arcname,
                "original_name": art.original_name,
                "mime_type": art.mime_type,
                "byte_size": art.byte_size,
                "stored_sha256": art.sha256,
                "verified_sha256": art.sha256 if art.sha256 else None,
                "integrity_note": "sha256_checked_during_zip" if art.sha256 else "no_stored_sha256",
                "storage_kind": sk,
                "skipped": False,
            }
        )

    if synth_pdf is not None and synth_arc is not None:
        z.add(_iter_memory_chunks(synth_pdf), synth_arc)
        digest = hashlib.sha256(synth_pdf).hexdigest()
        manifest_files.append(
            {
                "role": ROLE_OFFICIAL_FORM_PDF,
                "zip_path": synth_arc,
                "original_name": synth_arc.rsplit("/", 1)[-1],
                "mime_type": "application/pdf",
                "byte_size": len(synth_pdf),
                "stored_sha256": None,
                "verified_sha256": digest,
                "integrity_note": "synthesized_for_backup_match_preview_endpoint",
                "storage_kind": "synthesized",
                "skipped": False,
            }
        )

    missing_official = (
        not any(a.role in (ROLE_OFFICIAL_FORM_DOCX, ROLE_OFFICIAL_FORM_PDF) for a in artifacts) and synth_pdf is None
    )

    manifest: Dict[str, Any] = {
        "applicationId": application_id,
        "case_code": case_code,
        "initiative_id": str(initiative.id),
        "owner_id": owner_id,
        "submitted_at": submitted_at,
        "missing_official_form_artifacts": missing_official,
        "files": manifest_files,
    }

    if review_doc_json is not None:
        z.add(
            json.dumps(review_doc_json, ensure_ascii=False, indent=2).encode("utf-8"),
            "metadata/application_review_documents.json",
        )

    z.add(json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8"), "manifest.json")

    return z
