// @WHO:   vps-deploy/api/kds/src/lib/regression.ts
// @WHAT:  OLS Linear Regression engine for adaptive inventory — estimates per-ingredient per-menu usage from historical order + stock data
// @PART:  inventory
// @ENTRY: recalculateEstimates(), recalculateBaselines()

import { prisma } from './prisma';

// ── Matrix helpers (small-matrix OLS — max ~20 menus per ingredient) ──

// @FLOW:   MATRIX_TRANSPOSE
// @CALLS:  none
// @MUTATES: none
function matrixTranspose(A: number[][]): number[][] {
  const rows = A.length;
  const cols = A[0].length;
  const result: number[][] = [];
  for (let j = 0; j < cols; j++) {
    result[j] = [];
    for (let i = 0; i < rows; i++) {
      result[j][i] = A[i][j];
    }
  }
  return result;
}

// @FLOW:   MATRIX_MULTIPLY
// @CALLS:  none
// @MUTATES: none
function matrixMultiply(A: number[][], B: number[][]): number[][] {
  const aRows = A.length;
  const aCols = A[0].length;
  const bCols = B[0].length;
  const result: number[][] = [];
  for (let i = 0; i < aRows; i++) {
    result[i] = [];
    for (let j = 0; j < bCols; j++) {
      let sum = 0;
      for (let k = 0; k < aCols; k++) {
        sum += A[i][k] * B[k][j];
      }
      result[i][j] = sum;
    }
  }
  return result;
}

// @FLOW:   MATRIX_INVERT
// @CALLS:  none
// @MUTATES: none
// @BEHAVIOR: Gauss-Jordan elimination for small square matrices. Returns null if singular.
function matrixInvert(A: number[][]): number[][] | null {
  const n = A.length;
  // Augment with identity
  const aug: number[][] = [];
  for (let i = 0; i < n; i++) {
    aug[i] = [...A[i]];
    for (let j = 0; j < n; j++) {
      aug[i][j + n] = i === j ? 1 : 0;
    }
  }

  for (let col = 0; col < n; col++) {
    // Find pivot
    let maxRow = col;
    let maxVal = Math.abs(aug[col][col]);
    for (let row = col + 1; row < n; row++) {
      if (Math.abs(aug[row][col]) > maxVal) {
        maxVal = Math.abs(aug[row][col]);
        maxRow = row;
      }
    }
    if (maxVal < 1e-10) return null; // Singular

    // Swap rows
    if (maxRow !== col) {
      [aug[col], aug[maxRow]] = [aug[maxRow], aug[col]];
    }

    // Scale pivot row
    const pivot = aug[col][col];
    for (let j = 0; j < 2 * n; j++) {
      aug[col][j] /= pivot;
    }

    // Eliminate other rows
    for (let row = 0; row < n; row++) {
      if (row === col) continue;
      const factor = aug[row][col];
      for (let j = 0; j < 2 * n; j++) {
        aug[row][j] -= factor * aug[col][j];
      }
    }
  }

  // Extract inverse
  const result: number[][] = [];
  for (let i = 0; i < n; i++) {
    result[i] = aug[i].slice(n);
  }
  return result;
}

// @FLOW:   SOLVE_OLS
// @CALLS:  matrixTranspose, matrixMultiply, matrixInvert
// @MUTATES: none
// @BEHAVIOR: Solves β = (XᵀX)⁻¹XᵀY. Returns zero vector if matrix is singular.
function solveOLS(X: number[][], Y: number[]): number[] {
  const k = X[0].length; // number of coefficients
  if (X.length === 0 || k === 0) return new Array(k).fill(0);

  const Xt = matrixTranspose(X);
  const XtX = matrixMultiply(Xt, X);
  const XtXinv = matrixInvert(XtX);

  if (!XtXinv) return new Array(k).fill(0); // Singular — return zeros

  const XtY = matrixMultiply(Xt, Y.map((y) => [y]));
  const beta = matrixMultiply(XtXinv, XtY);

  return beta.map((row) => row[0]);
}

// @FLOW:   CONFIDENCE_SCORE
// @CALLS:  none
// @MUTATES: none
// @BEHAVIOR: Sigmoid-based confidence: <10 days → 0.1, 10-30 → 0.3-0.6, 30-60 → 0.6-0.8, >60 → 0.8-0.95
function confidenceScore(dataPoints: number): number {
  if (dataPoints < 1) return 0;
  if (dataPoints < 10) return 0.1;
  if (dataPoints < 30) return 0.3 + 0.3 * ((dataPoints - 10) / 20);
  if (dataPoints < 60) return 0.6 + 0.2 * ((dataPoints - 30) / 30);
  return Math.min(0.95, 0.8 + 0.15 * (1 - Math.exp(-(dataPoints - 60) / 60)));
}

// ── Exported functions ──

