import { prisma } from "@/core/prisma";
import { AppError } from "@/core/AppError";
import { trackedTransaction } from "@/core/tracked";
import { Prisma } from "@prisma/client";
import type {
    CashDrawerImpactMode,
    CashSessionHistoryQueryInput,
    CashSessionCountBreakdownInput,
    CashSessionCountBreakdownDto,
    CashSessionDto,
    CashSessionOperationDto,
    CreateDepositRefundInput,
    CreateCashSessionDraftInput,
    CreateCashSessionOperationInput,
    CreateCashSessionOperationResultDto,
    FinalizeCashSessionDraftInput,
    OpenCashSessionInput,
    UpdateCashSessionInput,
} from "ttm-shared";
import {
    ensureShopUserCanAccessStation,
    ensureShopUserHasPermission,
    getShopUserPermissionContext,
    type ShopUserPermissionContext,
} from "../shop-user/shop-user-permissions.service";

const SESSION_OPERATION_INCLUDE = {
    createdBy: { select: { id: true, displayName: true, username: true, email: true } },
    depositLines: true,
} as const;

const SESSION_INCLUDE_BASE = {
    station: true,
    openedBy: { select: { id: true, displayName: true, username: true, email: true } },
    closedBy: { select: { id: true, displayName: true, username: true, email: true } },
    orders: {
        select: {
            id: true,
            payments: {
                select: {
                    amount: true,
                    configSnapshot: true,
                }
            }
        }
    },
} as const;

const SESSION_INCLUDE = {
    ...SESSION_INCLUDE_BASE,
    operations: {
        select: {
            amountDelta: true,
            countsTowardExpectedCash: true,
        }
    },
} as const;

const CASH_DRAWER_IMPACT_SIGNS: Record<CashDrawerImpactMode, number> = {
    NONE: 0,
    INCREASE: 1,
    DECREASE: -1,
};

let hasWarnedMissingCashSessionOperationTable = false;

function isMissingCashSessionOperationTableError(error: unknown) {
    if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false;
    if (error.code !== "P2021") return false;

    const details = `${error.message} ${JSON.stringify(error.meta ?? {})}`.toLowerCase();
    return details.includes("cashsessionoperation")
        || details.includes("cash_session_operation")
        || details.includes("cash_session_operations");
}

function warnMissingCashSessionOperationTable() {
    if (hasWarnedMissingCashSessionOperationTable) return;
    hasWarnedMissingCashSessionOperationTable = true;
    console.warn("[cashSession] CashSessionOperation table missing. Falling back to compatibility mode without operation history.");
}

async function withOptionalOperationHistory<T>(
    runWithHistory: () => Promise<T>,
    runWithoutHistory: () => Promise<T>,
): Promise<T> {
    try {
        return await runWithHistory();
    } catch (error) {
        if (!isMissingCashSessionOperationTableError(error)) {
            throw error;
        }

        warnMissingCashSessionOperationTable();
        return runWithoutHistory();
    }
}

async function createOperationIfAvailable<T>(createOperation: () => Promise<T>): Promise<T | null> {
    try {
        return await createOperation();
    } catch (error) {
        if (!isMissingCashSessionOperationTableError(error)) {
            throw error;
        }

        warnMissingCashSessionOperationTable();
        return null;
    }
}

function createMissingOperationHistoryAppError() {
    return new AppError(
        "L'historique des mouvements de caisse n'est pas disponible tant que la migration Prisma n'a pas été appliquée.",
        503,
    );
}

function resolveActorName(user: { displayName?: string | null; username?: string | null; email?: string | null } | null | undefined) {
    return user?.displayName ?? user?.username ?? user?.email ?? null;
}

function normalizeOptionalText(value?: string | null) {
    const normalized = value?.trim();
    return normalized ? normalized : null;
}

function normalizeCountingBreakdown(input?: CashSessionCountBreakdownInput | null): CashSessionCountBreakdownDto | null {
    if (!input) return null;

    const entries = (input.entries ?? [])
        .filter((entry: CashSessionCountBreakdownInput["entries"][number]) => entry.quantity > 0)
        .map((entry: CashSessionCountBreakdownInput["entries"][number]) => ({
            denomination: entry.denomination,
            quantity: entry.quantity,
            kind: entry.kind,
        }));

    const total = entries.reduce((sum: number, entry) => sum + (entry.denomination * entry.quantity), 0) + (input.freeAmount ?? 0);

    return {
        entries,
        freeAmount: input.freeAmount ?? 0,
        total,
    };
}

function normalizeOpeningInput(input: { openingCash?: number; counting?: CashSessionCountBreakdownInput | null }) {
    const openingCountBreakdown = normalizeCountingBreakdown(input.counting ?? null);

    return {
        openingCash: openingCountBreakdown?.total ?? Math.max(0, input.openingCash ?? 0),
        openingCountBreakdown,
    };
}

function readImpactModeFromSnapshot(configSnapshot: unknown): CashDrawerImpactMode {
    const mode = typeof configSnapshot === "object" && configSnapshot !== null
        ? (configSnapshot as Record<string, unknown>).cashDrawerImpactMode
        : null;

    if (mode === "INCREASE" || mode === "DECREASE") return mode;
    return "NONE";
}

function computePaymentImpact(session: any): number {
    const payments = (session.orders ?? []).flatMap((order: any) => order.payments ?? []);
    return payments.reduce((sum: number, payment: any) => {
        const mode = readImpactModeFromSnapshot(payment.configSnapshot);
        return sum + ((CASH_DRAWER_IMPACT_SIGNS[mode] ?? 0) * (payment.amount ?? 0));
    }, 0);
}

function computeManualImpact(session: any): number {
    const operations = (session.operations ?? []).filter((operation: any) => operation.countsTowardExpectedCash);
    return operations.reduce((sum: number, operation: any) => sum + (operation.amountDelta ?? 0), 0);
}

