import dayjs from "dayjs";
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
import { and, desc, eq, gte } from "drizzle-orm";
import { FastifyInstance } from "fastify";
import { createHash } from "node:crypto";

import {
    bankaccounts,
    bankstatements,
    createddocuments,
    incominginvoices,
    statementallocations,
    tenants,
} from "../../db/schema";
import { secrets } from "./secrets";

type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement" | "draft_createddocument" | "tax_settlement" | "serial_template";

type TaxEvaluationPeriod = "monthly" | "quarterly" | "yearly";

type ForecastEvent = {
    date: string;
    amount: number;
    label: string;
    source: ForecastEventSource;
    sourceId?: number | string | null;
    recurringKey?: string;
    confidence?: number;
};

type TaxBreakdown = {
    net19: number;
    tax19: number;
    net7: number;
    tax7: number;
    net0: number;
};

type TaxForecastPeriod = {
    key: string;
    label: string;
    range: string;
    dueDate: string;
    outputTax: number;
    inputTax: number;
    balance: number;
    outputCount: number;
    inputCount: number;
};

type RecurringCandidate = {
    key?: string;
    label: string;
    amount: number;
    interval: "weekly" | "monthly" | "quarterly" | "yearly";
    nextDate: string;
    confidence: number;
    evidence: string;
};

const AiRecurringFormat = z.object({
    candidates: z.array(z.object({
        label: z.string(),
        amount: z.number(),
        interval: z.enum(["weekly", "monthly", "quarterly", "yearly"]),
        nextDate: z.string(),
        confidence: z.number().min(0).max(1),
        evidence: z.string(),
    })),
});

const FORECAST_DAYS = 90;
const HISTORY_MONTHS = 12;

const roundMoney = (value: number) => Number(Number(value || 0).toFixed(2));
const createZeroTaxBreakdown = (): TaxBreakdown => ({
    net19: 0,
    tax19: 0,
    net7: 0,
    tax7: 0,
    net0: 0,
});

const normalizeText = (value: unknown) => String(value || "")
    .toLowerCase()
    .replace(/[^a-z0-9äöüß]+/gi, " ")
    .replace(/\s+/g, " ")
    .trim();

const normalizeTaxEvaluationPeriod = (value?: string | null): TaxEvaluationPeriod => {
    if (value === "quarterly" || value === "yearly") return value;
    return "monthly";
};

const isTaxFreeDocument = (taxType?: string | null) => {
    return ["13b UStG", "19 UStG", "12.3 UStG"].includes(String(taxType || ""));
};

const getTaxEvaluationPeriodBounds = (
    referenceDate: dayjs.ConfigType,
    period: TaxEvaluationPeriod
) => {
    const base = dayjs(referenceDate);

    if (period === "yearly") {
        return {
            start: base.startOf("year"),
            end: base.endOf("year"),
        };
    }

    if (period === "quarterly") {
        const quarterStartMonth = Math.floor(base.month() / 3) * 3;
        const start = base.month(quarterStartMonth).startOf("month");

        return {
            start,
            end: start.add(2, "month").endOf("month"),
        };
    }

    return {
        start: base.startOf("month"),
        end: base.endOf("month"),
    };
};

const shiftTaxEvaluationPeriodStart = (
    periodStart: dayjs.ConfigType,
    period: TaxEvaluationPeriod,
    offset: number
) => {
    const base = dayjs(periodStart);

    if (period === "yearly") return base.add(offset, "year").startOf("year");
    if (period === "quarterly") return base.add(offset * 3, "month").startOf("month");
    return base.add(offset, "month").startOf("month");
};

const formatTaxEvaluationPeriodLabel = (
    periodStart: dayjs.ConfigType,
    period: TaxEvaluationPeriod
) => {
    const { start } = getTaxEvaluationPeriodBounds(periodStart, period);

    if (period === "yearly") {
        return start.format("YYYY");
    }

    if (period === "quarterly") {
        return `Q${Math.floor(start.month() / 3) + 1} ${start.format("YYYY")}`;
    }

    return start.format("MMMM YYYY");
};

