"""Operational service — business logic for penjualan (sales shift report) module."""

from datetime import date
from decimal import Decimal

from sqlalchemy import select
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.operational import LaporanShift, PenjualanNozzle, StatusLaporan
from app.models.product import ProdukHarga
from app.models.role import AksiEnum, ModulEnum
from app.models.spbu import Nozzle
from app.repositories import operational_repository, role_repository
from app.schemas.operational import (
    KasPaymentInput,
    LaporanShiftDetailResponse,
    LaporanShiftResponse,
    PenjualanNozzleInput,
    PenjualanNozzleResponse,
    TellerInitResponse,
)


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

async def _upsert_penyetoran(db: AsyncSession, laporan: LaporanShift, created_by_user: object | None) -> None:
    """Auto-create or update the Penyetoran record linked to a LaporanShift."""
    from app.models.penyetoran import Penyetoran, StatusPenyetoran
    from decimal import Decimal as D
    from sqlalchemy import func

    # kas_Xk columns store BILL COUNTS — multiply by denomination to get rupiah
    jumlah_kas = (
        (laporan.kas_100k or D("0")) * D("100000") +
        (laporan.kas_50k or D("0")) * D("50000") +
        (laporan.kas_20k or D("0")) * D("20000") +
        (laporan.kas_10k or D("0")) * D("10000") +
        (laporan.kas_5k or D("0")) * D("5000") +
        (laporan.kas_2k or D("0")) * D("2000") +
        (laporan.kas_1k or D("0")) * D("1000") +
        (laporan.kas_logam or D("0"))  # direct rupiah
    )
    jumlah_non_kas = (
        (laporan.pembayaran_kartu or D("0")) +
        (laporan.pembayaran_qr or D("0")) +
        (laporan.pembayaran_instansi or D("0"))
    )
    # Use direct SQL to avoid lazy-loading the relationship after db.refresh
    total_result = await db.execute(
        select(func.sum(PenjualanNozzle.nilai))
        .where(PenjualanNozzle.laporan_shift_id == laporan.id)
    )
    total_penjualan = total_result.scalar() or D("0")

    result = await db.execute(
        select(Penyetoran).where(Penyetoran.laporan_shift_id == laporan.id)
    )
    penyetoran = result.scalar_one_or_none()

    if penyetoran is None:
        penyetoran = Penyetoran(
            spbu_id=laporan.spbu_id,
            laporan_shift_id=laporan.id,
            tanggal=laporan.tanggal,
            shift_id=laporan.shift_id,
            jumlah_kas=jumlah_kas,
            jumlah_non_kas=jumlah_non_kas,
            total_penjualan=total_penjualan,
            status=StatusPenyetoran.DRAFT,
            created_by_id=getattr(created_by_user, "id", None),
        )
        db.add(penyetoran)
    else:
        # Always update amounts. If already submitted/approved, reset to DRAFT
        # because the underlying penjualan data has changed.
        if penyetoran.status != StatusPenyetoran.DRAFT:
            penyetoran.status = StatusPenyetoran.DRAFT
            penyetoran.catatan = (
                f"[AUTO] Dikembalikan ke Draft karena data penjualan shift ini diubah. "
                f"Catatan sebelumnya: {penyetoran.catatan or '-'}"
            )
        penyetoran.jumlah_kas = jumlah_kas
        penyetoran.jumlah_non_kas = jumlah_non_kas
        penyetoran.total_penjualan = total_penjualan

    await db.commit()


def _apply_kas_to_laporan(laporan: LaporanShift, kas: KasPaymentInput) -> None:
    """Copy kas/payment fields from input schema onto the ORM object."""
    laporan.kas_100k = kas.kas_100k
    laporan.kas_50k = kas.kas_50k
    laporan.kas_20k = kas.kas_20k
    laporan.kas_10k = kas.kas_10k
    laporan.kas_5k = kas.kas_5k
    laporan.kas_2k = kas.kas_2k
    laporan.kas_1k = kas.kas_1k
    laporan.kas_logam = kas.kas_logam
    laporan.pembayaran_kartu = kas.pembayaran_kartu
    laporan.pembayaran_qr = kas.pembayaran_qr
    laporan.pembayaran_instansi = kas.pembayaran_instansi