function computeExpectedCash(session: any): number {
    return (session.openingCash ?? 0) + computePaymentImpact(session) + computeManualImpact(session);
}

async function findActiveCashStation(shopId: string, stationId: string) {
    const station = await prisma.station.findFirst({
        where: { id: stationId, shopId, active: true }
    });

    if (!station) {
        throw new AppError("Station not found", 404);
    }

    if (station.mainType !== "CASH") {
        throw new AppError("Only CASH stations can have cash sessions", 400);
    }

    return station;
}

async function resolveCashSessionActor(shopId: string, userId: string) {
    return getShopUserPermissionContext(shopId, userId)
}

function ensureActorCanAccessCashStation(
    actor: ShopUserPermissionContext,
    stationId: string | null | undefined,
) {
    ensureShopUserCanAccessStation(actor, stationId, "Cette caisse n'est pas attribuée à ce compte.")
}

function ensureActorCanOpenCashSession(actor: ShopUserPermissionContext, stationId: string | null | undefined) {
    ensureActorCanAccessCashStation(actor, stationId)
    ensureShopUserHasPermission(actor, "cash_session.open", "Tu n'es pas autorisé à ouvrir la caisse de cette station.")
}

function ensureActorCanCloseCashSession(actor: ShopUserPermissionContext, stationId: string | null | undefined) {
    ensureActorCanAccessCashStation(actor, stationId)
    ensureShopUserHasPermission(actor, "cash_session.close", "Tu n'es pas autorisé à fermer la caisse de cette station.")
}

function ensureActorCanReopenCashSession(actor: ShopUserPermissionContext, stationId: string | null | undefined) {
    ensureActorCanAccessCashStation(actor, stationId)
    ensureShopUserHasPermission(actor, "cash_session.reopen", "Tu n'es pas autorisé à réouvrir la caisse de cette station.")
}

function ensureActorCanCreateManualCashOperation(actor: ShopUserPermissionContext, stationId: string | null | undefined) {
    ensureActorCanAccessCashStation(actor, stationId)
    ensureShopUserHasPermission(actor, "cash_session.manual_operation.create", "Tu n'es pas autorisé à enregistrer un mouvement manuel sur cette caisse.")
}

function ensureActorCanCreateDepositRefund(actor: ShopUserPermissionContext, stationId: string | null | undefined) {
    ensureActorCanAccessCashStation(actor, stationId)
    ensureShopUserHasPermission(actor, "cash_session.deposit_refund.create", "Tu n'es pas autorisé à enregistrer un retour de consigne sur cette caisse.")
}

function ensureActorCanUndoDepositRefund(actor: ShopUserPermissionContext, stationId: string | null | undefined) {
    ensureActorCanAccessCashStation(actor, stationId)
    ensureShopUserHasPermission(actor, "cash_session.deposit_refund.undo_own", "Tu n'es pas autorisé à annuler un retour de consigne sur cette caisse.")
}

function buildAccessibleCashSessionWhere(actor: ShopUserPermissionContext) {
    if (actor.role === "MANAGER") {
        return {}
    }

    if (actor.stationIds.length === 0) {
        return {
            stationId: {
                in: ["__no_assigned_cash_station__"],
            },
        }
    }

    return {
        stationId: {
            in: actor.stationIds,
        },
    }
}

async function findOwnedDraft(shopId: string, userId: string, stationId?: string) {
    return withOptionalOperationHistory(
        () => prisma.cashSession.findFirst({
            where: {
                shopId,
                openedById: userId,
                status: "DRAFT",
                ...(stationId ? { stationId } : {}),
            },
            include: SESSION_INCLUDE,
            orderBy: [{ openedAt: "desc" }, { id: "desc" }],
        }),
        () => prisma.cashSession.findFirst({
            where: {
                shopId,
                openedById: userId,
                status: "DRAFT",
                ...(stationId ? { stationId } : {}),
            },
            include: SESSION_INCLUDE_BASE,
            orderBy: [{ openedAt: "desc" }, { id: "desc" }],
        }),
    );
}

async function findOwnedDraftById(shopId: string, userId: string, sessionId: string) {
    return withOptionalOperationHistory(
        () => prisma.cashSession.findFirst({
            where: { id: sessionId, shopId, openedById: userId, status: "DRAFT" },
            include: SESSION_INCLUDE,
        }),
        () => prisma.cashSession.findFirst({
            where: { id: sessionId, shopId, openedById: userId, status: "DRAFT" },
            include: SESSION_INCLUDE_BASE,
        }),
    );
}

async function createOpenSession(
    shopId: string,
    userId: string,
    station: { id: string; name: string | null },
    input: OpenCashSessionInput,
    senderSocketId?: string,
) {
    const { openingCash, openingCountBreakdown } = normalizeOpeningInput(input);

    const created = await trackedTransaction(shopId, "CashSession", async (t, tx: any) => {
        const session = await t.create<any>("cashSession", {
            shopId,
            stationId: station.id,
            openedById: userId,
            status: "OPEN",
            openedAt: new Date(),
            openingCash,
            openingCountBreakdown: openingCountBreakdown as any,
            closingCash: null,
            closingCountBreakdown: null,
        } as any, {
            include: SESSION_INCLUDE,
        },
        "cash_session.opened",
        {
            openingCash,
            openingCountBreakdown,
            openedBy: userId,
            stationId: station.id,
            stationName: station.name,
        });

        await createOperationIfAvailable(() => tx.cashSessionOperation.create({
            data: {
                shopId,
                cashSessionId: session.id,
                createdById: userId,
                type: "OPENING",
                source: openingCountBreakdown ? "Ouverture avec comptage" : "Ouverture manuelle",
                note: null,
                amountDelta: openingCash,
                countsTowardExpectedCash: false,
                resultingExpectedCash: openingCash,
                metadata: {
                    openingCash,
                    openingCountBreakdown,
                    stationId: station.id,
                    stationName: station.name,
                },
            },
        }));

        return getSessionByIdFromTx(tx, session.id);
    }, { actorUserId: userId, senderSocketId });

    return toCashSessionDto(created);
}