// @FLOW:     REGRESSION_RECALCULATE
// @CALLS:    DB:outlet_ingredients SELECT, DB:stock_logs SELECT, DB:recipes SELECT,
//            DB:order_items SELECT (via raw), DB:ingredient_usage_estimates UPSERT
// @MUTATES:  DB:ingredient_usage_estimates (UPSERT per ingredient-menu pair)
// @BEHAVIOR: For each ingredient with orders: builds daily X(menu counts) → Y(stock reduction) matrix,
//            solves OLS, stores positive coefficients as estimatedUsage. Skips ingredients with no recipes.
export async function recalculateEstimates(outletId: string): Promise<void> {
  const now = new Date();
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);

  // Get all ingredients that have recipes in this outlet's menus
  const ingredientsWithRecipes = await prisma.ingredient.findMany({
    where: {
      recipes: {
        some: {
          menu: { outletId },
        },
      },
    },
    include: {
      recipes: {
        where: { menu: { outletId } },
        select: { menuId: true },
      },
    },
  });

  // Also get ingredients that have stock logs but no recipes (for regression)
  const outletIngredients = await prisma.outletIngredient.findMany({
    where: { outletId },
    select: { ingredientId: true },
  });
  const outletIngredientIds = new Set(outletIngredients.map((oi) => oi.ingredientId));

  // Merge: ingredients with recipes + outlet ingredients
  const candidateIds = new Set([
    ...ingredientsWithRecipes.map((i) => i.id),
    ...outletIngredientIds,
  ]);

  for (const ingredientId of candidateIds) {
    // Get distinct menu IDs that use this ingredient (via Recipe)
    const recipes = await prisma.recipe.findMany({
      where: { ingredientId },
      include: { menu: { select: { outletId: true } } },
    });

    // Only keep recipes for this outlet's menus
    const outletRecipes = recipes.filter((r) => r.menu.outletId === outletId);
    const menuIds = [...new Set(outletRecipes.map((r) => r.menuId))];

    // If no recipes link this ingredient to any menu, we can't regress
    if (menuIds.length === 0) continue;

    // Get daily stock reduction (OUT type) for last 30 days
    const stockLogs = await prisma.stockLog.findMany({
      where: {
        outletId,
        ingredientId,
        type: 'OUT',
        createdAt: { gte: thirtyDaysAgo },
      },
      select: { quantity: true, createdAt: true },
    });

    // If no stock reduction data, skip
    if (stockLogs.length === 0) continue;

    // Group stock reductions by day
    const dailyReduction = new Map<string, number>();
    for (const log of stockLogs) {
      const dayKey = log.createdAt.toISOString().slice(0, 10);
      dailyReduction.set(dayKey, (dailyReduction.get(dayKey) || 0) + Math.abs(log.quantity));
    }

    // Get daily order counts per menu for orders in this outlet
    // We need orders from last 30 days that contain these menuIds
    const orders = await prisma.order.findMany({
      where: {
        outletId,
        createdAt: { gte: thirtyDaysAgo },
        items: { some: { menuId: { in: menuIds } } },
      },
      select: {
        createdAt: true,
        items: {
          where: { menuId: { in: menuIds } },
          select: { menuId: true, quantity: true },
        },
      },
    });

    // Group order items by day → menuId → total quantity
    const dailyMenuCounts = new Map<string, Map<string, number>>();
    for (const order of orders) {
      const dayKey = order.createdAt.toISOString().slice(0, 10);
      if (!dailyMenuCounts.has(dayKey)) dailyMenuCounts.set(dayKey, new Map());
      const dayMenus = dailyMenuCounts.get(dayKey)!;
      for (const item of order.items) {
        dayMenus.set(item.menuId, (dayMenus.get(item.menuId) || 0) + item.quantity);
      }
    }

    // Build X and Y: only use days where we have both stock reduction and order data
    const allDays = new Set([...dailyReduction.keys(), ...dailyMenuCounts.keys()]);
    const X: number[][] = [];
    const Y: number[] = [];

    for (const dayKey of allDays) {
      const yVal = dailyReduction.get(dayKey) || 0;
      const row: number[] = [];
      for (const menuId of menuIds) {
        row.push(dailyMenuCounts.get(dayKey)?.get(menuId) || 0);
      }
      X.push(row);
      Y.push(yVal);
    }

    if (X.length === 0) continue;

    // Solve OLS
    const betas = solveOLS(X, Y);
    const dataPoints = X.length;
    const conf = confidenceScore(dataPoints);

    // Upsert estimates for each menu coefficient
    for (let i = 0; i < menuIds.length; i++) {
      const estimatedUsage = Math.max(0, betas[i]); // Only positive coefficients

      // If estimate is essentially zero and no data, skip upsert
      if (estimatedUsage === 0 && dataPoints < 5) continue;

      // Calculate standard error (simplified: residual std dev / sqrt(dataPoints))
      let residualSumSq = 0;
      for (let d = 0; d < X.length; d++) {
        let predicted = 0;
        for (let j = 0; j < menuIds.length; j++) {
          predicted += betas[j] * X[d][j];
        }
        residualSumSq += (Y[d] - predicted) ** 2;
      }
      const residualStdDev = X.length > menuIds.length
        ? Math.sqrt(residualSumSq / (X.length - menuIds.length))
        : 0;
      const stdError = X.length > 0 ? residualStdDev / Math.sqrt(X.length) : null;

      await prisma.ingredientUsageEstimate.upsert({
        where: {
          outletId_ingredientId_menuId: {
            outletId,
            ingredientId,
            menuId: menuIds[i],
          },
        },
        create: {
          outletId,
          ingredientId,
          menuId: menuIds[i],
          estimatedUsage,
          confidenceScore: estimatedUsage > 0 ? conf : 0,
          dataPoints,
          standardError: stdError,
          source: 'REGRESSION',
          lastCalculated: now,
          periodStart: thirtyDaysAgo,
          periodEnd: now,
        },
        update: {
          estimatedUsage,
          confidenceScore: estimatedUsage > 0 ? conf : 0,
          dataPoints,
          standardError: stdError,
          source: 'REGRESSION',
          lastCalculated: now,
          periodStart: thirtyDaysAgo,
          periodEnd: now,
        },
      });
    }
  }
}

