"""Persist and query submitted applications in PostgreSQL-backed initiative tables."""

from __future__ import annotations

import asyncio
import logging
import uuid
from datetime import datetime, timezone
from io import BytesIO
from typing import Any, Dict, List, Optional, Tuple

from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession

from src.initiative_db.application_storage import (
    ROLE_OFFICIAL_FORM_DOCX,
    ROLE_OFFICIAL_FORM_PDF,
    STORAGE_FILESYSTEM,
    STORAGE_MINIO_EXPORTS,
    record_submit_snapshot,
    upsert_application_taxonomy,
    upsert_application_workflow,
    upsert_artifact_full_pdf,
    upsert_artifact_official_form,
)
from src.initiative_db.drafts import SYSTEM_DRAFT_OWNER_ID
from src.initiative_db.models import ApplicationAdminResult
from src.initiative_db.models import ApplicationReviewDocument
from src.initiative_db.models import ApplicationSubmitSnapshot
from src.initiative_db.models import Draft, Initiative, User
from src.initiative_db.submission_readiness import (
    ApplicationSubmissionNotReadyError,
    collect_submission_readiness_gaps,
    fetch_evidence_presence_flags,
)

logger = logging.getLogger(__name__)


class ApplicationSubmitPersistError(Exception):
    """Canonical storage (MinIO / printable forms) failed during submit — do not silently fall back."""


def _now_utc() -> datetime:
    return datetime.now(timezone.utc).replace(microsecond=0)


def _iso_utc(dt: Optional[datetime]) -> str:
    if dt is None:
        return ""
    value = dt.astimezone(timezone.utc).replace(microsecond=0).isoformat()
    return value.replace("+00:00", "Z")


def _normalize_case_id(case_id: Optional[str], fallback_prefix: str = "CASE") -> str:
    raw = case_id or f"{fallback_prefix}-{int(datetime.now().timestamp() * 1000)}"
    safe = "".join(ch for ch in raw if ch.isalnum() or ch in ("-", "_"))
    if not safe:
        raise ValueError("Invalid case id")
    return safe


async def _get_or_create_latest_draft(session: AsyncSession, initiative: Initiative, case_id: str) -> Draft:
    draft_stmt = (
        select(Draft)
        .where(Draft.initiative_id == initiative.id)
        .order_by(Draft.updated_at.desc())
        .limit(1)
    )
    draft = (await session.execute(draft_stmt)).scalar_one_or_none()
    if draft is not None:
        return draft

    draft = Draft(
        draft_code=f"DRAFT-{case_id}",
        initiative_id=initiative.id,
        payload={"caseId": case_id, "updatedAt": _iso_utc(_now_utc()), "tabs": {}},
    )
    session.add(draft)
    await session.flush()
    return draft


def _map_status_for_client(raw: str) -> str:
    """Align DB enum values with frontend `ApplicationStatus` (mock list uses `pending` for new)."""
    if raw == "submitted":
        return "pending"
    return raw


def _calendar_year_from_submitted(row: Dict[str, Any]) -> Optional[int]:
    sd = str(row.get("submittedDate") or "")
    if len(sd) >= 4 and sd[:4].isdigit():
        return int(sd[:4])
    return None


def _as_review_document_row(doc: ApplicationReviewDocument) -> Dict[str, Any]:
    return {
        "id": str(doc.id),
        "initiativeId": str(doc.initiative_id),
        "caseId": doc.case_id,
        "documentVersion": int(doc.document_version or 1),
        "officialBieuMau": dict(doc.official_bieu_mau or {}),
        "templateData": dict(doc.template_data or {}) if isinstance(doc.template_data, dict) else None,
        "fullBundle": dict(doc.full_bundle or {}) if isinstance(doc.full_bundle, dict) else None,
        "createdBy": str(doc.created_by) if doc.created_by else None,
        "createdAt": _iso_utc(doc.created_at),
    }


def _submission_display_id(initiative: Initiative, stored: Dict[str, Any]) -> str:
    """Public row id for list + GET /api/applications/{id} (must stay in sync everywhere we resolve by id)."""
    sid = stored.get("id")
    if sid is not None and str(sid).strip():
        return str(sid)
    # Match `sub-{uuid4().hex[:16]}` (no hyphens); avoid `str(uuid)[:16]` which truncates dashed UUID awkwardly.
    raw_id = getattr(initiative, "id", None)
    if raw_id is not None:
        hex32 = getattr(raw_id, "hex", None)
        if isinstance(hex32, str) and len(hex32) >= 16:
            return f"sub-{hex32[:16]}"
        compact = str(raw_id).replace("-", "")
        if len(compact) >= 16:
            return f"sub-{compact[:16]}"
    case = str(getattr(initiative, "case_code", "") or "")
    tail = "".join(c for c in case if c.isalnum())[:16] or "noid"
    return f"sub-{tail}"


def _sanitize_case_key_fragment(case_key: str) -> str:
    """Same character allow-list as ``main._normalize_case_id`` without inventing a fallback id."""
    raw = (case_key or "").strip()
    return "".join(ch for ch in raw if ch.isalnum() or ch in ("-", "_"))


async def resolve_initiative_for_draft_case_key(
    session: AsyncSession,
    case_key: str,
) -> Optional[Initiative]:
    """
    Resolve ``Initiative`` for draft/evidence URLs keyed by ``Initiative.case_code`` *or* the public
    submission row id (``sub-…`` / ``SUB-…``, case-insensitive — must match ``_submission_display_id``).
    """
    safe = _sanitize_case_key_fragment(case_key)
    if not safe:
        return None

    ini = (await session.execute(select(Initiative).where(Initiative.case_code == safe))).scalar_one_or_none()
    if ini is not None:
        return ini

    key_lower = safe.lower()
    stmt = (
        select(Initiative)
        .where(Initiative.status != "draft")
        .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at))
    )
    initiatives = (await session.execute(stmt)).scalars().all()
    for initiative in initiatives:
        draft_stmt = (
            select(Draft)
            .where(Draft.initiative_id == initiative.id)
            .order_by(Draft.updated_at.desc())
            .limit(1)
        )
        draft = (await session.execute(draft_stmt)).scalar_one_or_none()
        if draft is None:
            continue
        payload = dict(draft.payload) if isinstance(draft.payload, dict) else {}
        stored = payload.get("submissionRecord") if isinstance(payload.get("submissionRecord"), dict) else {}
        disp = _submission_display_id(initiative, stored)
        if disp.lower() == key_lower:
            return initiative
    return None