async function finalizeDraftSession(
    shopId: string,
    userId: string,
    draft: any,
    input: FinalizeCashSessionDraftInput,
    senderSocketId?: string,
) {
    const station = await findActiveCashStation(shopId, draft.stationId);
    const { openingCash, openingCountBreakdown } = normalizeOpeningInput(input);

    const finalized = await trackedTransaction(shopId, "CashSession", async (t, tx: any) => {
        await t.update("cashSession", { id: draft.id }, {
            status: "OPEN",
            openedAt: new Date(),
            openingCash,
            openingCountBreakdown: openingCountBreakdown as any,
            closedById: null,
            closedAt: null,
            closingCash: null,
            closingCountBreakdown: null,
        } as any, {
            include: SESSION_INCLUDE_BASE,
        }, "cash_session.opened", {
            openingCash,
            openingCountBreakdown,
            openedBy: userId,
            stationId: station.id,
            stationName: station.name,
            fromDraft: true,
            draftSessionId: draft.id,
        });

        await createOperationIfAvailable(() => tx.cashSessionOperation.create({
            data: {
                shopId,
                cashSessionId: draft.id,
                createdById: userId,
                type: "OPENING",
                source: openingCountBreakdown ? "Ouverture via brouillon avec comptage" : "Ouverture via brouillon",
                note: null,
                amountDelta: openingCash,
                countsTowardExpectedCash: false,
                resultingExpectedCash: openingCash,
                metadata: {
                    openingCash,
                    openingCountBreakdown,
                    stationId: station.id,
                    stationName: station.name,
                    fromDraft: true,
                },
            },
        }));

        return getSessionByIdFromTx(tx, draft.id);
    }, { actorUserId: userId, senderSocketId });

    return toCashSessionDto(finalized);
}

function toCashSessionOperationDto(row: any): CashSessionOperationDto {
    const linkedOrderId = typeof row.metadata?.linkedOrderId === "string"
        ? row.metadata.linkedOrderId
        : null;

    return {
        id: row.id,
        cashSessionId: row.cashSessionId,
        shopId: row.shopId ?? null,
        type: row.type,
        createdById: row.createdById ?? null,
        linkedOrderId,
        depositLines: (row.depositLines ?? []).map((line: any) => ({
            id: line.id,
            depositDefinitionId: line.depositDefinitionId,
            label: line.labelSnapshot,
            unitAmount: line.unitAmountSnapshot,
            quantity: line.quantity,
            totalAmount: line.totalAmount,
        })),
        source: row.source ?? null,
        note: row.note ?? null,
        amountDelta: row.amountDelta ?? null,
        countsTowardExpectedCash: Boolean(row.countsTowardExpectedCash),
        resultingExpectedCash: row.resultingExpectedCash ?? null,
        createdAt: row.createdAt.toISOString(),
        createdByName: resolveActorName(row.createdBy),
    };
}

function toCashSessionDto(row: any): CashSessionDto {
    const paymentImpactTotal = computePaymentImpact(row);
    const manualImpactTotal = computeManualImpact(row);
    const expectedCash = (row.openingCash ?? 0) + paymentImpactTotal + manualImpactTotal;
    const openingCountBreakdown = normalizeCountingBreakdown((row.openingCountBreakdown as CashSessionCountBreakdownInput | null | undefined) ?? null);
    const closingCountBreakdown = normalizeCountingBreakdown((row.closingCountBreakdown as CashSessionCountBreakdownInput | null | undefined) ?? null);

    return {
        id: row.id,
        shopId: row.shopId ?? null,
        status: row.status,
        openingCash: row.openingCash ?? 0,
        openingCountBreakdown,
        closingCash: row.closingCash ?? null,
        openedAt: row.openedAt ? row.openedAt.toISOString() : null,
        closedAt: row.closedAt ? row.closedAt.toISOString() : null,
        nextOrderNumber: row.nextOrderNumber ?? null,
        stationId: row.stationId ?? row.station?.id ?? null,
        stationName: row.station?.name ?? null,
        openedByName: resolveActorName(row.openedBy),
        closedByName: resolveActorName(row.closedBy),
        orderCount: (row.orders ?? []).length,
        expectedCash,
        paymentImpactTotal,
        manualImpactTotal,
        difference: row.closingCash != null ? row.closingCash - expectedCash : null,
        closingCountBreakdown,
    };
}

async function getSessionById(shopId: string, sessionId: string) {
    return withOptionalOperationHistory(
        () => prisma.cashSession.findFirst({
            where: { id: sessionId, shopId },
            include: SESSION_INCLUDE,
        }),
        () => prisma.cashSession.findFirst({
            where: { id: sessionId, shopId },
            include: SESSION_INCLUDE_BASE,
        }),
    );
}

async function getSessionOperationsById(shopId: string, sessionId: string) {
    return withOptionalOperationHistory(
        () => prisma.cashSessionOperation.findMany({
            where: { shopId, cashSessionId: sessionId },
            include: SESSION_OPERATION_INCLUDE,
            orderBy: [{ createdAt: "desc" }, { id: "desc" }],
        }),
        async () => [],
    );
}

async function getSessionByIdFromTx(tx: any, sessionId: string) {
    return withOptionalOperationHistory(
        () => tx.cashSession.findUnique({
            where: { id: sessionId },
            include: SESSION_INCLUDE,
        }),
        () => tx.cashSession.findUnique({
            where: { id: sessionId },
            include: SESSION_INCLUDE_BASE,
        }),
    );
}