def _build_laporan_response(laporan: LaporanShift) -> LaporanShiftResponse:
    """Build a summary LaporanShiftResponse from an ORM object."""
    rows = laporan.penjualan_nozzle or []
    total_volume = sum((r.volume for r in rows), Decimal("0"))
    total_nilai = sum((r.nilai for r in rows), Decimal("0"))
    return LaporanShiftResponse(
        id=laporan.id,
        spbu_id=laporan.spbu_id,
        shift_id=laporan.shift_id,
        shift_nama=laporan.shift.nama if laporan.shift else "",
        tanggal=laporan.tanggal,
        status=laporan.status,
        submitted_by_name=laporan.submitted_by.name if laporan.submitted_by else None,
        submitted_at=laporan.submitted_at,
        reviewed_by_name=laporan.reviewed_by.name if laporan.reviewed_by else None,
        reviewed_at=laporan.reviewed_at,
        catatan_review=laporan.catatan_review,
        recalled_by_name=laporan.recalled_by.name if laporan.recalled_by else None,
        recalled_at=laporan.recalled_at,
        unlocked_by_name=laporan.unlocked_by.name if laporan.unlocked_by else None,
        unlocked_at=laporan.unlocked_at,
        total_volume=total_volume,
        total_nilai=total_nilai,
        nozzle_count=len(rows),
        source_foto_url=laporan.source_foto_url,
        kas_100k=laporan.kas_100k or Decimal("0"),
        kas_50k=laporan.kas_50k or Decimal("0"),
        kas_20k=laporan.kas_20k or Decimal("0"),
        kas_10k=laporan.kas_10k or Decimal("0"),
        kas_5k=laporan.kas_5k or Decimal("0"),
        kas_2k=laporan.kas_2k or Decimal("0"),
        kas_1k=laporan.kas_1k or Decimal("0"),
        kas_logam=laporan.kas_logam or Decimal("0"),
        pembayaran_kartu=laporan.pembayaran_kartu or Decimal("0"),
        pembayaran_qr=laporan.pembayaran_qr or Decimal("0"),
        pembayaran_instansi=laporan.pembayaran_instansi or Decimal("0"),
    )


def _build_nozzle_response(row) -> PenjualanNozzleResponse:
    """Build an enriched PenjualanNozzleResponse from a PenjualanNozzle ORM row."""
    nozzle = row.nozzle
    produk = nozzle.produk if nozzle else None
    island = nozzle.island if nozzle else None
    return PenjualanNozzleResponse(
        id=row.id,
        nozzle_id=row.nozzle_id,
        nozzle_nama=nozzle.nama if nozzle else "",
        island_nama=island.nama if island else "",
        produk_id=produk.id if produk else 0,
        produk_nama=produk.nama if produk else "",
        produk_kode=produk.kode if produk else "",
        teller_awal_manual=row.teller_awal_manual,
        teller_akhir_manual=row.teller_akhir_manual,
        teller_awal_digital=row.teller_awal_digital,
        teller_akhir_digital=row.teller_akhir_digital,
        flag_reset_teller=row.flag_reset_teller,
        primary_teller=nozzle.primary_teller or "manual",
        volume=row.volume,
        harga_jual=row.harga_jual,
        nilai=row.nilai,
    )


def _build_detail_response(laporan: LaporanShift) -> LaporanShiftDetailResponse:
    """Build a full LaporanShiftDetailResponse including nozzle rows."""
    summary = _build_laporan_response(laporan)
    nozzle_responses = [_build_nozzle_response(row) for row in (laporan.penjualan_nozzle or [])]
    return LaporanShiftDetailResponse(
        **summary.model_dump(),
        nozzles=nozzle_responses,
        unlock_reason=laporan.unlock_reason,
    )