def _as_submission_item(initiative: Initiative, payload: Dict[str, Any]) -> Dict[str, Any]:
    stored = payload.get("submissionRecord") if isinstance(payload.get("submissionRecord"), dict) else {}
    item_id = _submission_display_id(initiative, stored)
    submitted_at = initiative.submitted_at
    submitted_date = _iso_utc(submitted_at) or str(stored.get("submittedDate") or "")
    submission_file = payload.get("submissionFile") if isinstance(payload.get("submissionFile"), dict) else {}
    author = stored.get("author") if isinstance(stored.get("author"), dict) else {}

    row: Dict[str, Any] = {
        "id": item_id,
        "submittedDate": submitted_date,
        "name": str(stored.get("name") or "Hồ sơ sáng kiến"),
        "author": {
            "id": str(author.get("id") or initiative.case_code),
            "name": str(author.get("name") or "—"),
            "email": author.get("email"),
            "phone": author.get("phone"),
        },
        "subjectId": str(stored.get("subjectId") or ""),
        "groupId": str(stored.get("groupId") or ""),
        "status": _map_status_for_client(str(initiative.status or stored.get("status") or "submitted")),
        "reviewStatus": str(stored.get("reviewStatus") or "not_reviewed"),
        "supervisor": stored.get("supervisor"),
        "reviewer": stored.get("reviewer"),
        "reviewDeadline": stored.get("reviewDeadline"),
        "conference": stored.get("conference"),
        "topicType": str(stored.get("topicType") or "Hồ sơ PDF (đơn + báo cáo)"),
        "files": {
            "fullText": submission_file if submission_file.get("url") else None,
            "abstract": None,
            "poster": None,
        },
    }
    cy = _calendar_year_from_submitted(row)
    if cy is not None:
        row["calendarYear"] = cy
    # Initiative.case_code is the key for application-drafts; submission "id" is often sub-… and must not be used to load tabs.
    row["draft_case_id"] = initiative.case_code
    tabs = payload.get("tabs") if isinstance(payload.get("tabs"), dict) else {}
    app_tab = tabs.get("application") if isinstance(tabs.get("application"), dict) else {}
    ic = app_tab.get("initiativeClassification")
    if ic is not None and ic != "":
        row["initiativeClassification"] = ic
    rek = app_tab.get("researchEvidenceKind")
    if rek is not None and rek != "":
        row["researchEvidenceKind"] = rek
    tek = app_tab.get("textbookEvidenceKind")
    if tek is not None and tek != "":
        row["textbookEvidenceKind"] = tek
        row["textbook_evidence_kind"] = tek
    return row


async def _admin_feedback_by_initiative_ids(
    session: AsyncSession,
    initiative_ids: List[uuid.UUID],
) -> Dict[uuid.UUID, str]:
    """Initiative id → admin result ``feedback`` (trimmed) for API ``nhan_xet`` / «Nhận xét» column."""
    if not initiative_ids:
        return {}
    stmt = select(ApplicationAdminResult).where(ApplicationAdminResult.initiative_id.in_(initiative_ids))
    rows = (await session.execute(stmt)).scalars().all()
    out: Dict[uuid.UUID, str] = {}
    for rec in rows:
        text = (rec.feedback or "").strip()
        if text:
            out[rec.initiative_id] = text
    return out


async def _admin_decision_reviewers_by_initiative_ids(
    session: AsyncSession,
    initiative_ids: List[uuid.UUID],
) -> Dict[uuid.UUID, Dict[str, Any]]:
    """
    Initiative id → ``reviewer`` person for rows with ``application_admin_results``.

    Uses ``updated_by`` (last admin who saved approve/reject) and ``users.full_name`` so
    GET /api/applications exposes «Người đánh giá» consistently with adjudication history.
    """
    if not initiative_ids:
        return {}
    stmt = select(ApplicationAdminResult).where(ApplicationAdminResult.initiative_id.in_(initiative_ids))
    recs = (await session.execute(stmt)).scalars().all()
    if not recs:
        return {}

    uid_list = [r.updated_by for r in recs if r.updated_by is not None]
    name_by_uid: Dict[uuid.UUID, str] = {}
    if uid_list:
        urows = (await session.execute(select(User).where(User.id.in_(uid_list)))).scalars().all()
        for u in urows:
            name_by_uid[u.id] = (u.full_name or "").strip() or "—"

    out: Dict[uuid.UUID, Dict[str, Any]] = {}
    for rec in recs:
        uid = rec.updated_by
        if uid is None:
            out[rec.initiative_id] = {"id": "", "name": "—"}
        else:
            out[rec.initiative_id] = {
                "id": str(uid),
                "name": name_by_uid.get(uid, "—"),
            }
    return out


META_CASE_KEYS = (
    "initiativeCaseId",
    "applicationCaseId",
    "draftCaseId",
    "caseCode",
)


def _looks_like_client_draft_session_id(value: str) -> bool:
    s = value.strip()
    return s.upper().startswith("DRAFT-")