const formatTaxEvaluationPeriodRange = (
    periodStart: dayjs.ConfigType,
    period: TaxEvaluationPeriod
) => {
    const { start, end } = getTaxEvaluationPeriodBounds(periodStart, period);
    return `${start.format("DD.MM.YYYY")} - ${end.format("DD.MM.YYYY")}`;
};

const getTaxSettlementDate = (periodEnd: dayjs.Dayjs) => {
    return periodEnd.add(1, "month").date(10).startOf("day");
};

const getRecurringKey = (candidate: RecurringCandidate) => {
    const rawKey = [
        Math.sign(candidate.amount),
        Math.round(Math.abs(candidate.amount) * 100),
        normalizeText(candidate.label),
        candidate.interval,
    ].join("|");

    return createHash("sha1").update(rawKey).digest("hex").slice(0, 16);
};

const addInterval = (date: dayjs.Dayjs, interval: RecurringCandidate["interval"]) => {
    if (interval === "weekly") return date.add(1, "week");
    if (interval === "quarterly") return date.add(3, "month");
    if (interval === "yearly") return date.add(1, "year");
    return date.add(1, "month");
};

const addSerialInterval = (date: dayjs.Dayjs, interval: string) => {
    if (interval === "wöchentlich") return date.add(1, "week");
    if (interval === "2 - wöchentlich") return date.add(2, "week");
    if (interval === "vierteljährlich") return date.add(3, "month");
    if (interval === "halbjährlich") return date.add(6, "month");
    if (interval === "jährlich") return date.add(1, "year");
    return date.add(1, "month");
};

const getStatementPartner = (statement: any) => {
    return statement.amount < 0
        ? statement.credName || statement.debName || statement.text || "Regelmäßige Ausgabe"
        : statement.debName || statement.credName || statement.text || "Regelmäßige Einnahme";
};

const getCreatedDocumentGrossAmount = (document: any, allDocuments: any[] = []) => {
    let totalNet = 0;
    let totalTax = 0;

    (document.rows || []).forEach((row: any) => {
        if (["pagebreak", "title", "text"].includes(row.mode)) return;

        const rowNet = Number(
            (Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3)
        );
        const taxPercent = Number(row.taxPercent);

        totalNet += rowNet;
        totalTax += rowNet * (Number.isFinite(taxPercent) ? taxPercent : 0) / 100;
    });

    let advancePayments = 0;
    (document.usedAdvanceInvoices || []).forEach((advanceInvoiceId: number) => {
        const advanceInvoice = allDocuments.find((item) => item.id === advanceInvoiceId);
        const advanceRow = advanceInvoice?.rows?.find((row: any) => row.advanceInvoiceData);
        if (!advanceRow) return;

        advancePayments += Number(advanceRow.price || 0) * ((100 + Number(advanceRow.taxPercent || 0)) / 100);
    });

    return roundMoney(Number(totalNet.toFixed(2)) + Number(totalTax.toFixed(2)) - advancePayments);
};

const getCreatedDocumentTaxBreakdown = (document: any): TaxBreakdown => {
    const breakdown = createZeroTaxBreakdown();

    if (!document || isTaxFreeDocument(document.taxType)) {
        return breakdown;
    }

    (document.rows || []).forEach((row: any) => {
        if (!row || ["pagebreak", "title", "text"].includes(row.mode)) return;

        const quantity = Number(row.quantity || 0);
        const price = Number(row.price || 0);
        const discountPercent = Number(row.discountPercent || 0);
        const taxPercent = Number(row.taxPercent || 0);
        const net = Number((quantity * price * (1 - discountPercent / 100)).toFixed(2));

        if (!Number.isFinite(net) || net === 0) return;

        if (taxPercent === 19) {
            breakdown.net19 += net;
            breakdown.tax19 += Number((net * 0.19).toFixed(2));
        } else if (taxPercent === 7) {
            breakdown.net7 += net;
            breakdown.tax7 += Number((net * 0.07).toFixed(2));
        } else {
            breakdown.net0 += net;
        }
    });

    return {
        net19: roundMoney(breakdown.net19),
        tax19: roundMoney(breakdown.tax19),
        net7: roundMoney(breakdown.net7),
        tax7: roundMoney(breakdown.tax7),
        net0: roundMoney(breakdown.net0),
    };
};