// @FLOW:     BASELINE_RECALCULATE
// @CALLS:    DB:outlet_ingredients SELECT, DB:stock_logs SELECT,
//            DB:depletion_baselines UPSERT
// @MUTATES:  DB:depletion_baselines (UPSERT per ingredient-dayOfWeek pair)
// @BEHAVIOR: For each ingredient: groups daily depletion by dayOfWeek, computes mean/stdDev/predictedMax/criticalMax.
//            Uses absolute value of OUT/WASTE quantities as depletion.
export async function recalculateBaselines(outletId: string): Promise<void> {
  const now = new Date();
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);

  const outletIngs = await prisma.outletIngredient.findMany({
    where: { outletId },
    select: { ingredientId: true },
  });

  for (const { ingredientId } of outletIngs) {
    // Get all OUT and WASTE stock logs for last 30 days
    const logs = await prisma.stockLog.findMany({
      where: {
        outletId,
        ingredientId,
        type: { in: ['OUT', 'WASTE'] },
        createdAt: { gte: thirtyDaysAgo },
      },
      select: { quantity: true, createdAt: true },
    });

    if (logs.length === 0) continue;

    // Group by dayOfWeek
    // JS: Sunday=0, Monday=1... but schema says 0=Senin(Monday), 6=Minggu(Sunday)
    // Convert: JS day → schema day: (jsDay + 6) % 7
    const byDayOfWeek: Map<number, number[]> = new Map();
    for (let d = 0; d < 7; d++) byDayOfWeek.set(d, []);

    for (const log of logs) {
      const jsDay = log.createdAt.getDay();
      const schemaDay = (jsDay + 6) % 7; // 0=Monday, 6=Sunday
      const depletion = Math.abs(log.quantity);
      byDayOfWeek.get(schemaDay)!.push(depletion);
    }

    // Aggregate by actual day to get daily totals (multiple logs per day)
    const dailyTotals = new Map<string, { depletion: number; dayOfWeek: number }>();
    for (const log of logs) {
      const dayKey = log.createdAt.toISOString().slice(0, 10);
      const jsDay = log.createdAt.getDay();
      const schemaDay = (jsDay + 6) % 7;
      const existing = dailyTotals.get(dayKey);
      if (existing) {
        existing.depletion += Math.abs(log.quantity);
      } else {
        dailyTotals.set(dayKey, { depletion: Math.abs(log.quantity), dayOfWeek: schemaDay });
      }
    }

    // Re-group by dayOfWeek using daily totals
    const byDOW: Map<number, number[]> = new Map();
    for (let d = 0; d < 7; d++) byDOW.set(d, []);
    for (const { depletion, dayOfWeek } of dailyTotals.values()) {
      byDOW.get(dayOfWeek)!.push(depletion);
    }

    for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++) {
      const values = byDOW.get(dayOfWeek)!;
      if (values.length === 0) continue;

      const n = values.length;
      const mean = values.reduce((a, b) => a + b, 0) / n;
      const variance = n > 1
        ? values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (n - 1)
        : 0;
      const stdDev = Math.sqrt(variance);

      const predictedMax = mean + 2 * stdDev;
      const criticalMax = mean + 3 * stdDev;

      await prisma.depletionBaseline.upsert({
        where: {
          outletId_ingredientId_dayOfWeek: {
            outletId,
            ingredientId,
            dayOfWeek,
          },
        },
        create: {
          outletId,
          ingredientId,
          dayOfWeek,
          meanDepletion: mean,
          stdDeviation: stdDev,
          predictedMax,
          criticalMax,
          dataPoints: n,
          periodStart: thirtyDaysAgo,
          periodEnd: now,
          lastCalculated: now,
        },
        update: {
          meanDepletion: mean,
          stdDeviation: stdDev,
          predictedMax,
          criticalMax,
          dataPoints: n,
          periodStart: thirtyDaysAgo,
          periodEnd: now,
          lastCalculated: now,
        },
      });
    }
  }
}