async def _find_initiative_for_submit_metadata(
    session: AsyncSession,
    metadata: Dict[str, Any],
    owner_user_id: Optional[uuid.UUID],
) -> Optional[Initiative]:
    """Match an existing applicant draft (CASE-…) when the client sends Postgres case keys; ignore DRAFT-* session ids."""
    candidates: List[str] = []
    for key in META_CASE_KEYS:
        raw = metadata.get(key)
        if isinstance(raw, str) and raw.strip():
            candidates.append(raw.strip())

    raw_fallback = metadata.get("caseId")
    if isinstance(raw_fallback, str) and raw_fallback.strip():
        s = raw_fallback.strip()
        if not _looks_like_client_draft_session_id(s):
            candidates.append(s)

    seen: set[str] = set()
    for c in candidates:
        if c in seen:
            continue
        seen.add(c)
        try:
            nid = _normalize_case_id(c, fallback_prefix="CASE")
        except ValueError:
            continue
        ini = (
            await session.execute(select(Initiative).where(Initiative.case_code == nid))
        ).scalar_one_or_none()
        if ini is not None:
            logger.info(
                "Submit linked to existing initiative case_code=%s id=%s",
                ini.case_code,
                ini.id,
            )
            return ini

    if owner_user_id is None:
        return None

    stmt = (
        select(Initiative)
        .where(
            Initiative.owner_id == owner_user_id,
            Initiative.case_code.ilike(r"CASE-%"),
        )
        .order_by(desc(Initiative.updated_at))
        .limit(1)
    )
    fb = (await session.execute(stmt)).scalar_one_or_none()
    if fb is not None:
        logger.warning(
            "Submit matched initiative via owner CASE-* fallback case_code=%s owner=%s "
            "(prefer sending initiativeCaseId from the applicant dashboard)",
            fb.case_code,
            owner_user_id,
        )
    return fb


def _new_case_code_for_fresh_submit(metadata: Dict[str, Any]) -> str:
    """When no existing initiative matches, allocate a stable case_code from metadata or SUB-…."""
    for key in META_CASE_KEYS:
        raw = metadata.get(key)
        if isinstance(raw, str) and raw.strip():
            cand = raw.strip()
            if not _looks_like_client_draft_session_id(cand):
                return _normalize_case_id(cand, fallback_prefix="CASE")
    raw2 = metadata.get("caseId")
    if isinstance(raw2, str) and raw2.strip():
        cand2 = raw2.strip()
        if not _looks_like_client_draft_session_id(cand2):
            return _normalize_case_id(cand2, fallback_prefix="CASE")
    return _normalize_case_id(None, fallback_prefix="SUB")


def _tabs_effectively_empty(tabs: Any) -> bool:
    if not isinstance(tabs, dict) or len(tabs) == 0:
        return True
    for key in ("report", "application", "contribution"):
        val = tabs.get(key)
        if isinstance(val, dict) and len(val) > 0:
            return False
        if val:
            return False
    return True


async def _upload_submitted_pdf_to_exports_minio_required(
    initiative: Initiative,
    pdf_body: bytes,
    original_name: Optional[str],
) -> str:
    """Upload full submitted PDF to exports bucket; required for HTTP submit path."""
    from src.minio.storage import S3Storage, StorageError, settings as s3s

    s3 = S3Storage()
    bucket = s3s.s3_bucket_exports
    key = s3.build_key_for_initiative(initiative.id, original_name or "ho-so.pdf")
    try:
        await s3.upload(
            bucket,
            key,
            BytesIO(pdf_body),
            "application/pdf",
            metadata={
                "case_code": str(initiative.case_code or ""),
                "role": "full_pdf_submission",
            },
        )
    except StorageError as exc:
        raise ApplicationSubmitPersistError(f"Lưu PDF hồ sơ lên MinIO thất bại: {exc}") from exc
    except Exception as exc:
        raise ApplicationSubmitPersistError(f"Lưu PDF hồ sơ lên MinIO thất bại: {exc}") from exc
    logger.info("Submitted PDF copied to MinIO exports bucket key=%s", key[:48])
    return key


async def _persist_official_application_forms(
    session: AsyncSession,
    initiative: Initiative,
    owner_user_id: Optional[uuid.UUID],
) -> None:
    """
    If a review-document row has non-empty officialBieuMau, render DOCX+PDF and register MinIO artifacts.
    Skips when no bundle exists (legacy submits).
    """
    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
    from src.minio.storage import S3Storage, StorageError, settings as s3s

    stmt = (
        select(ApplicationReviewDocument)
        .where(ApplicationReviewDocument.initiative_id == initiative.id)
        .order_by(desc(ApplicationReviewDocument.document_version))
        .limit(1)
    )
    doc = (await session.execute(stmt)).scalar_one_or_none()
    if doc is None:
        return
    obm = doc.official_bieu_mau
    if not isinstance(obm, dict) or len(obm) == 0:
        return

    try:
        ctx = official_to_data_blank(dict(obm))
    except Exception as exc:
        raise ApplicationSubmitPersistError(f"Dữ liệu biểu mẫu không hợp lệ: {exc}") from exc

    try:
        docx_bytes = await asyncio.to_thread(fill_application_form_docx, ctx)
        pdf_bytes = await asyncio.to_thread(
            convert_docx_bytes_to_pdf,
            docx_bytes,
            relax_justified_softbreaks=True,
            strip_table_row_heights=False,
        )
    except FileNotFoundError as exc:
        raise ApplicationSubmitPersistError(
            "Không thể tạo PDF mẫu: thiếu LibreOffice hoặc file mẫu Word. " + str(exc)
        ) from exc
    except Exception as exc:
        raise ApplicationSubmitPersistError(f"Không thể tạo biểu mẫu DOCX/PDF: {exc}") from exc

    s3 = S3Storage()
    bucket = s3s.s3_bucket_exports
    safe_case = str(initiative.case_code or "case").replace("/", "_")[:80]
    base_name = f"official-form-{safe_case}"
    docx_key = s3.build_key_for_initiative(initiative.id, f"{base_name}.docx")
    pdf_key = s3.build_key_for_initiative(initiative.id, f"{base_name}.pdf")

    try:
        docx_res = await s3.upload(
            bucket,
            docx_key,
            BytesIO(docx_bytes),
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
            metadata={"case_code": str(initiative.case_code or ""), "role": ROLE_OFFICIAL_FORM_DOCX},
        )
        pdf_res = await s3.upload(
            bucket,
            pdf_key,
            BytesIO(pdf_bytes),
            "application/pdf",
            metadata={"case_code": str(initiative.case_code or ""), "role": ROLE_OFFICIAL_FORM_PDF},
        )
    except StorageError as exc:
        raise ApplicationSubmitPersistError(f"Tải biểu mẫu lên MinIO thất bại: {exc}") from exc

    await upsert_artifact_official_form(
        session,
        initiative_id=initiative.id,
        role=ROLE_OFFICIAL_FORM_DOCX,
        storage_uri=docx_key,
        original_name=f"{base_name}.docx",
        byte_size=docx_res["size"],
        sha256_hex=docx_res["sha256"],
        uploaded_by=owner_user_id,
        mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        storage_kind=STORAGE_MINIO_EXPORTS,
    )
    await upsert_artifact_official_form(
        session,
        initiative_id=initiative.id,
        role=ROLE_OFFICIAL_FORM_PDF,
        storage_uri=pdf_key,
        original_name=f"{base_name}.pdf",
        byte_size=pdf_res["size"],
        sha256_hex=pdf_res["sha256"],
        uploaded_by=owner_user_id,
        mime_type="application/pdf",
        storage_kind=STORAGE_MINIO_EXPORTS,
    )