const getIncomingInvoiceTaxBreakdown = (invoice: any): TaxBreakdown => {
    const breakdown = createZeroTaxBreakdown();

    (invoice?.accounts || []).forEach((account: any) => {
        const taxType = String(account?.taxType || "");
        const amountNet = Number(account?.amountNet || 0);
        const amountTax = Number(account?.amountTax || 0);

        if (taxType === "19") {
            breakdown.net19 += amountNet;
            breakdown.tax19 += amountTax;
        } else if (taxType === "7") {
            breakdown.net7 += amountNet;
            breakdown.tax7 += amountTax;
        } else {
            breakdown.net0 += amountNet;
        }
    });

    return {
        net19: roundMoney(breakdown.net19),
        tax19: roundMoney(breakdown.tax19),
        net7: roundMoney(breakdown.net7),
        tax7: roundMoney(breakdown.tax7),
        net0: roundMoney(breakdown.net0),
    };
};

const getIncomingInvoiceSignedAmount = (invoice: any) => {
    const amount = (invoice.accounts || []).reduce((sum: number, account: any) => {
        return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0);
    }, 0);

    return roundMoney(invoice.expense === false ? amount : amount * -1);
};

const getRemainingSignedAmount = (signedAmount: number, allocatedAmount: number) => {
    const remainingAbsolute = Math.max(0, Math.abs(Number(signedAmount || 0)) - Math.abs(Number(allocatedAmount || 0)));
    if (remainingAbsolute <= 0.01) return 0;
    return roundMoney(Math.sign(Number(signedAmount || 0)) * remainingAbsolute);
};

const findCancellationDocumentIds = (documents: any[]) => {
    return new Set(
        documents
            .filter((document) => document.type === "cancellationInvoices" && document.state !== "Entwurf" && !document.archived)
            .map((document) => typeof document.createddocument === "object" ? document.createddocument?.id : document.createddocument)
            .filter(Boolean)
    );
};

const inferInterval = (dates: dayjs.Dayjs[]): RecurringCandidate["interval"] | null => {
    if (dates.length < 3) return null;

    const gaps = dates
        .slice(1)
        .map((date, index) => date.diff(dates[index], "day"))
        .filter((gap) => gap > 0);

    const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / Math.max(gaps.length, 1);

    if (averageGap >= 6 && averageGap <= 8) return "weekly";
    if (averageGap >= 25 && averageGap <= 35) return "monthly";
    if (averageGap >= 80 && averageGap <= 100) return "quarterly";
    if (averageGap >= 340 && averageGap <= 390) return "yearly";

    return null;
};

const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] => {
    const groups = new Map<string, any[]>();

    statements.forEach((statement) => {
        const parsedDate = dayjs(statement.valueDate || statement.date);
        if (!parsedDate.isValid() || Number(statement.amount || 0) >= 0) return;

        const partner = normalizeText(getStatementPartner(statement));
        const purpose = normalizeText(statement.text).split(" ").slice(0, 5).join(" ");
        const amountBucket = Math.round(Number(statement.amount || 0) * 100);
        const key = [Math.sign(amountBucket), Math.abs(amountBucket), partner || purpose].join("|");

        if (!groups.has(key)) groups.set(key, []);
        groups.get(key)!.push(statement);
    });

    return [...groups.values()]
        .map((group) => {
            const sorted = group
                .map((statement) => ({ ...statement, parsedDate: dayjs(statement.valueDate || statement.date) }))
                .sort((a, b) => a.parsedDate.valueOf() - b.parsedDate.valueOf());

            const interval = inferInterval(sorted.map((statement) => statement.parsedDate));
            if (!interval) return null;

            let next = addInterval(sorted[sorted.length - 1].parsedDate, interval);
            while (next.isBefore(dayjs(), "day")) {
                next = addInterval(next, interval);
            }

            const amount = roundMoney(sorted.reduce((sum, statement) => sum + Number(statement.amount || 0), 0) / sorted.length);
            const partner = getStatementPartner(sorted[sorted.length - 1]);

            return {
                label: partner,
                amount,
                interval,
                nextDate: next.format("YYYY-MM-DD"),
                confidence: Math.min(0.9, 0.55 + sorted.length * 0.08),
                evidence: `${sorted.length} ähnliche Bankbewegungen erkannt`,
            };
        })
        .filter(Boolean) as RecurringCandidate[];
};