export const cashSessionService = {
    async createDraft(shopId: string, userId: string, input: CreateCashSessionDraftInput) {
        const { stationId } = input;
        const actor = await resolveCashSessionActor(shopId, userId)
        ensureActorCanOpenCashSession(actor, stationId)
        await findActiveCashStation(shopId, stationId);

        const alreadyOpen = await prisma.cashSession.findFirst({
            where: {
                shopId,
                stationId,
                status: "OPEN"
            }
        });

        if (alreadyOpen) {
            throw new AppError("A session is already open for this station", 400);
        }

        const existingDraft = await findOwnedDraft(shopId, userId, stationId);
        if (existingDraft) {
            if (input.openingCash !== undefined || input.counting !== undefined) {
                return this.update(shopId, userId, existingDraft.id, {
                    ...(input.openingCash !== undefined ? { openingCash: input.openingCash } : {}),
                    ...(input.counting !== undefined ? { counting: input.counting } : {}),
                });
            }

            return toCashSessionDto(existingDraft);
        }

        const { openingCash, openingCountBreakdown } = normalizeOpeningInput(input);

        const created = await withOptionalOperationHistory(
            () => prisma.cashSession.create({
                data: {
                    shopId,
                    stationId,
                    openedById: userId,
                    status: "DRAFT",
                    openedAt: new Date(),
                    openingCash,
                    openingCountBreakdown: openingCountBreakdown as any,
                    closingCash: null,
                    closingCountBreakdown: Prisma.DbNull,
                },
                include: SESSION_INCLUDE,
            }),
            () => prisma.cashSession.create({
                data: {
                    shopId,
                    stationId,
                    openedById: userId,
                    status: "DRAFT",
                    openedAt: new Date(),
                    openingCash,
                    openingCountBreakdown: openingCountBreakdown as any,
                    closingCash: null,
                    closingCountBreakdown: Prisma.DbNull,
                },
                include: SESSION_INCLUDE_BASE,
            }),
        );

        return toCashSessionDto(created);
    },

    async open(shopId: string, userId: string, input: OpenCashSessionInput, senderSocketId?: string) {
        const { stationId } = input;
        const actor = await resolveCashSessionActor(shopId, userId)
        ensureActorCanOpenCashSession(actor, stationId)
        const station = await findActiveCashStation(shopId, stationId);

        const alreadyOpen = await prisma.cashSession.findFirst({
            where: {
                shopId,
                stationId,
                status: "OPEN"
            }
        });

        if (alreadyOpen) {
            throw new AppError("A session is already open for this station", 400);
        }

        const existingDraft = await findOwnedDraft(shopId, userId, stationId);
        if (existingDraft) {
            return finalizeDraftSession(shopId, userId, existingDraft, input, senderSocketId);
        }

        return createOpenSession(shopId, userId, station, input, senderSocketId);
    },

    async getDraft(shopId: string, userId: string, stationId?: string) {
        const actor = await resolveCashSessionActor(shopId, userId)
        ensureActorCanAccessCashStation(actor, stationId)
        const session = await findOwnedDraft(shopId, userId, stationId);
        return session ? toCashSessionDto(session) : null;
    },

    async finalizeDraft(
        shopId: string,
        userId: string,
        sessionId: string,
        input: FinalizeCashSessionDraftInput,
        senderSocketId?: string,
    ) {
        const actor = await resolveCashSessionActor(shopId, userId)
        const draft = await findOwnedDraftById(shopId, userId, sessionId);

        if (!draft) {
            throw new AppError("Cash session draft not found", 404);
        }

        if (!draft.stationId) {
            throw new AppError("Draft session is missing a station", 400);
        }

        ensureActorCanOpenCashSession(actor, draft.stationId)

        const alreadyOpen = await prisma.cashSession.findFirst({
            where: {
                shopId,
                stationId: draft.stationId,
                status: "OPEN",
                id: { not: sessionId },
            }
        });

        if (alreadyOpen) {
            throw new AppError("A session is already open for this station", 400);
        }

        return finalizeDraftSession(shopId, userId, draft, input, senderSocketId);
    },

    async discardDraft(shopId: string, userId: string, sessionId: string) {
        const actor = await resolveCashSessionActor(shopId, userId)
        const draft = await findOwnedDraftById(shopId, userId, sessionId);

        if (!draft) {
            throw new AppError("Cash session draft not found", 404);
        }

        ensureActorCanOpenCashSession(actor, draft.stationId)

        await prisma.cashSession.delete({
            where: { id: sessionId },
        });
    },

    async getCurrent(shopId: string, userId: string, stationId?: string) {
        const actor = await resolveCashSessionActor(shopId, userId)
        ensureActorCanAccessCashStation(actor, stationId)

        const session = await withOptionalOperationHistory(
            () => prisma.cashSession.findFirst({
                where: {
                    shopId,
                    ...buildAccessibleCashSessionWhere(actor),
                    ...(stationId ? { stationId } : {}),
                    status: "OPEN"
                },
                include: SESSION_INCLUDE,
                orderBy: { openedAt: "desc" }
            }),
            () => prisma.cashSession.findFirst({
                where: {
                    shopId,
                    ...buildAccessibleCashSessionWhere(actor),
                    ...(stationId ? { stationId } : {}),
                    status: "OPEN"
                },
                include: SESSION_INCLUDE_BASE,
                orderBy: { openedAt: "desc" }
            }),
        );

        return session ? toCashSessionDto(session) : null;
    },

    async getLatest(shopId: string, userId: string, stationId: string) {
        const actor = await resolveCashSessionActor(shopId, userId)
        ensureActorCanAccessCashStation(actor, stationId)

        const session = await withOptionalOperationHistory(
            () => prisma.cashSession.findFirst({
                where: {
                    shopId,
                    ...buildAccessibleCashSessionWhere(actor),
                    stationId,
                    status: { in: ["OPEN", "CLOSED"] },
                },
                include: SESSION_INCLUDE,
                orderBy: [{ openedAt: "desc" }, { id: "desc" }]
            }),
            () => prisma.cashSession.findFirst({
                where: {
                    shopId,
                    ...buildAccessibleCashSessionWhere(actor),
                    stationId,
                    status: { in: ["OPEN", "CLOSED"] },
                },
                include: SESSION_INCLUDE_BASE,
                orderBy: [{ openedAt: "desc" }, { id: "desc" }]
            }),
        );

        return session ? toCashSessionDto(session) : null;
    },

    async getAllOpen(shopId: string, userId: string) {
        const actor = await resolveCashSessionActor(shopId, userId)

        const sessions = await withOptionalOperationHistory(
            () => prisma.cashSession.findMany({
                where: { shopId, status: "OPEN", ...buildAccessibleCashSessionWhere(actor) },
                include: SESSION_INCLUDE,
                orderBy: { openedAt: "asc" }
            }),
            () => prisma.cashSession.findMany({
                where: { shopId, status: "OPEN", ...buildAccessibleCashSessionWhere(actor) },
                include: SESSION_INCLUDE_BASE,
                orderBy: { openedAt: "asc" }
            }),
        );

        return sessions.map(toCashSessionDto);
    },

    async getHistory(shopId: string, userId: string, query: CashSessionHistoryQueryInput = {}) {
        const actor = await resolveCashSessionActor(shopId, userId)
        ensureActorCanAccessCashStation(actor, query.stationId)

        const sessions = await withOptionalOperationHistory(
            () => prisma.cashSession.findMany({
                where: {
                    shopId,
                    ...buildAccessibleCashSessionWhere(actor),
                    ...(query.stationId ? { stationId: query.stationId } : {}),
                    ...(query.status ? { status: query.status } : { status: { in: ["OPEN", "CLOSED"] } }),
                },
                include: SESSION_INCLUDE,
                orderBy: [{ closedAt: "desc" }, { openedAt: "desc" }, { id: "desc" }],
                take: query.limit ?? 50,
            }),
            () => prisma.cashSession.findMany({
                where: {
                    shopId,
                    ...buildAccessibleCashSessionWhere(actor),
                    ...(query.stationId ? { stationId: query.stationId } : {}),
                    ...(query.status ? { status: query.status } : { status: { in: ["OPEN", "CLOSED"] } }),
                },
                include: SESSION_INCLUDE_BASE,
                orderBy: [{ closedAt: "desc" }, { openedAt: "desc" }, { id: "desc" }],
                take: query.limit ?? 50,
            }),
        );

        return sessions.map(toCashSessionDto);
    },

    async update(shopId: string, userId: string, sessionId: string, input: UpdateCashSessionInput, senderSocketId?: string) {
        const actor = await resolveCashSessionActor(shopId, userId)
        const existing = await getSessionById(shopId, sessionId);

        if (!existing) {
            throw new AppError("Cash session not found", 404);
        }

        ensureActorCanAccessCashStation(actor, existing.stationId)

        if (existing.status === "CLOSED") {
            throw new AppError("A closed session cannot be modified", 400);
        }

        if (existing.status === "DRAFT" && existing.openedById !== userId) {
            throw new AppError("Only the creator can modify this draft session", 403);
        }

        if (input.openingCash !== undefined || input.counting !== undefined) {
            ensureActorCanOpenCashSession(actor, existing.stationId)
        }

        const { openingCash, openingCountBreakdown } = normalizeOpeningInput({
            openingCash: input.openingCash,
            counting: input.counting,
        });
        const hasOpeningCashUpdate = input.openingCash !== undefined || input.counting !== undefined;

        if (existing.status === "DRAFT") {
            const updatedDraft = await withOptionalOperationHistory(
                () => prisma.cashSession.update({
                    where: { id: sessionId },
                    data: {
                        ...(hasOpeningCashUpdate ? {
                            openingCash,
                            openingCountBreakdown: openingCountBreakdown as any,
                        } : {}),
                    },
                    include: SESSION_INCLUDE,
                }),
                () => prisma.cashSession.update({
                    where: { id: sessionId },
                    data: {
                        ...(hasOpeningCashUpdate ? {
                            openingCash,
                            openingCountBreakdown: openingCountBreakdown as any,
                        } : {}),
                    },
                    include: SESSION_INCLUDE_BASE,
                }),
            );

            return toCashSessionDto(updatedDraft);
        }

        const previousExpectedCash = computeExpectedCash(existing);
        const openingDelta = openingCash - (existing.openingCash ?? 0);

        const updated = await trackedTransaction(shopId, "CashSession", async (t, tx: any) => {
            await t.update("cashSession", { id: sessionId }, {
                ...(hasOpeningCashUpdate ? {
                    openingCash,
                    openingCountBreakdown: openingCountBreakdown as any,
                } : {}),
            } as any, {
                include: SESSION_INCLUDE_BASE,
            }, "cash_session.updated", {
                sessionId,
                stationId: existing.stationId ?? null,
                stationName: existing.station?.name ?? null,
                openingCash: hasOpeningCashUpdate ? openingCash : existing.openingCash,
                openingCountBreakdown: hasOpeningCashUpdate
                    ? openingCountBreakdown
                    : normalizeCountingBreakdown((((existing as any).openingCountBreakdown) as CashSessionCountBreakdownInput | null | undefined) ?? null),
                previousOpeningCash: existing.openingCash,
            });

            if (hasOpeningCashUpdate && openingDelta !== 0) {
                await createOperationIfAvailable(() => tx.cashSessionOperation.create({
                    data: {
                        shopId,
                        cashSessionId: sessionId,
                        createdById: userId,
                        type: "OPENING_EDIT",
                        source: input.counting ? "Correction via recomptage" : "Correction manuelle du fond",
                        note: null,
                        amountDelta: openingDelta,
                        countsTowardExpectedCash: false,
                        resultingExpectedCash: previousExpectedCash + openingDelta,
                        metadata: {
                            previousOpeningCash: existing.openingCash,
                            openingCash,
                            openingCountBreakdown,
                        },
                    },
                }));
            }

            return getSessionByIdFromTx(tx, sessionId);
        }, { actorUserId: userId, senderSocketId });

        return toCashSessionDto(updated);
    },

    async reopen(shopId: string, userId: string, sessionId: string, senderSocketId?: string) {
        const actor = await resolveCashSessionActor(shopId, userId)
        const session = await getSessionById(shopId, sessionId);

        if (!session) {
            throw new AppError("Cash session not found", 404);
        }

        ensureActorCanReopenCashSession(actor, session.stationId)

        if (session.status !== "CLOSED") {
            throw new AppError("Only a closed session can be reopened", 400);
        }

        if (session.stationId) {
            const alreadyOpen = await prisma.cashSession.findFirst({
                where: {
                    shopId,
                    stationId: session.stationId,
                    status: "OPEN",
                    id: { not: sessionId }
                }
            });

            if (alreadyOpen) {
                throw new AppError("Another session is already open for this station", 400);
            }
        }


        const reopened = await trackedTransaction(shopId, "CashSession", async (t, tx: any) => {
            await t.update("cashSession", { id: sessionId }, {
                status: "OPEN",
                closedById: null,
                closedAt: null,
                closingCash: null,
                closingCountBreakdown: null,
            } as any, {
                include: SESSION_INCLUDE_BASE,
            }, "cash_session.reopened", {
                sessionId,
                stationId: session.stationId ?? null,
                stationName: session.station?.name ?? null,
                reopenedBy: userId,
            });

            await createOperationIfAvailable(() => tx.cashSessionOperation.create({
                data: {
                    shopId,
                    cashSessionId: sessionId,
                    createdById: userId,
                    type: "REOPEN",
                    source: "Réouverture de session",
                    note: null,
                    amountDelta: null,
                    countsTowardExpectedCash: false,
                    resultingExpectedCash: computeExpectedCash(session),
                    metadata: {
                        previousClosedAt: session.closedAt?.toISOString() ?? null,
                        previousClosingCash: session.closingCash ?? null,
                    },
                },
            }));

            return getSessionByIdFromTx(tx, sessionId);
        }, { actorUserId: userId, senderSocketId });

        return toCashSessionDto(reopened);
    },

    async closeById(
        shopId: string,
        userId: string,
        sessionId: string,
        input: { counting: CashSessionCountBreakdownInput; confirmClose: true; confirmDifference?: boolean },
        senderSocketId?: string,
    ) {
        const actor = await resolveCashSessionActor(shopId, userId)
        const session = await getSessionById(shopId, sessionId);

        if (!session) {
            throw new AppError("Cash session not found", 404);
        }

        ensureActorCanCloseCashSession(actor, session.stationId)

        if (session.status !== "OPEN") {
            throw new AppError("Only an open session can be closed", 400);
        }

        const normalizedCounting = normalizeCountingBreakdown(input.counting);
        if (!normalizedCounting) {
            throw new AppError("Cash counting is required", 400);
        }

        const expectedCash = computeExpectedCash(session);
        const difference = normalizedCounting.total - expectedCash;

        if (!input.confirmClose) {
            throw new AppError("Cash session closing must be explicitly confirmed", 400);
        }

        if (difference !== 0 && !input.confirmDifference) {
            throw new AppError("Closing with a cash difference must be explicitly confirmed", 409);
        }

        const activeProductionItemCount = await prisma.orderItem.count({
            where: {
                order: {
                    shopId,
                    cashSessionId: session.id,
                },
                status: {
                    notIn: ["DELIVERED", "CANCELLED"],
                },
            },
        });

        if (activeProductionItemCount > 0) {
            throw new AppError(
                `${activeProductionItemCount} article(s) de commande sont encore en cours pour cette session. Termine, livre ou annule les commandes avant de clôturer la caisse.`,
                409,
            );
        }

        const closed = await trackedTransaction(shopId, "CashSession", async (t, tx: any) => {
            await t.update("cashSession", { id: session.id }, {
                status: "CLOSED",
                closedById: userId,
                closedAt: new Date(),
                closingCash: normalizedCounting.total,
                closingCountBreakdown: normalizedCounting as any,
            } as any, {
                include: SESSION_INCLUDE_BASE,
            }, "cash_session.closed", {
                sessionId: session.id,
                stationId: session.stationId ?? null,
                stationName: session.station?.name ?? null,
                openingCash: session.openingCash,
                closingCash: normalizedCounting.total,
                expectedCash,
                difference,
                closingCountBreakdown: normalizedCounting,
                closedBy: userId,
            });

            await createOperationIfAvailable(() => tx.cashSessionOperation.create({
                data: {
                    shopId,
                    cashSessionId: session.id,
                    createdById: userId,
                    type: "CLOSING",
                    source: "Clôture de session",
                    note: difference === 0 ? "Clôture sans écart" : null,
                    amountDelta: null,
                    countsTowardExpectedCash: false,
                    resultingExpectedCash: expectedCash,
                    metadata: {
                        closingCash: normalizedCounting.total,
                        expectedCash,
                        difference,
                        closingCountBreakdown: normalizedCounting,
                    },
                },
            }));

            return getSessionByIdFromTx(tx, session.id);
        }, { actorUserId: userId, senderSocketId });

        return toCashSessionDto(closed);
    },

    async getOperations(shopId: string, userId: string, sessionId: string) {
        const actor = await resolveCashSessionActor(shopId, userId)
        const session = await getSessionById(shopId, sessionId);

        if (!session) {
            throw new AppError("Cash session not found", 404);
        }

        ensureActorCanAccessCashStation(actor, session.stationId)

        if (session.status === "DRAFT" && session.openedById !== userId) {
            throw new AppError("Cash session not found", 404);
        }

        const operations = await getSessionOperationsById(shopId, sessionId);
        return operations.map(toCashSessionOperationDto);
    },

    async createOperation(
        shopId: string,
        userId: string,
        sessionId: string,
        input: CreateCashSessionOperationInput,
        senderSocketId?: string,
    ): Promise<CreateCashSessionOperationResultDto> {
        const actor = await resolveCashSessionActor(shopId, userId)
        const session = await getSessionById(shopId, sessionId);

        if (!session) {
            throw new AppError("Cash session not found", 404);
        }

        ensureActorCanCreateManualCashOperation(actor, session.stationId)

        if (session.status !== "OPEN") {
            throw new AppError("Only an open session can receive manual cash movements", 400);
        }

        const amountDelta = input.type === "MANUAL_IN" ? input.amount : -input.amount;
        const currentExpectedCash = computeExpectedCash(session);
        const resultingExpectedCash = currentExpectedCash + amountDelta;

        if (resultingExpectedCash < 0) {
            throw new AppError("The cash drawer total cannot become negative", 400);
        }

        const result = await trackedTransaction(shopId, "CashSession", async (t, tx: any) => {
            const operation = await createOperationIfAvailable<any>(() => tx.cashSessionOperation.create({
                data: {
                    shopId,
                    cashSessionId: session.id,
                    createdById: userId,
                    type: input.type,
                    source: normalizeOptionalText(input.source),
                    note: normalizeOptionalText(input.note),
                    amountDelta,
                    countsTowardExpectedCash: true,
                    resultingExpectedCash,
                    metadata: {
                        previousExpectedCash: currentExpectedCash,
                    },
                },
                include: SESSION_OPERATION_INCLUDE,
            }));

            if (!operation) {
                throw createMissingOperationHistoryAppError();
            }

            await t.update<any>("cashSession", { id: session.id }, {} as any, {
                include: SESSION_INCLUDE_BASE,
            }, "cash_session.updated", {
                sessionId: session.id,
                stationId: session.stationId ?? null,
                stationName: session.station?.name ?? null,
                operationId: operation.id,
                operationType: operation.type,
                amountDelta,
                source: operation.source,
            });

            return {
                session: await getSessionByIdFromTx(tx, session.id),
                operation,
            };
        }, { actorUserId: userId, senderSocketId });

        return {
            session: toCashSessionDto(result.session),
            operation: toCashSessionOperationDto(result.operation),
        };
    },

    async createDepositRefund(
        shopId: string,
        userId: string,
        sessionId: string,
        input: CreateDepositRefundInput,
        senderSocketId?: string,
    ): Promise<CreateCashSessionOperationResultDto> {
        const actor = await resolveCashSessionActor(shopId, userId)
        const session = await getSessionById(shopId, sessionId);

        if (!session) {
            throw new AppError("Cash session not found", 404);
        }

        ensureActorCanCreateDepositRefund(actor, session.stationId)

        if (session.status !== "OPEN") {
            throw new AppError("Only an open session can receive deposit refunds", 400);
        }

        let linkedOrder: {
            id: string;
            cashSessionId: string | null;
            status: string;
            sessionOrderNumber: number | null;
        } | null = null;

        if (input.orderId) {
            linkedOrder = await prisma.order.findFirst({
                where: {
                    id: input.orderId,
                    shopId,
                },
                select: {
                    id: true,
                    cashSessionId: true,
                    status: true,
                    sessionOrderNumber: true,
                },
            });

            if (!linkedOrder) {
                throw new AppError("Linked order not found", 404);
            }

            if (linkedOrder.status === "CANCELLED") {
                throw new AppError("Cannot link a deposit refund to a cancelled order", 400);
            }

            if (linkedOrder.cashSessionId !== session.id) {
                throw new AppError("The linked order does not belong to the current cash session", 400);
            }
        }

        const normalizedMap = new Map<string, number>();
        for (const line of input.lines) {
            normalizedMap.set(line.depositDefinitionId, (normalizedMap.get(line.depositDefinitionId) ?? 0) + line.quantity);
        }

        const normalizedLines = [...normalizedMap.entries()].map(([depositDefinitionId, quantity]) => ({
            depositDefinitionId,
            quantity,
        }));

        const definitions = await prisma.depositDefinition.findMany({
            where: {
                shopId,
                active: true,
                id: { in: normalizedLines.map((line) => line.depositDefinitionId) },
            },
            select: {
                id: true,
                label: true,
                amount: true,
            }
        });

        if (definitions.length !== normalizedLines.length) {
            throw new AppError("One or more deposit definitions are invalid or inactive", 400);
        }

        const definitionMap = new Map(definitions.map((definition: { id: string; label: string; amount: number }) => [definition.id, definition]));
        const depositLines = normalizedLines.map((line) => {
            const definition = definitionMap.get(line.depositDefinitionId);
            if (!definition) {
                throw new AppError("Deposit definition not found", 400);
            }

            return {
                depositDefinitionId: definition.id,
                labelSnapshot: definition.label,
                unitAmountSnapshot: definition.amount,
                quantity: line.quantity,
                totalAmount: definition.amount * line.quantity,
            };
        });

        const refundTotal = depositLines.reduce((sum: number, line) => sum + line.totalAmount, 0);
        if (refundTotal <= 0) {
            throw new AppError("Deposit refund total must be positive", 400);
        }

        const requestedCashRefundAmount = typeof (input as { cashRefundAmount?: unknown }).cashRefundAmount === "number"
            ? (input as { cashRefundAmount?: number }).cashRefundAmount
            : undefined;
        const cashRefundAmount = requestedCashRefundAmount ?? refundTotal;
        if (cashRefundAmount <= 0) {
            throw new AppError("Cash refund amount must be positive", 400);
        }
        if (cashRefundAmount > refundTotal) {
            throw new AppError("Cash refund amount cannot exceed refunded deposits total", 400);
        }

        const currentExpectedCash = computeExpectedCash(session);
        const amountDelta = -cashRefundAmount;
        const resultingExpectedCash = currentExpectedCash + amountDelta;

        if (resultingExpectedCash < 0) {
            throw new AppError("The cash drawer total cannot become negative", 400);
        }

        const result = await trackedTransaction(shopId, "CashSession", async (t, tx: any) => {
            const operation = await createOperationIfAvailable<any>(() => tx.cashSessionOperation.create({
                data: {
                    shopId,
                    cashSessionId: session.id,
                    createdById: userId,
                    type: "DEPOSIT_REFUND",
                    source: "Remboursement de consigne",
                    note: normalizeOptionalText(input.note),
                    amountDelta,
                    countsTowardExpectedCash: true,
                    resultingExpectedCash,
                    metadata: {
                        previousExpectedCash: currentExpectedCash,
                        refundTotal,
                        cashRefundAmount,
                        ...(linkedOrder
                            ? {
                                linkedOrderId: linkedOrder.id,
                                linkedOrderNumber: linkedOrder.sessionOrderNumber,
                            }
                            : {}),
                    },
                    depositLines: {
                        create: depositLines,
                    },
                },
                include: SESSION_OPERATION_INCLUDE,
            }));

            if (!operation) {
                throw createMissingOperationHistoryAppError();
            }

            await t.update<any>("cashSession", { id: session.id }, {} as any, {
                include: SESSION_INCLUDE_BASE,
            }, "cash_session.updated", {
                sessionId: session.id,
                stationId: session.stationId ?? null,
                stationName: session.station?.name ?? null,
                operationId: operation.id,
                operationType: operation.type,
                amountDelta,
                        depositRefundTotal: refundTotal,
                        cashRefundAmount,
            });

            return {
                session: await getSessionByIdFromTx(tx, session.id),
                operation,
            };
        }, { actorUserId: userId, senderSocketId });

        return {
            session: toCashSessionDto(result.session),
            operation: toCashSessionOperationDto(result.operation),
        };
    },

    async undoLastDepositRefund(
        shopId: string,
        userId: string,
        sessionId: string,
        senderSocketId?: string,
    ): Promise<CreateCashSessionOperationResultDto> {
        const actor = await resolveCashSessionActor(shopId, userId)
        const session = await getSessionById(shopId, sessionId);

        if (!session) {
            throw new AppError("Cash session not found", 404);
        }

        ensureActorCanUndoDepositRefund(actor, session.stationId)

        if (session.status !== "OPEN") {
            throw new AppError("Only an open session can undo a deposit refund", 400);
        }

        const result = await trackedTransaction(shopId, "CashSession", async (t, tx: any) => {
            const currentSession: any = await getSessionByIdFromTx(tx, session.id);
            if (!currentSession || currentSession.status !== "OPEN") {
                throw new AppError("Only an open session can undo a deposit refund", 400);
            }

            const lastOwnRefund = await tx.cashSessionOperation.findFirst({
                where: {
                    shopId,
                    cashSessionId: session.id,
                    createdById: userId,
                    type: "DEPOSIT_REFUND",
                },
                include: {
                    depositLines: true,
                },
                orderBy: [{ createdAt: "desc" }, { id: "desc" }],
            });

            if (!lastOwnRefund) {
                throw new AppError("Aucun retour de consigne à annuler pour ton compte.", 404);
            }

            const undoNote = `Annule le retour de consigne ${lastOwnRefund.id}`;
            const existingUndo = await tx.cashSessionOperation.findFirst({
                where: {
                    shopId,
                    cashSessionId: session.id,
                    createdById: userId,
                    type: "MANUAL_IN",
                    note: undoNote,
                },
                select: { id: true },
            });

            if (existingUndo) {
                throw new AppError("Ce retour de consigne a déjà été annulé.", 409);
            }

            const refundTotal = lastOwnRefund.depositLines.reduce((sum: number, line: any) => sum + (line.totalAmount ?? 0), 0);
            const refundedCashAmount = Math.abs(lastOwnRefund.amountDelta ?? 0) || refundTotal;
            if (refundedCashAmount <= 0) {
                throw new AppError("Le dernier retour de consigne est invalide et ne peut pas être annulé.", 409);
            }

            const currentExpectedCash = computeExpectedCash(currentSession);
            const amountDelta = refundedCashAmount;
            const resultingExpectedCash = currentExpectedCash + amountDelta;

            const operation = await createOperationIfAvailable<any>(() => tx.cashSessionOperation.create({
                data: {
                    shopId,
                    cashSessionId: session.id,
                    createdById: userId,
                    type: "MANUAL_IN",
                    source: "Annulation retour consigne",
                    note: undoNote,
                    amountDelta,
                    countsTowardExpectedCash: true,
                    resultingExpectedCash,
                    metadata: {
                        previousExpectedCash: currentExpectedCash,
                        undoOfOperationId: lastOwnRefund.id,
                        refundTotal,
                        refundedCashAmount,
                    },
                    depositLines: {
                        create: (lastOwnRefund.depositLines ?? []).map((line: any) => ({
                            depositDefinitionId: line.depositDefinitionId,
                            labelSnapshot: line.labelSnapshot,
                            unitAmountSnapshot: line.unitAmountSnapshot,
                            quantity: line.quantity,
                            totalAmount: line.totalAmount,
                        })),
                    },
                },
                include: SESSION_OPERATION_INCLUDE,
            }));

            if (!operation) {
                throw createMissingOperationHistoryAppError();
            }

            await t.update<any>("cashSession", { id: session.id }, {} as any, {
                include: SESSION_INCLUDE_BASE,
            }, "cash_session.updated", {
                sessionId: session.id,
                stationId: currentSession.stationId ?? null,
                stationName: currentSession.station?.name ?? null,
                operationId: operation.id,
                operationType: operation.type,
                amountDelta,
                undoOfOperationId: lastOwnRefund.id,
            });

            return {
                session: await getSessionByIdFromTx(tx, session.id),
                operation,
            };
        }, { actorUserId: userId, senderSocketId });

        return {
            session: toCashSessionDto(result.session),
            operation: toCashSessionOperationDto(result.operation),
        };
    },
};