async def merge_applicant_draft_bundle_tabs_from_snapshot_if_empty(
    session: AsyncSession,
    *,
    initiative: Initiative,
    bundle: Dict[str, Any],
) -> Dict[str, Any]:
    """
    Recover tab JSON for admins when drafts.payload.tabs stayed empty but a submit snapshot exists
    (e.g. older bug linked submit to wrong initiative row).
    """
    tabs = bundle.get("tabs")
    if not _tabs_effectively_empty(tabs):
        return bundle
    stmt = (
        select(ApplicationSubmitSnapshot)
        .where(ApplicationSubmitSnapshot.initiative_id == initiative.id)
        .order_by(desc(ApplicationSubmitSnapshot.captured_at))
        .limit(1)
    )
    snap = (await session.execute(stmt)).scalar_one_or_none()
    if snap is None or not isinstance(snap.merged_tabs, dict):
        return bundle
    snap_tabs = snap.merged_tabs
    if _tabs_effectively_empty(snap_tabs):
        return bundle
    merged = dict(bundle)
    merged["tabs"] = {**snap_tabs, **(merged.get("tabs") or {})}
    merged.setdefault("caseId", initiative.case_code)
    return merged


async def merge_application_draft_document_with_snapshot_if_needed(
    session: AsyncSession,
    case_code: str,
    doc: Dict[str, Any],
) -> Dict[str, Any]:
    """GET /api/v1/application-drafts/:caseId — hydrate empty tabs from immutable submit snapshot when present."""
    raw = (case_code or "").strip()
    if not raw:
        return doc
    try:
        norm = _normalize_case_id(raw, fallback_prefix="CASE")
    except ValueError:
        norm = raw
    ini = (await session.execute(select(Initiative).where(Initiative.case_code == norm))).scalar_one_or_none()
    if ini is None:
        return doc
    return await merge_applicant_draft_bundle_tabs_from_snapshot_if_empty(session, initiative=ini, bundle=doc)


async def save_submitted_application(
    session: AsyncSession,
    metadata: Dict[str, Any],
    file_url: str,
    submission_id: Optional[str] = None,
    owner_user_id: Optional[uuid.UUID] = None,
    *,
    pdf_byte_size: Optional[int] = None,
    pdf_sha256: Optional[str] = None,
    pdf_original_name: Optional[str] = None,
    pdf_body: Optional[bytes] = None,
) -> Dict[str, Any]:
    initiative_name = str(metadata.get("initiativeName") or metadata.get("name") or "").strip() or "Hồ sơ sáng kiến"
    author_name = str(metadata.get("authorName") or "").strip() or "—"
    author_email = str(metadata.get("authorEmail") or "").strip() or None
    author_phone = str(metadata.get("authorPhone") or "").strip() or None
    now = _now_utc()
    resolved_submission_id = submission_id or f"sub-{uuid.uuid4().hex[:16]}"
    effective_owner = owner_user_id or SYSTEM_DRAFT_OWNER_ID

    matched = await _find_initiative_for_submit_metadata(session, metadata, owner_user_id)

    if matched is not None:
        initiative = matched
        case_id = initiative.case_code
    else:
        case_id = _new_case_code_for_fresh_submit(metadata)
        initiative = (await session.execute(select(Initiative).where(Initiative.case_code == case_id))).scalar_one_or_none()
        if initiative is None:
            initiative = Initiative(case_code=case_id, owner_id=effective_owner)
            session.add(initiative)
            await session.flush()
        elif owner_user_id and initiative.owner_id == SYSTEM_DRAFT_OWNER_ID:
            initiative.owner_id = owner_user_id

    assert initiative is not None

    if owner_user_id and initiative.owner_id == SYSTEM_DRAFT_OWNER_ID:
        initiative.owner_id = owner_user_id

    draft = await _get_or_create_latest_draft(session, initiative, case_id)
    payload_pre: Dict[str, Any] = dict(draft.payload) if isinstance(draft.payload, dict) else {}
    tabs_pre = dict(payload_pre.get("tabs") or {})
    bundle_v = await merge_applicant_draft_bundle_tabs_from_snapshot_if_empty(
        session,
        initiative=initiative,
        bundle={"tabs": tabs_pre, "caseId": case_id},
    )
    merged_tabs_validate = dict(bundle_v.get("tabs") or {})
    evidence_flags = await fetch_evidence_presence_flags(session, initiative.id)
    missing_ready = collect_submission_readiness_gaps(merged_tabs_validate, evidence_flags)
    if missing_ready:
        raise ApplicationSubmissionNotReadyError(missing_ready)

    initiative.status = "submitted"
    initiative.submitted_at = now

    current_payload: Dict[str, Any] = payload_pre
    current_payload["caseId"] = case_id
    current_payload["updatedAt"] = _iso_utc(now)
    current_payload["tabs"] = dict(current_payload.get("tabs") or {})
    current_payload["submissionFile"] = {"url": file_url, "type": "pdf"}
    current_payload["submissionRecord"] = {
        "id": resolved_submission_id,
        "submittedDate": _iso_utc(now),
        "name": initiative_name,
        "author": {
            "id": case_id,
            "name": author_name,
            "email": author_email,
            "phone": author_phone,
        },
        "subjectId": str(metadata.get("subjectId") or ""),
        "groupId": str(metadata.get("groupId") or ""),
        "status": "submitted",
        "reviewStatus": "not_reviewed",
        "supervisor": None,
        "reviewer": None,
        "reviewDeadline": None,
        "conference": None,
        "topicType": str(metadata.get("topicType") or "Hồ sơ PDF (đơn + báo cáo)"),
    }

    draft.payload = current_payload
    draft.version = (draft.version or 0) + 1
    await session.flush()

    sr = current_payload.get("submissionRecord")
    sr_dict = dict(sr) if isinstance(sr, dict) else {}
    merged_tabs = dict(current_payload.get("tabs") or {})
    submit_meta: Dict[str, Any] = {
        "caseId": case_id,
        "draftVersion": draft.version,
        "submissionFile": current_payload.get("submissionFile"),
    }
    if isinstance(metadata, dict):
        for k in (
            "initiativeName",
            "name",
            "authorName",
            "authorEmail",
            "topicType",
            "subjectId",
            "groupId",
            "initiativeCaseId",
            "applicationCaseId",
            "draftCaseId",
        ):
            if k in metadata:
                submit_meta[k] = metadata[k]

    artifact_storage_uri = file_url
    artifact_storage_kind: Optional[str] = STORAGE_FILESYSTEM if file_url else None
    if pdf_body and len(pdf_body) >= 50:
        minio_key = await _upload_submitted_pdf_to_exports_minio_required(
            initiative, pdf_body, pdf_original_name or f"{resolved_submission_id}.pdf"
        )
        artifact_storage_uri = minio_key
        artifact_storage_kind = STORAGE_MINIO_EXPORTS

    await record_submit_snapshot(
        session,
        initiative_id=initiative.id,
        submission_record_id=resolved_submission_id,
        merged_tabs=merged_tabs,
        submit_metadata=submit_meta,
        full_pdf_uri=file_url,
    )
    await upsert_application_taxonomy(
        session,
        initiative_id=initiative.id,
        subject_id=str(sr_dict.get("subjectId") or metadata.get("subjectId") or ""),
        group_id=str(sr_dict.get("groupId") or metadata.get("groupId") or ""),
        topic_type=str(sr_dict.get("topicType") or metadata.get("topicType") or ""),
    )
    await upsert_application_workflow(
        session,
        initiative_id=initiative.id,
        submission_record=sr_dict,
    )
    await upsert_artifact_full_pdf(
        session,
        initiative_id=initiative.id,
        storage_uri=artifact_storage_uri,
        original_name=pdf_original_name,
        byte_size=pdf_byte_size,
        sha256_hex=pdf_sha256,
        uploaded_by=owner_user_id,
        storage_kind=artifact_storage_kind,
    )
    await _persist_official_application_forms(session, initiative, owner_user_id)

    return {
        "id": resolved_submission_id,
        "submittedDate": _iso_utc(now),
        "publicUrl": file_url,
        "name": initiative_name,
    }