const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise<RecurringCandidate[]> => {
    if (!secrets.OPENAI_API_KEY || statements.length < 6) return [];

    const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
    const compactStatements = statements.slice(0, 220).map((statement) => ({
        date: statement.valueDate || statement.date,
        amount: roundMoney(Number(statement.amount || 0)),
        partner: getStatementPartner(statement),
        text: String(statement.text || "").slice(0, 160),
    }));

    try {
        const completion = await openai.chat.completions.parse({
            model: "gpt-4o",
            store: true,
            response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"),
            messages: [
                {
                    role: "system",
                    content: "Du erkennst wiederkehrende Bankbewegungen für eine Liquiditätsprognose. Gib nur Muster zurück, die durch die gelieferten Umsätze plausibel belegt sind.",
                },
                {
                    role: "user",
                    content: JSON.stringify({
                        today: dayjs().format("YYYY-MM-DD"),
                        horizonDays: FORECAST_DAYS,
                        bankStatements: compactStatements,
                        rules: [
                            "amount ist aus Sicht des Bankkontos: negative Werte sind Auszahlungen, positive Werte Einzahlungen.",
                            "nextDate muss in der Zukunft liegen.",
                            "Nutze keine einmaligen oder unsicheren Bewegungen.",
                        ],
                    }),
                },
            ],
        });

        return (completion.choices[0].message.parsed?.candidates || [])
            .filter((candidate) => dayjs(candidate.nextDate).isValid())
            .filter((candidate) => Number(candidate.amount || 0) < 0)
            .map((candidate) => ({
                ...candidate,
                amount: roundMoney(candidate.amount),
                confidence: Math.max(0, Math.min(1, candidate.confidence)),
            }));
    } catch (error) {
        server.log.warn("KI-Erkennung für regelmäßige Bankbewegungen konnte nicht ausgeführt werden.");
        server.log.warn(error);
        return [];
    }
};

const mergeRecurringCandidates = (heuristic: RecurringCandidate[], ai: RecurringCandidate[]) => {
    const merged = new Map<string, RecurringCandidate>();

    [...heuristic, ...ai].forEach((candidate) => {
        const key = [
            Math.sign(candidate.amount),
            Math.round(Math.abs(candidate.amount) * 100),
            normalizeText(candidate.label).slice(0, 32),
            candidate.interval,
        ].join("|");
        const existing = merged.get(key);

        if (!existing || candidate.confidence > existing.confidence) {
            merged.set(key, candidate);
        }
    });

    return [...merged.values()]
        .map((candidate) => ({
            ...candidate,
            key: getRecurringKey(candidate),
        }))
        .filter((candidate) => Number(candidate.amount || 0) < 0)
        .filter((candidate) => Math.abs(candidate.amount) >= 1)
        .sort((a, b) => a.nextDate.localeCompare(b.nextDate));
};

const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.Dayjs): ForecastEvent[] => {
    const events: ForecastEvent[] = [];

    recurring.forEach((candidate) => {
        let date = dayjs(candidate.nextDate);
        let guard = 0;

        while (date.isValid() && !date.isAfter(endDate, "day") && guard < 40) {
            events.push({
                date: date.format("YYYY-MM-DD"),
                amount: roundMoney(candidate.amount),
                label: candidate.label,
                source: "recurring_bankstatement",
                recurringKey: candidate.key,
                confidence: candidate.confidence,
            });
            date = addInterval(date, candidate.interval);
            guard += 1;
        }
    });

    return events;
};