async def _resolve_harga(db: AsyncSession, produk_id: int, tanggal: date) -> Decimal:
    """Fetch the effective harga for a product on a given date (latest berlaku_mulai <= tanggal)."""
    from app.models.product import Produk
    result = await db.execute(
        select(ProdukHarga)
        .where(ProdukHarga.produk_id == produk_id, ProdukHarga.berlaku_mulai <= tanggal)
        .order_by(ProdukHarga.berlaku_mulai.desc())
        .limit(1)
    )
    row = result.scalar_one_or_none()
    if row is None:
        # Lookup product name for a clearer error message
        produk_result = await db.execute(select(Produk).where(Produk.id == produk_id))
        produk = produk_result.scalar_one_or_none()
        produk_label = f'"{produk.nama}"' if produk else f"id={produk_id}"
        raise ValueError(
            f"Belum ada harga untuk produk {produk_label} pada tanggal {tanggal}. "
            f"Silakan set harga produk terlebih dahulu di menu Products."
        )
    return row.harga


async def _process_nozzle_inputs(
    db: AsyncSession,
    nozzle_inputs: list[PenjualanNozzleInput],
    tanggal: date,
) -> list[dict]:
    """
    Validate and enrich each nozzle input into a dict ready for DB insertion.

    Volume is computed from the nozzle's primary_teller setting:
      - 'manual'  → teller_akhir_manual  - teller_awal_manual
      - 'digital' → teller_akhir_digital - teller_awal_digital

    Raises ValueError on validation failure.
    """
    rows: list[dict] = []
    for inp in nozzle_inputs:
        # Load nozzle to get produk_id and primary_teller
        result = await db.execute(
            select(Nozzle).where(Nozzle.id == inp.nozzle_id, Nozzle.deleted_at.is_(None))
        )
        nozzle = result.scalar_one_or_none()
        if nozzle is None:
            raise ValueError(f"Nozzle id={inp.nozzle_id} tidak ditemukan")

        # Validate teller readings for both manual and digital
        if not inp.flag_reset_teller:
            if inp.teller_akhir_manual < inp.teller_awal_manual:
                raise ValueError(
                    f"Nozzle id={inp.nozzle_id}: teller_akhir_manual ({inp.teller_akhir_manual}) "
                    f"harus >= teller_awal_manual ({inp.teller_awal_manual}) kecuali flag_reset_teller=True"
                )
            if inp.teller_akhir_digital < inp.teller_awal_digital:
                raise ValueError(
                    f"Nozzle id={inp.nozzle_id}: teller_akhir_digital ({inp.teller_akhir_digital}) "
                    f"harus >= teller_awal_digital ({inp.teller_awal_digital}) kecuali flag_reset_teller=True"
                )

        # Determine harga_jual
        if inp.harga_jual_override is not None:
            harga_jual = inp.harga_jual_override
        elif nozzle.produk_id is not None:
            harga_jual = await _resolve_harga(db, nozzle.produk_id, tanggal)
        else:
            raise ValueError(f"Nozzle id={inp.nozzle_id} tidak memiliki produk — tidak bisa menentukan harga")

        # Volume is always based on primary_teller setting of the nozzle
        primary = nozzle.primary_teller or "manual"
        if primary == "digital":
            volume = inp.teller_akhir_digital - inp.teller_awal_digital
        else:
            volume = inp.teller_akhir_manual - inp.teller_awal_manual

        nilai = volume * harga_jual

        rows.append({
            "nozzle_id": inp.nozzle_id,
            "teller_awal_manual": inp.teller_awal_manual,
            "teller_akhir_manual": inp.teller_akhir_manual,
            "teller_awal_digital": inp.teller_awal_digital,
            "teller_akhir_digital": inp.teller_akhir_digital,
            "flag_reset_teller": inp.flag_reset_teller,
            "volume": volume,
            "harga_jual": harga_jual,
            "nilai": nilai,
        })
    return rows


# ---------------------------------------------------------------------------
# Internal helpers (continued)
# ---------------------------------------------------------------------------