async def create_submitted_application_shell(
    session: AsyncSession,
    *,
    owner_user_id: uuid.UUID,
    name: Optional[str] = None,
    author_name: Optional[str] = None,
    author_email: Optional[str] = None,
    author_phone: Optional[str] = None,
) -> Dict[str, Any]:
    """
    Create a new submitted-application shell record and return list-item shape.
    This allocates an `applicationId` before the applicant uploads the final PDF.
    """
    now = _now_utc()
    case_id = _normalize_case_id(f"SUB-{uuid.uuid4().hex[:12]}", fallback_prefix="SUB")
    submission_id = f"sub-{uuid.uuid4().hex[:16]}"

    initiative = Initiative(
        case_code=case_id,
        owner_id=owner_user_id,
        status="submitted",
        submitted_at=now,
    )
    session.add(initiative)
    await session.flush()

    submission_record = {
        "id": submission_id,
        "submittedDate": _iso_utc(now),
        "name": (name or "").strip() or "Hồ sơ mới",
        "author": {
            "id": case_id,
            "name": (author_name or "").strip() or "—",
            "email": (author_email or "").strip() or None,
            "phone": (author_phone or "").strip() or None,
        },
        "subjectId": "",
        "groupId": "",
        "status": "submitted",
        "reviewStatus": "not_reviewed",
        "supervisor": None,
        "reviewer": None,
        "reviewDeadline": None,
        "conference": None,
        "topicType": "Hồ sơ khởi tạo",
    }
    payload: Dict[str, Any] = {
        "caseId": case_id,
        "updatedAt": _iso_utc(now),
        "tabs": {},
        "submissionRecord": submission_record,
    }
    draft = Draft(
        draft_code=f"DRAFT-{case_id}",
        initiative_id=initiative.id,
        payload=payload,
    )
    session.add(draft)
    await session.flush()
    return _as_submission_item(initiative, payload)


async def save_review_document_bundle(
    session: AsyncSession,
    *,
    case_id: str,
    official_bieu_mau: Dict[str, Any],
    template_data: Optional[Dict[str, Any]],
    full_bundle: Optional[Dict[str, Any]],
    owner_user_id: Optional[uuid.UUID],
) -> Dict[str, Any]:
    normalized_case = _normalize_case_id(case_id, fallback_prefix="CASE")
    stmt = select(Initiative).where(Initiative.case_code == normalized_case)
    initiative = (await session.execute(stmt)).scalar_one_or_none()
    effective_owner = owner_user_id or SYSTEM_DRAFT_OWNER_ID
    if initiative is None:
        initiative = Initiative(case_code=normalized_case, owner_id=effective_owner)
        session.add(initiative)
        await session.flush()
    elif owner_user_id and initiative.owner_id == SYSTEM_DRAFT_OWNER_ID:
        initiative.owner_id = owner_user_id

    max_stmt = (
        select(ApplicationReviewDocument.document_version)
        .where(ApplicationReviewDocument.initiative_id == initiative.id)
        .order_by(desc(ApplicationReviewDocument.document_version))
        .limit(1)
    )
    latest_ver = (await session.execute(max_stmt)).scalar_one_or_none()
    next_ver = int(latest_ver or 0) + 1
    row = ApplicationReviewDocument(
        initiative_id=initiative.id,
        case_id=normalized_case,
        document_version=next_ver,
        official_bieu_mau=dict(official_bieu_mau or {}),
        template_data=dict(template_data or {}) if isinstance(template_data, dict) else None,
        full_bundle=dict(full_bundle or {}) if isinstance(full_bundle, dict) else None,
        created_by=owner_user_id,
    )
    session.add(row)
    await session.flush()
    return _as_review_document_row(row)