const buildTaxForecastPeriods = (
    documents: any[],
    incomingInvoices: any[],
    periodType: TaxEvaluationPeriod,
    today: dayjs.Dayjs,
    endDate: dayjs.Dayjs
) => {
    const currentBounds = getTaxEvaluationPeriodBounds(today, periodType);
    const periods: TaxForecastPeriod[] = [];
    let offset = 0;

    while (offset < 12) {
        const periodStart = shiftTaxEvaluationPeriodStart(currentBounds.start, periodType, offset);
        const bounds = getTaxEvaluationPeriodBounds(periodStart, periodType);
        const dueDate = getTaxSettlementDate(bounds.end);

        if (dueDate.isAfter(endDate, "day")) break;

        const outputDocs = documents.filter((document) => {
            if (document?.state !== "Gebucht") return false;
            if (!["invoices", "advanceInvoices", "cancellationInvoices"].includes(document?.type)) return false;

            const date = dayjs(document.documentDate);
            return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
        });

        const inputDocs = incomingInvoices.filter((invoice) => {
            if (invoice?.state !== "Gebucht" || !invoice?.date) return false;

            const date = dayjs(invoice.date);
            return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
        });

        const outputTax = roundMoney(outputDocs.reduce((sum, document) => {
            const breakdown = getCreatedDocumentTaxBreakdown(document);
            return sum + breakdown.tax19 + breakdown.tax7;
        }, 0));

        const inputTax = roundMoney(inputDocs.reduce((sum, invoice) => {
            const breakdown = getIncomingInvoiceTaxBreakdown(invoice);
            return sum + breakdown.tax19 + breakdown.tax7;
        }, 0));

        const balance = roundMoney(outputTax - inputTax);

        periods.push({
            key: bounds.start.format("YYYY-MM-DD"),
            label: formatTaxEvaluationPeriodLabel(bounds.start, periodType),
            range: formatTaxEvaluationPeriodRange(bounds.start, periodType),
            dueDate: dueDate.format("YYYY-MM-DD"),
            outputTax,
            inputTax,
            balance,
            outputCount: outputDocs.length,
            inputCount: inputDocs.length,
        });

        offset += 1;
    }

    return periods;
};

const buildSerialTemplateEvents = (
    documents: any[],
    today: dayjs.Dayjs,
    endDate: dayjs.Dayjs
) => {
    const events: ForecastEvent[] = [];

    documents
        .filter((document) => document.type === "serialInvoices")
        .filter((document) => document.serialConfig?.active)
        .forEach((document) => {
            const firstExecution = dayjs(document.serialConfig?.firstExecution);
            const executionUntil = dayjs(document.serialConfig?.executionUntil);

            if (!firstExecution.isValid() || !executionUntil.isValid()) return;

            const amount = getCreatedDocumentGrossAmount(document, documents);
            if (amount <= 0.01) return;

            let executionDate = firstExecution.startOf("day");
            let guard = 0;

            while (executionDate.isBefore(today, "day") && guard < 240) {
                executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
                guard += 1;
            }

            while (
                executionDate.isValid()
                && !executionDate.isAfter(executionUntil, "day")
                && !executionDate.isAfter(endDate, "day")
                && guard < 400
            ) {
                const dueDate = executionDate.add(Number(document.paymentDays || 0), "day");

                events.push({
                    date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
                    amount,
                    label: document.documentNumber || document.title || `Serienvorlage ${document.id}`,
                    source: "serial_template",
                    sourceId: document.id,
                    confidence: 0.75,
                });

                executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
                guard += 1;
            }
        });

    return events.sort((a, b) => a.date.localeCompare(b.date));
};