async def _user_can_approve(db: AsyncSession, user, spbu_id: int) -> bool:
    """
    Return True if user has penjualan:approve permission for this SPBU.
    Superadmin always can. Regular users must have the permission via their role assignment.
    """
    if user.is_superadmin:
        return True
    # Find the assignment for this SPBU
    assignment = next((a for a in (user.assignments or []) if a.spbu_id == spbu_id), None)
    if assignment is None:
        return False
    return await role_repository.has_permission(
        db, assignment.role_id, ModulEnum.penjualan, AksiEnum.approve
    )


# ---------------------------------------------------------------------------
# Public service functions
# ---------------------------------------------------------------------------

async def list_laporan(
    db: AsyncSession,
    spbu_id: int,
    tanggal=None,
    shift_id: int | None = None,
    status: StatusLaporan | None = None,
    skip: int = 0,
    limit: int = 20,
) -> tuple[list[LaporanShiftResponse], int]:
    """Return a paginated list of LaporanShiftResponse for an SPBU."""
    laporan_list, total = await operational_repository.get_all_laporan(
        db, spbu_id, tanggal, shift_id, status, skip, limit
    )
    return [_build_laporan_response(l) for l in laporan_list], total


async def get_laporan_detail(
    db: AsyncSession, spbu_id: int, laporan_id: int
) -> LaporanShiftDetailResponse:
    """Return a fully enriched LaporanShiftDetailResponse."""
    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    if laporan is None:
        raise ValueError("Laporan tidak ditemukan")
    return _build_detail_response(laporan)


async def create_laporan(
    db: AsyncSession,
    spbu_id: int,
    shift_id: int,
    tanggal: date,
    nozzle_inputs: list[PenjualanNozzleInput],
    submitter_user: object,  # full User ORM object (needs .id, .is_superadmin, .assignments)
    source_foto_url: str | None = None,
    force_draft: bool = False,
    kas: "KasPaymentInput | None" = None,
) -> LaporanShiftDetailResponse:
    """
    Create a new LaporanShift.

    - Operators → saved as DRAFT (status=DRAFT, submitted_by not set yet)
    - Admins (penjualan:approve permission) → saved & immediately APPROVED in one step
      (submitted_by = reviewed_by = the admin, audit trail preserved)

    Raises ValueError if a laporan for the same spbu+shift+tanggal already exists,
    or on any nozzle validation failure.
    """
    from sqlalchemy import and_
    dup = await db.execute(
        select(LaporanShift).where(
            and_(
                LaporanShift.spbu_id == spbu_id,
                LaporanShift.shift_id == shift_id,
                LaporanShift.tanggal == tanggal,
            )
        )
    )
    if dup.scalar_one_or_none() is not None:
        raise ValueError(
            f"Laporan untuk shift_id={shift_id} pada tanggal {tanggal} sudah ada"
        )

    nozzle_rows = await _process_nozzle_inputs(db, nozzle_inputs, tanggal)

    can_approve = (await _user_can_approve(db, submitter_user, spbu_id)) and not force_draft

    try:
        laporan = await operational_repository.create_laporan(db, spbu_id, shift_id, tanggal, nozzle_rows)

        if source_foto_url:
            laporan.source_foto_url = source_foto_url

        if kas:
            _apply_kas_to_laporan(laporan, kas)

        # Opsi B: if user has approve permission → immediately approve (skip SUBMITTED step)
        if can_approve:
            from datetime import datetime, timezone
            laporan = await operational_repository.update_status(
                db, laporan, StatusLaporan.APPROVED,
                user_id=submitter_user.id,  # type: ignore[union-attr]
                catatan="Auto-approved oleh admin saat input",
            )
            # Also stamp submitted_by so audit trail is complete
            laporan.submitted_by_id = submitter_user.id  # type: ignore[union-attr]
            laporan.submitted_at = datetime.now(timezone.utc)

        await db.commit()
    except IntegrityError:
        await db.rollback()
        raise ValueError("Duplicate atau constraint error")
    except SQLAlchemyError:
        await db.rollback()
        raise

    # Auto-create/update penyetoran — outside the main try so its failure
    # doesn't roll back the already-committed laporan.
    await _upsert_penyetoran(db, laporan, submitter_user)

    # Reload with full relationships (db.refresh expires lazy attrs → greenlet error)
    laporan = await operational_repository.get_laporan_by_id(db, laporan.id, spbu_id)
    return _build_detail_response(laporan)