async def get_latest_review_document_bundle(
    session: AsyncSession, *, case_id: str
) -> Optional[Dict[str, Any]]:
    normalized_case = _normalize_case_id(case_id, fallback_prefix="CASE")
    stmt = select(Initiative).where(Initiative.case_code == normalized_case)
    initiative = (await session.execute(stmt)).scalar_one_or_none()
    if initiative is None:
        return None
    doc_stmt = (
        select(ApplicationReviewDocument)
        .where(ApplicationReviewDocument.initiative_id == initiative.id)
        .order_by(desc(ApplicationReviewDocument.document_version), desc(ApplicationReviewDocument.created_at))
        .limit(1)
    )
    doc = (await session.execute(doc_stmt)).scalar_one_or_none()
    if doc is None:
        return None
    return _as_review_document_row(doc)


async def list_review_document_bundles(
    session: AsyncSession, *, case_id: str, limit: int = 20
) -> List[Dict[str, Any]]:
    normalized_case = _normalize_case_id(case_id, fallback_prefix="CASE")
    stmt = select(Initiative).where(Initiative.case_code == normalized_case)
    initiative = (await session.execute(stmt)).scalar_one_or_none()
    if initiative is None:
        return []
    doc_stmt = (
        select(ApplicationReviewDocument)
        .where(ApplicationReviewDocument.initiative_id == initiative.id)
        .order_by(desc(ApplicationReviewDocument.document_version), desc(ApplicationReviewDocument.created_at))
        .limit(max(1, min(limit, 200)))
    )
    rows = (await session.execute(doc_stmt)).scalars().all()
    return [_as_review_document_row(x) for x in rows]


async def get_review_document_bundle_by_id(
    session: AsyncSession, *, review_document_id: str
) -> Optional[Dict[str, Any]]:
    try:
        rid = uuid.UUID(str(review_document_id))
    except ValueError:
        return None
    row = await session.get(ApplicationReviewDocument, rid)
    if row is None:
        return None
    return _as_review_document_row(row)


async def update_review_document_bundle(
    session: AsyncSession,
    *,
    review_document_id: str,
    official_bieu_mau: Dict[str, Any],
    template_data: Optional[Dict[str, Any]],
    full_bundle: Optional[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
    try:
        rid = uuid.UUID(str(review_document_id))
    except ValueError:
        return None
    row = await session.get(ApplicationReviewDocument, rid)
    if row is None:
        return None
    row.official_bieu_mau = dict(official_bieu_mau or {})
    row.template_data = dict(template_data or {}) if isinstance(template_data, dict) else None
    row.full_bundle = dict(full_bundle or {}) if isinstance(full_bundle, dict) else None
    await session.flush()
    return _as_review_document_row(row)


async def delete_review_document_bundle(
    session: AsyncSession, *, review_document_id: str
) -> bool:
    try:
        rid = uuid.UUID(str(review_document_id))
    except ValueError:
        return False
    row = await session.get(ApplicationReviewDocument, rid)
    if row is None:
        return False
    await session.delete(row)
    await session.flush()
    return True


async def resolve_submitted_initiative_for_backup(
    session: AsyncSession,
    application_id: str,
) -> Optional[Tuple[Initiative, str]]:
    """
    Resolve initiative + public submission id (``sub-…``) the same way as ``get_application_by_id``.
    Used by the admin backup ZIP builder.
    """
    aid = (application_id or "").strip()
    if not aid:
        return None

    stmt = (
        select(Initiative)
        .where(Initiative.status != "draft")
        .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at))
    )
    initiatives = (await session.execute(stmt)).scalars().all()
    for initiative in initiatives:
        draft_stmt = (
            select(Draft)
            .where(Draft.initiative_id == initiative.id)
            .order_by(Draft.updated_at.desc())
            .limit(1)
        )
        draft = (await session.execute(draft_stmt)).scalar_one_or_none()
        payload = dict(draft.payload) if draft is not None and isinstance(draft.payload, dict) else {}
        stored = payload.get("submissionRecord") if isinstance(payload.get("submissionRecord"), dict) else {}
        if _submission_display_id(initiative, stored) == aid or initiative.case_code == aid:
            return initiative, _submission_display_id(initiative, stored)
    return None


async def get_application_by_id(session: AsyncSession, application_id: str) -> Optional[Dict[str, Any]]:
    """Return one application row (same shape as list items) or None."""
    aid = application_id.strip()
    if not aid:
        return None

    stmt = (
        select(Initiative)
        .where(Initiative.status != "draft")
        .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at))
    )
    initiatives = (await session.execute(stmt)).scalars().all()
    _iids = [i.id for i in initiatives]
    feedback_map = await _admin_feedback_by_initiative_ids(session, _iids)
    reviewer_map = await _admin_decision_reviewers_by_initiative_ids(session, _iids)

    for initiative in initiatives:
        draft_stmt = (
            select(Draft)
            .where(Draft.initiative_id == initiative.id)
            .order_by(Draft.updated_at.desc())
            .limit(1)
        )
        draft = (await session.execute(draft_stmt)).scalar_one_or_none()
        payload = dict(draft.payload) if draft is not None and isinstance(draft.payload, dict) else {}
        stored = payload.get("submissionRecord") if isinstance(payload.get("submissionRecord"), dict) else {}
        if _submission_display_id(initiative, stored) == aid or initiative.case_code == aid:
            row = _as_submission_item(initiative, payload)
            fb = feedback_map.get(initiative.id)
            if fb:
                row["nhan_xet"] = fb
            rev = reviewer_map.get(initiative.id)
            if rev is not None:
                row["reviewer"] = rev
            return row

    return None