export const generateLiquidityForecast = async (
    server: FastifyInstance,
    tenantId: number,
    ignoredRecurringKeys: string[] = []
) => {
    const today = dayjs().startOf("day");
    const endDate = today.add(FORECAST_DAYS, "day");
    const historyStart = today.subtract(HISTORY_MONTHS, "month").format("YYYY-MM-DD");

    const [accounts, statements, documents, incomingInvoices, allocations, tenantSettings] = await Promise.all([
        server.db
            .select()
            .from(bankaccounts)
            .where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
        server.db
            .select()
            .from(bankstatements)
            .where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), gte(bankstatements.valueDate, historyStart)))
            .orderBy(desc(bankstatements.valueDate)),
        server.db
            .select()
            .from(createddocuments)
            .where(and(eq(createddocuments.tenant, tenantId), eq(createddocuments.archived, false))),
        server.db
            .select()
            .from(incominginvoices)
            .where(and(eq(incominginvoices.tenant, tenantId), eq(incominginvoices.archived, false))),
        server.db
            .select()
            .from(statementallocations)
            .where(and(eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false))),
        server.db
            .select({
                taxEvaluationPeriod: tenants.taxEvaluationPeriod,
            })
            .from(tenants)
            .where(eq(tenants.id, tenantId))
            .limit(1),
    ]);

    const activeAccounts = accounts.filter((account) => !account.archived);
    const activeStatements = statements.filter((statement) => !statement.archived);
    const activeDocuments = documents.filter((document) => !document.archived);
    const activeIncomingInvoices = incomingInvoices.filter((invoice) => !invoice.archived);
    const activeDocumentIds = new Set(activeDocuments.map((document) => document.id));
    const activeIncomingInvoiceIds = new Set(activeIncomingInvoices.map((invoice) => invoice.id));
    const taxPeriodType = normalizeTaxEvaluationPeriod(tenantSettings[0]?.taxEvaluationPeriod);
    const activeAllocations = allocations.filter((allocation) => {
        if (allocation.archived) return false;
        if (allocation.createddocument && !activeDocumentIds.has(allocation.createddocument)) return false;
        if (allocation.incominginvoice && !activeIncomingInvoiceIds.has(allocation.incominginvoice)) return false;
        return true;
    });

    const startingBalance = roundMoney(
        activeAccounts
            .filter((account) => !account.expired)
            .reduce((sum, account) => sum + Number(account.balance || 0), 0)
    );
    const allocationByDocument = new Map<number, number>();
    const allocationByIncomingInvoice = new Map<number, number>();

    activeAllocations.forEach((allocation) => {
        if (allocation.createddocument) {
            allocationByDocument.set(
                allocation.createddocument,
                roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
            );
        }

        if (allocation.incominginvoice) {
            allocationByIncomingInvoice.set(
                allocation.incominginvoice,
                roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
            );
        }
    });

    const cancelledDocumentIds = findCancellationDocumentIds(activeDocuments);
    const openEvents: ForecastEvent[] = [];
    const draftEvents: ForecastEvent[] = [];

    activeDocuments
        .filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
        .filter((document) => document.state === "Gebucht" && !cancelledDocumentIds.has(document.id))
        .forEach((document) => {
            const total = getCreatedDocumentGrossAmount(document, activeDocuments);
            const openAmount = roundMoney(total - (allocationByDocument.get(document.id) || 0));
            if (openAmount <= 0.01) return;

            const dueDate = dayjs(document.documentDate).isValid()
                ? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
                : today;

            openEvents.push({
                date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
                amount: openAmount,
                label: document.documentNumber || document.title || `Ausgangsbeleg ${document.id}`,
                source: "open_createddocument",
                sourceId: document.id,
                confidence: 1,
            });
        });

    activeDocuments
        .filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
        .filter((document) => document.state === "Entwurf" && !cancelledDocumentIds.has(document.id))
        .forEach((document) => {
            const total = getCreatedDocumentGrossAmount(document, activeDocuments);
            if (total <= 0.01) return;

            const dueDate = dayjs(document.documentDate).isValid()
                ? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
                : today;

            draftEvents.push({
                date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
                amount: total,
                label: document.documentNumber || document.title || `Rechnungsentwurf ${document.id}`,
                source: "draft_createddocument",
                sourceId: document.id,
                confidence: 0.35,
            });
        });

    activeIncomingInvoices
        .filter((invoice) => invoice.state === "Gebucht" || invoice.state === "Vorbereitet")
        .filter((invoice) => !invoice.paid)
        .forEach((invoice) => {
            const signedAmount = getIncomingInvoiceSignedAmount(invoice);
            const openAmount = getRemainingSignedAmount(signedAmount, allocationByIncomingInvoice.get(invoice.id) || 0);
            if (Math.abs(openAmount) <= 0.01) return;

            const dueDate = dayjs(invoice.dueDate || invoice.date).isValid()
                ? dayjs(invoice.dueDate || invoice.date)
                : today;

            openEvents.push({
                date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
                amount: openAmount,
                label: invoice.reference || invoice.description || `Eingangsbeleg ${invoice.id}`,
                source: "open_incominginvoice",
                sourceId: invoice.id,
                confidence: invoice.state === "Gebucht" ? 1 : 0.8,
            });
        });

    const heuristicRecurring = detectRecurringHeuristically(activeStatements);
    const aiRecurring = await detectRecurringWithAi(server, activeStatements);
    const ignoredRecurringKeySet = new Set(ignoredRecurringKeys.filter(Boolean));
    const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring)
        .filter((candidate) => !candidate.key || !ignoredRecurringKeySet.has(candidate.key));
    const taxPeriods = buildTaxForecastPeriods(activeDocuments, activeIncomingInvoices, taxPeriodType, today, endDate);
    const taxEvents: ForecastEvent[] = taxPeriods
        .filter((period) => Math.abs(period.balance) > 0.01)
        .map((period) => ({
            date: period.dueDate,
            amount: roundMoney(period.balance * -1),
            label: `USt ${period.label}`,
            source: "tax_settlement" as const,
            sourceId: period.key,
            confidence: 0.95,
        }));
    const serialTemplateEvents = buildSerialTemplateEvents(activeDocuments, today, endDate);
    const events = [
        ...openEvents,
        ...taxEvents,
        ...serialTemplateEvents,
        ...expandRecurringEvents(recurring, endDate),
    ].filter((event) => {
        const date = dayjs(event.date);
        return date.isValid() && !date.isAfter(endDate, "day");
    });

    const dailyEvents = new Map<string, ForecastEvent[]>();
    events.forEach((event) => {
        if (!dailyEvents.has(event.date)) dailyEvents.set(event.date, []);
        dailyEvents.get(event.date)!.push(event);
    });

    let runningBalance = startingBalance;
    const points = [];

    for (let offset = 0; offset <= FORECAST_DAYS; offset += 1) {
        const date = today.add(offset, "day").format("YYYY-MM-DD");
        const dayEvents = dailyEvents.get(date) || [];
        const income = roundMoney(dayEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
        const expense = roundMoney(dayEvents.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));

        runningBalance = roundMoney(runningBalance + income + expense);

        points.push({
            date,
            balance: runningBalance,
            income,
            expense,
            events: dayEvents.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)),
        });
    }

    const lowestPoint = points.reduce((lowest, point) => point.balance < lowest.balance ? point : lowest, points[0]);
    const totalIncome = roundMoney(events.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
    const totalExpense = roundMoney(events.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
    const draftIncome = roundMoney(draftEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));

    return {
        generatedAt: new Date().toISOString(),
        horizonDays: FORECAST_DAYS,
        startingBalance,
        endingBalance: points[points.length - 1]?.balance || startingBalance,
        lowestBalance: lowestPoint?.balance || startingBalance,
        lowestBalanceDate: lowestPoint?.date || today.format("YYYY-MM-DD"),
        totalIncome,
        totalExpense,
        accounts: activeAccounts.map((account) => ({
            id: account.id,
            name: account.name,
            iban: account.iban,
            balance: roundMoney(Number(account.balance || 0)),
            expired: account.expired,
            syncedAt: account.syncedAt,
        })),
        recurring,
        events: events.sort((a, b) => a.date.localeCompare(b.date)),
        draftEvents: draftEvents.sort((a, b) => a.date.localeCompare(b.date)),
        draftIncome,
        tax: {
            periodType: taxPeriodType,
            periods: taxPeriods,
            totalBalance: roundMoney(taxPeriods.reduce((sum, period) => sum + period.balance, 0)),
        },
        points,
        ai: {
            enabled: Boolean(secrets.OPENAI_API_KEY),
            candidates: aiRecurring.length,
        },
    };
};