async def delete_laporan(db: AsyncSession, spbu_id: int, laporan_id: int) -> None:
    """Hard-delete a DRAFT laporan (and nozzle rows via cascade). Non-draft → ValueError."""
    from sqlalchemy import delete as sql_delete
    from app.utils.file_upload import delete_file
    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    if laporan is None:
        raise ValueError("Laporan tidak ditemukan")
    if laporan.status != StatusLaporan.DRAFT:
        raise ValueError("Hanya laporan berstatus Draft yang bisa dihapus")
    foto_url = laporan.source_foto_url  # capture before delete
    try:
        await db.execute(sql_delete(PenjualanNozzle).where(PenjualanNozzle.laporan_shift_id == laporan_id))
        await db.delete(laporan)
        await db.commit()
    except SQLAlchemyError:
        await db.rollback()
        raise
    await delete_file(foto_url)


async def update_laporan(
    db: AsyncSession,
    spbu_id: int,
    laporan_id: int,
    nozzle_inputs: list[PenjualanNozzleInput],
    source_foto_url: str | None = None,
    kas: "KasPaymentInput | None" = None,
) -> LaporanShiftDetailResponse:
    """
    Replace nozzle rows on a DRAFT laporan.
    Raises ValueError if laporan not found, wrong SPBU, or not in DRAFT status.
    """
    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    if laporan is None:
        raise ValueError("Laporan tidak ditemukan")
    if laporan.spbu_id != spbu_id:
        raise ValueError("Laporan tidak termasuk SPBU ini")
    if laporan.status != StatusLaporan.DRAFT:
        raise ValueError("Hanya laporan berstatus DRAFT yang dapat diedit")

    nozzle_rows = await _process_nozzle_inputs(db, nozzle_inputs, laporan.tanggal)

    try:
        laporan = await operational_repository.update_laporan_nozzles(db, laporan, nozzle_rows)

        if source_foto_url:
            laporan.source_foto_url = source_foto_url

        if kas:
            _apply_kas_to_laporan(laporan, kas)

        await db.commit()
    except IntegrityError:
        await db.rollback()
        raise ValueError("Duplicate atau constraint error")
    except SQLAlchemyError:
        await db.rollback()
        raise

    # Auto-update penyetoran — outside the main try so its failure
    # doesn't roll back the already-committed laporan.
    await _upsert_penyetoran(db, laporan, None)

    # Reload with full relationships (db.refresh expires lazy attrs → greenlet error)
    laporan = await operational_repository.get_laporan_by_id(db, laporan.id, spbu_id)
    return _build_detail_response(laporan)