def _matches_filters(
    row: Dict[str, Any],
    *,
    name: str,
    author_name: str,
    reviewer_name: str,
    status: str,
    review_status: str,
    date_from: str,
    date_to: str,
    lifecycle: str = "",
    skip_status_filter: bool = False,
) -> bool:
    row_status = str(row.get("status") or "")
    lc = (lifecycle or "").strip().lower()
    if lc == "inbox":
        if row_status in ("approved", "rejected"):
            return False
    elif lc == "decided":
        if row_status not in ("approved", "rejected"):
            return False
    n = name.strip().lower()
    if n and n not in str(row.get("name") or "").lower():
        return False
    an = author_name.strip().lower()
    author = row.get("author") or {}
    if an and an not in str(author.get("name") or "").lower():
        return False
    rn = reviewer_name.strip().lower()
    if rn:
        reviewer = row.get("reviewer") or {}
        if rn not in str(reviewer.get("name") or "").lower():
            return False
    if not skip_status_filter and status and row_status != status:
        return False
    if review_status and str(row.get("reviewStatus") or "") != review_status:
        return False
    sd = str(row.get("submittedDate") or "")
    sd_day = sd[:10] if len(sd) >= 10 else ""
    if date_from and sd_day and sd_day < date_from:
        return False
    if date_to and sd_day and sd_day > date_to:
        return False
    return True


def _sort_submission_pairs_in_place(
    pairs: List[Tuple[Dict[str, Any], Dict[str, Any]]],
    *,
    sort_by: str,
    sort_order: str,
) -> None:
    """Same ordering as GET /api/applications (after optional ``status`` query narrowing)."""
    reverse = sort_order != "asc"
    if sort_by == "name":
        pairs.sort(key=lambda x: str(x[0].get("name") or ""), reverse=reverse)
    elif sort_by == "author":
        pairs.sort(key=lambda x: str((x[0].get("author") or {}).get("name") or ""), reverse=reverse)
    else:
        pairs.sort(key=lambda x: str(x[0].get("submittedDate") or ""), reverse=reverse)


async def collect_submission_row_payload_pairs(
    session: AsyncSession,
    *,
    name: str,
    author_name: str,
    reviewer_name: str,
    review_status: str,
    date_from: str,
    date_to: str,
    lifecycle: str = "",
) -> List[Tuple[Dict[str, Any], Dict[str, Any]]]:
    """
    Submissions matching list filters (lifecycle, text, dates), before ``status`` query param
    and before sort. Each entry is ``(list_row, draft_payload_dict)``.
    """
    stmt = (
        select(Initiative)
        .where(Initiative.status != "draft")
        .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at))
    )
    initiatives = (await session.execute(stmt)).scalars().all()
    _iids = [i.id for i in initiatives]
    feedback_map = await _admin_feedback_by_initiative_ids(session, _iids)
    reviewer_map = await _admin_decision_reviewers_by_initiative_ids(session, _iids)

    pairs: List[Tuple[Dict[str, Any], Dict[str, Any]]] = []
    for initiative in initiatives:
        draft_stmt = (
            select(Draft)
            .where(Draft.initiative_id == initiative.id)
            .order_by(Draft.updated_at.desc())
            .limit(1)
        )
        draft = (await session.execute(draft_stmt)).scalar_one_or_none()
        payload = dict(draft.payload) if draft is not None and isinstance(draft.payload, dict) else {}
        row = _as_submission_item(initiative, payload)
        fb = feedback_map.get(initiative.id)
        if fb:
            row["nhan_xet"] = fb
        rev = reviewer_map.get(initiative.id)
        if rev is not None:
            row["reviewer"] = rev
        if not _matches_filters(
            row,
            name=name,
            author_name=author_name,
            reviewer_name=reviewer_name,
            status="",
            review_status=review_status,
            date_from=date_from,
            date_to=date_to,
            lifecycle=lifecycle,
            skip_status_filter=True,
        ):
            continue
        pairs.append((row, payload))

    return pairs


async def list_submitted_applications(
    session: AsyncSession,
    *,
    page: int,
    page_size: int,
    name: str,
    author_name: str,
    reviewer_name: str,
    status: str,
    review_status: str,
    date_from: str,
    date_to: str,
    sort_by: str,
    sort_order: str,
    lifecycle: str = "",
) -> Dict[str, Any]:
    pairs = await collect_submission_row_payload_pairs(
        session,
        name=name,
        author_name=author_name,
        reviewer_name=reviewer_name,
        review_status=review_status,
        date_from=date_from,
        date_to=date_to,
        lifecycle=lifecycle,
    )
    items = [r for r, _ in pairs]

    status_counts = {
        "approved": sum(1 for r in items if str(r.get("status") or "") == "approved"),
        "rejected": sum(1 for r in items if str(r.get("status") or "") == "rejected"),
    }
    if status:
        pairs = [(r, p) for r, p in pairs if str(r.get("status") or "") == status]
    _sort_submission_pairs_in_place(pairs, sort_by=sort_by, sort_order=sort_order)
    items = [r for r, _ in pairs]

    total = len(items)
    start = (page - 1) * page_size
    page_data = items[start : start + page_size]
    total_pages = max(1, (total + page_size - 1) // page_size) if total else 1

    return {
        "data": page_data,
        "pagination": {
            "page": page,
            "pageSize": page_size,
            "totalItems": total,
            "totalPages": total_pages,
        },
        "statusCounts": status_counts,
    }


async def submitted_applications_pairs_for_export(
    session: AsyncSession,
    *,
    name: str,
    author_name: str,
    reviewer_name: str,
    status: str,
    review_status: str,
    date_from: str,
    date_to: str,
    sort_by: str,
    sort_order: str,
    lifecycle: str = "",
) -> List[Tuple[Dict[str, Any], Dict[str, Any]]]:
    """(row, draft_payload) tuples after the same filters + sort as GET /api/applications (all pages)."""
    pairs = await collect_submission_row_payload_pairs(
        session,
        name=name,
        author_name=author_name,
        reviewer_name=reviewer_name,
        review_status=review_status,
        date_from=date_from,
        date_to=date_to,
        lifecycle=lifecycle,
    )
    if status:
        pairs = [(r, p) for r, p in pairs if str(r.get("status") or "") == status]
    _sort_submission_pairs_in_place(pairs, sort_by=sort_by, sort_order=sort_order)
    return pairs


async def list_my_submitted_applications(
    session: AsyncSession,
    user_id: uuid.UUID,
    user_email: str,
) -> List[Dict[str, Any]]:
    """
    Submissions for the logged-in applicant: owned initiatives, or legacy rows matched by author email.
    """
    stmt = (
        select(Initiative)
        .where(Initiative.status != "draft")
        .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at))
    )
    initiatives = (await session.execute(stmt)).scalars().all()
    _iids = [i.id for i in initiatives]
    feedback_map = await _admin_feedback_by_initiative_ids(session, _iids)
    reviewer_map = await _admin_decision_reviewers_by_initiative_ids(session, _iids)

    email_norm = user_email.strip().lower()
    seen: set[str] = set()
    out: List[Dict[str, Any]] = []

    for initiative in initiatives:
        draft_stmt = (
            select(Draft)
            .where(Draft.initiative_id == initiative.id)
            .order_by(Draft.updated_at.desc())
            .limit(1)
        )
        draft = (await session.execute(draft_stmt)).scalar_one_or_none()
        payload = dict(draft.payload) if draft is not None and isinstance(draft.payload, dict) else {}
        row = _as_submission_item(initiative, payload)
        fb = feedback_map.get(initiative.id)
        if fb:
            row["nhan_xet"] = fb
        rev = reviewer_map.get(initiative.id)
        if rev is not None:
            row["reviewer"] = rev
        rid = str(row.get("id") or "")
        if not rid or rid in seen:
            continue

        owns = initiative.owner_id == user_id
        auth_email = str((row.get("author") or {}).get("email") or "").strip().lower()
        legacy_email_match = bool(email_norm and auth_email and auth_email == email_norm)

        if owns or legacy_email_match:
            seen.add(rid)
            out.append(row)

    out.sort(key=lambda x: str(x.get("submittedDate") or ""), reverse=True)
    return out