async def submit_laporan(
    db: AsyncSession, spbu_id: int, laporan_id: int, user_id: int
) -> LaporanShiftDetailResponse:
    """Transition a DRAFT laporan to SUBMITTED."""
    from decimal import Decimal as D
    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    if laporan is None:
        raise ValueError("Laporan tidak ditemukan")
    if laporan.status != StatusLaporan.DRAFT:
        raise ValueError("Hanya laporan berstatus DRAFT yang dapat di-submit")

    # Validate kas grand total == total penjualan
    # kas_Xk columns store BILL COUNTS — multiply by denomination to get rupiah
    grand_total = (
        (laporan.kas_100k or D("0")) * D("100000") +
        (laporan.kas_50k or D("0")) * D("50000") +
        (laporan.kas_20k or D("0")) * D("20000") +
        (laporan.kas_10k or D("0")) * D("10000") +
        (laporan.kas_5k or D("0")) * D("5000") +
        (laporan.kas_2k or D("0")) * D("2000") +
        (laporan.kas_1k or D("0")) * D("1000") +
        (laporan.kas_logam or D("0")) +  # direct rupiah
        (laporan.pembayaran_kartu or D("0")) +
        (laporan.pembayaran_qr or D("0")) +
        (laporan.pembayaran_instansi or D("0"))
    )
    total_penjualan = sum((r.nilai for r in laporan.penjualan_nozzle or []), D("0"))
    if abs(grand_total - total_penjualan) >= D("1"):
        selisih = grand_total - total_penjualan
        label = "Lebih" if selisih > 0 else "Kurang"
        raise ValueError(
            f"Grand Total Kas ({grand_total:,.0f}) tidak sama dengan Total Penjualan ({total_penjualan:,.0f}). "
            f"{label} Rp {abs(selisih):,.0f}. Koreksi data kas sebelum submit."
        )

    try:
        laporan = await operational_repository.update_status(
            db, laporan, StatusLaporan.SUBMITTED, user_id=user_id
        )
        await db.commit()
    except IntegrityError:
        await db.rollback()
        raise ValueError("Duplicate atau constraint error")
    except SQLAlchemyError:
        await db.rollback()
        raise

    # Reload with full relationships needed for anomali checks (async can't lazy-load)
    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)

    # Run anomali detection checks (non-blocking — does not raise on failure)
    from app.utils.anomali import run_penjualan_checks
    await run_penjualan_checks(db, laporan)

    from app.utils.audit import log_action
    await log_action(db, user_id=user_id, spbu_id=spbu_id, aksi="submit", modul="penjualan", object_id=laporan_id)

    # Commit anomali records and audit log entry
    await db.commit()

    return _build_detail_response(laporan)


async def recall_laporan(
    db: AsyncSession, spbu_id: int, laporan_id: int, user_id: int
) -> LaporanShiftDetailResponse:
    """
    Allow an operator to pull a SUBMITTED laporan back to DRAFT.
    Only valid while status is still SUBMITTED (admin has not acted yet).
    Clears submitted_by/submitted_at so it can be corrected and re-submitted.
    """
    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    if laporan is None:
        raise ValueError("Laporan tidak ditemukan")
    if laporan.status != StatusLaporan.SUBMITTED:
        raise ValueError("Recall hanya bisa dilakukan pada laporan berstatus SUBMITTED")

    try:
        laporan = await operational_repository.update_status(
            db, laporan, StatusLaporan.DRAFT, user_id=user_id, recall=True
        )
        await db.commit()
    except IntegrityError:
        await db.rollback()
        raise ValueError("Duplicate atau constraint error")
    except SQLAlchemyError:
        await db.rollback()
        raise

    from app.utils.audit import log_action
    await log_action(db, user_id=user_id, spbu_id=spbu_id, aksi="recall", modul="penjualan", object_id=laporan_id)

    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    return _build_detail_response(laporan)


async def review_laporan(
    db: AsyncSession,
    spbu_id: int,
    laporan_id: int,
    user_id: int,
    action: str,
    catatan: str | None,
) -> LaporanShiftDetailResponse:
    """Approve or reject a SUBMITTED laporan."""
    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    if laporan is None:
        raise ValueError("Laporan tidak ditemukan")
    if laporan.status != StatusLaporan.SUBMITTED:
        raise ValueError("Hanya laporan berstatus SUBMITTED yang dapat di-review")

    new_status = StatusLaporan.APPROVED if action == "approve" else StatusLaporan.REJECTED

    try:
        laporan = await operational_repository.update_status(
            db, laporan, new_status, user_id=user_id, catatan=catatan
        )
        await db.commit()
    except IntegrityError:
        await db.rollback()
        raise ValueError("Duplicate atau constraint error")
    except SQLAlchemyError:
        await db.rollback()
        raise

    from app.utils.audit import log_action
    await log_action(db, user_id=user_id, spbu_id=spbu_id, aksi=action, modul="penjualan", object_id=laporan_id,
                     detail={"catatan": catatan} if catatan else None)
    await db.commit()

    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    return _build_detail_response(laporan)


async def lock_laporan(
    db: AsyncSession, spbu_id: int, laporan_id: int
) -> LaporanShiftDetailResponse:
    """Lock an APPROVED laporan (no further edits)."""
    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    if laporan is None:
        raise ValueError("Laporan tidak ditemukan")
    if laporan.status != StatusLaporan.APPROVED:
        raise ValueError("Hanya laporan berstatus APPROVED yang dapat di-lock")

    try:
        laporan = await operational_repository.update_status(db, laporan, StatusLaporan.LOCKED)
        await db.commit()
    except IntegrityError:
        await db.rollback()
        raise ValueError("Duplicate atau constraint error")
    except SQLAlchemyError:
        await db.rollback()
        raise

    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    return _build_detail_response(laporan)


async def unlock_laporan(
    db: AsyncSession, spbu_id: int, laporan_id: int, user_id: int, alasan: str
) -> LaporanShiftDetailResponse:
    """Reopen an APPROVED or LOCKED laporan back to DRAFT, recording the reason."""
    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    if laporan is None:
        raise ValueError("Laporan tidak ditemukan")
    if laporan.status not in (StatusLaporan.APPROVED, StatusLaporan.LOCKED):
        raise ValueError("Hanya laporan berstatus APPROVED atau LOCKED yang dapat di-unlock")

    try:
        from datetime import datetime, timezone
        laporan = await operational_repository.update_status(
            db, laporan, StatusLaporan.DRAFT, unlock_reason=alasan
        )
        laporan.unlocked_by_id = user_id
        laporan.unlocked_at = datetime.now(timezone.utc)
        await db.commit()
    except IntegrityError:
        await db.rollback()
        raise ValueError("Duplicate atau constraint error")
    except SQLAlchemyError:
        await db.rollback()
        raise

    from app.utils.audit import log_action
    await log_action(db, user_id=user_id, spbu_id=spbu_id, aksi="unlock", modul="penjualan", object_id=laporan_id,
                     detail={"alasan": alasan})
    await db.commit()

    laporan = await operational_repository.get_laporan_by_id(db, laporan_id, spbu_id)
    return _build_detail_response(laporan)


async def check_user_can_approve(db: AsyncSession, user, spbu_id: int) -> bool:
    """Public wrapper — returns True if user can approve penjualan for this SPBU."""
    return await _user_can_approve(db, user, spbu_id)


async def get_teller_init(
    db: AsyncSession, spbu_id: int, tanggal: "date", shift_id: int
) -> list[TellerInitResponse]:
    """Return teller_awal pre-fill from the previous shift's submitted/approved laporan.

    Previous shift = shift before shift_id ordered by jam_mulai.
    If shift_id is the first shift of the day → last shift of (tanggal - 1).
    If no qualifying laporan found → is_first_time=True for all nozzles.
    """
    from datetime import date, timedelta
    from sqlalchemy import select as sa_select
    from app.models.spbu import Shift

    # Get all active shifts for this SPBU ordered by jam_mulai
    shift_result = await db.execute(
        sa_select(Shift)
        .where(Shift.spbu_id == spbu_id, Shift.is_active.is_(True), Shift.deleted_at.is_(None))
        .order_by(Shift.jam_mulai)
    )
    shifts = list(shift_result.scalars().all())

    if not shifts:
        # No shifts configured — return empty
        return []

    # Find index of requested shift
    shift_ids = [s.id for s in shifts]
    try:
        idx = shift_ids.index(shift_id)
    except ValueError:
        idx = 0  # fallback: treat as first shift

    if idx > 0:
        prev_shift_id = shifts[idx - 1].id
        prev_tanggal = tanggal
    else:
        # First shift of the day → previous is last shift of previous date
        prev_shift_id = shifts[-1].id
        prev_tanggal = tanggal - timedelta(days=1)

    rows = await operational_repository.get_teller_init(db, spbu_id, prev_tanggal, prev_shift_id)
    return [
        TellerInitResponse(
            nozzle_id=r["nozzle_id"],
            teller_awal_manual=r["teller_terakhir_manual"] if r["teller_terakhir_manual"] is not None else Decimal("0"),
            teller_awal_digital=r["teller_terakhir_digital"] if r["teller_terakhir_digital"] is not None else Decimal("0"),
            primary_teller=r["primary_teller"],
            is_first_time=r["is_first_time"],
        )
        for r in rows
    ]