def _applicant_may_mutate_row(
    initiative: Initiative,
    row: Dict[str, Any],
    user_id: uuid.UUID,
    user_email: str,
) -> bool:
    """Same visibility rule as list_my_submitted_applications: owner or legacy author email match."""
    if initiative.owner_id == user_id:
        return True
    email_norm = user_email.strip().lower()
    auth_email = str((row.get("author") or {}).get("email") or "").strip().lower()
    return bool(email_norm and auth_email and auth_email == email_norm)


async def _resolve_initiative_and_latest_draft_for_application_id(
    session: AsyncSession,
    application_id: str,
) -> tuple[Initiative, Draft]:
    aid = application_id.strip()
    if not aid:
        raise LookupError("missing_id")

    stmt = (
        select(Initiative)
        .where(Initiative.status != "draft")
        .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at))
    )
    initiatives = (await session.execute(stmt)).scalars().all()

    for initiative in initiatives:
        draft_stmt = (
            select(Draft)
            .where(Draft.initiative_id == initiative.id)
            .order_by(Draft.updated_at.desc())
            .limit(1)
        )
        draft = (await session.execute(draft_stmt)).scalar_one_or_none()
        if draft is None:
            continue
        payload = dict(draft.payload) if isinstance(draft.payload, dict) else {}
        stored = payload.get("submissionRecord") if isinstance(payload.get("submissionRecord"), dict) else {}
        disp = _submission_display_id(initiative, stored)
        if disp.lower() == aid.lower() or initiative.case_code == aid:
            return initiative, draft

    raise LookupError("not_found")


def _parse_submitted_date_input(submitted_date: str) -> datetime:
    """Accept `YYYY-MM-DD` (from HTML date input) or ISO strings."""
    s = submitted_date.strip()
    if len(s) >= 10 and s[4] == "-" and s[7] == "-":
        y, mo, d = int(s[0:4]), int(s[5:7]), int(s[8:10])
        return datetime(y, mo, d, 12, 0, 0, tzinfo=timezone.utc)
    raw = s.replace("Z", "+00:00")
    dt = datetime.fromisoformat(raw)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc)


async def update_my_submitted_application(
    session: AsyncSession,
    user_id: uuid.UUID,
    user_email: str,
    application_id: str,
    name: str,
    submitted_date: str,
) -> Dict[str, Any]:
    """
    Update display fields on a submitted initiative (submissionRecord + initiative.submitted_at).
    Raises LookupError if not found, PermissionError if caller cannot mutate this row.
    """
    initiative, draft = await _resolve_initiative_and_latest_draft_for_application_id(session, application_id)
    payload = dict(draft.payload) if isinstance(draft.payload, dict) else {}
    row = _as_submission_item(initiative, payload)
    if not _applicant_may_mutate_row(initiative, row, user_id, user_email):
        raise PermissionError("forbidden")

    new_name = name.strip() or str(row.get("name") or "Hồ sơ sáng kiến")
    submitted_at = _parse_submitted_date_input(submitted_date)
    initiative.submitted_at = submitted_at

    sr = dict(payload.get("submissionRecord") or {})
    sr["name"] = new_name
    sr["submittedDate"] = _iso_utc(submitted_at)
    payload["submissionRecord"] = sr
    payload["updatedAt"] = _iso_utc(_now_utc())
    draft.payload = payload
    draft.version = (draft.version or 0) + 1
    await session.flush()

    return _as_submission_item(initiative, payload)


async def delete_my_submitted_application(
    session: AsyncSession,
    user_id: uuid.UUID,
    user_email: str,
    application_id: str,
) -> None:
    """Delete initiative (cascades drafts). Raises LookupError / PermissionError."""
    initiative, draft = await _resolve_initiative_and_latest_draft_for_application_id(session, application_id)
    payload = dict(draft.payload) if isinstance(draft.payload, dict) else {}
    row = _as_submission_item(initiative, payload)
    if not _applicant_may_mutate_row(initiative, row, user_id, user_email):
        raise PermissionError("forbidden")

    await session.delete(initiative)
    await session.flush()
