  import { unstable_cache } from "next/cache";
  import { prisma } from "@/lib/prisma";
  import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
  import { getRecapDataCached } from "@/lib/recap/getRecapData";
  import {
    buildTimelineSegments,
    compressTimelineSegments,
    TIMELINE_EVENT_TYPES,
    type TimelineCycleRow,
    type TimelineEventRow,
  } from "@/lib/recap/timeline";
  import { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState";
  import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
  import type {
    RecapDetailResponse,
    RecapMachine,
    RecapMachineDetail,
    RecapMachineStatus,
    RecapRangeMode,
    RecapStateContext,
    RecapSummaryMachine,
    RecapSummaryResponse,
  } from "@/lib/recap/types";

  type DetailRangeInput = {
    mode?: string | null;
    start?: string | null;
    end?: string | null;
  };

  const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
  const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
  const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
  const RECAP_CACHE_TTL_SEC = 60;
  const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
  const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
    Mon: "mon",
    Tue: "tue",
    Wed: "wed",
    Thu: "thu",
    Fri: "fri",
    Sat: "sat",
    Sun: "sun",
  };

  function round2(value: number) {
    return Math.round(value * 100) / 100;
  }

  function parseDate(input?: string | null) {
    if (!input) return null;
    const n = Number(input);
    if (Number.isFinite(n)) {
      const d = new Date(n);
      return Number.isFinite(d.getTime()) ? d : null;
    }
    const d = new Date(input);
    return Number.isFinite(d.getTime()) ? d : null;
  }

  function parseHours(input: string | null) {
    const parsed = Math.trunc(Number(input ?? "24"));
    if (!Number.isFinite(parsed)) return 24;
    return Math.max(1, Math.min(72, parsed));
  }

  function parseTimeMinutes(input?: string | null) {
    if (!input) return null;
    const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
    if (!match) return null;
    const hours = Number(match[1]);
    const minutes = Number(match[2]);
    if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
      return null;
    }
    return hours * 60 + minutes;
  }

  function getLocalParts(ts: Date, timeZone: string) {
    try {
      const parts = new Intl.DateTimeFormat("en-US", {
        timeZone,
        year: "numeric",
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
        weekday: "short",
        hour12: false,
      }).formatToParts(ts);

      const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
      const year = Number(value("year"));
      const month = Number(value("month"));
      const day = Number(value("day"));
      const hour = Number(value("hour"));
      const minute = Number(value("minute"));
      const weekday = value("weekday");

      return {
        year,
        month,
        day,
        hour,
        minute,
        weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
        minutesOfDay: hour * 60 + minute,
      };
    } catch {
      return {
        year: ts.getUTCFullYear(),
        month: ts.getUTCMonth() + 1,
        day: ts.getUTCDate(),
        hour: ts.getUTCHours(),
        minute: ts.getUTCMinutes(),
        weekday: WEEKDAY_KEYS[ts.getUTCDay()],
        minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
      };
    }
  }

  function parseOffsetMinutes(offsetLabel: string | null) {
    if (!offsetLabel) return null;
    const normalized = offsetLabel.replace("UTC", "GMT");
    const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
    if (!match) return null;
    const sign = match[1] === "-" ? -1 : 1;
    const hour = Number(match[2]);
    const minute = Number(match[3] ?? "0");
    if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
    return sign * (hour * 60 + minute);
  }

  function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
    try {
      const parts = new Intl.DateTimeFormat("en-US", {
        timeZone,
        timeZoneName: "shortOffset",
        hour: "2-digit",
      }).formatToParts(utcDate);
      const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
      return parseOffsetMinutes(offsetPart);
    } catch {
      return null;
    }
  }

  function zonedToUtcDate(input: {
    year: number;
    month: number;
    day: number;
    hours: number;
    minutes: number;
    timeZone: string;
  }) {
    const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
    const guessDate = new Date(baseUtc);
    const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
    if (offsetA == null) return guessDate;

    let corrected = new Date(baseUtc - offsetA * 60000);
    const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
    if (offsetB != null && offsetB !== offsetA) {
      corrected = new Date(baseUtc - offsetB * 60000);
    }

    return corrected;
  }

  function addDays(input: { year: number; month: number; day: number }, days: number) {
    const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
    base.setUTCDate(base.getUTCDate() + days);
    return {
      year: base.getUTCFullYear(),
      month: base.getUTCMonth() + 1,
      day: base.getUTCDate(),
    };
  }

  // Detect active episodes (macrostop, mold-change) from event rows.
  // Returns the latest non-auto-ack episode whose final status is "active"
  // and that's been refreshed within ACTIVE_STALE_MS.
  const ACTIVE_STALE_MS = 2 * 60 * 1000;

  type ActiveEpisode = { startedAtMs: number; lastTsMs: number };

  function detectActiveEpisode(
    events: TimelineEventRow[] | undefined,
    eventType: "macrostop" | "mold-change",
    endMs: number
  ): ActiveEpisode | null {
    if (!events || events.length === 0) return null;

    type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null };
    const episodes = new Map<string, Episode>();

    for (const event of events) {
      if (String(event.eventType || "").toLowerCase() !== eventType) continue;

      let parsed: unknown = event.data;
      if (typeof parsed === "string") {
        try { parsed = JSON.parse(parsed); } catch { parsed = null; }
      }
      const data: Record<string, unknown> =
        parsed && typeof parsed === "object" && !Array.isArray(parsed)
          ? (parsed as Record<string, unknown>)
          : {};

      const isAutoAck =
        data.is_auto_ack === true ||
        data.isAutoAck === true ||
        data.is_auto_ack === "true" ||
        data.isAutoAck === "true";
      if (isAutoAck) continue;

      const status = String(data.status ?? "").trim().toLowerCase();
      const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
        || `${eventType}:${event.ts.getTime()}`;
      const tsMs = event.ts.getTime();
      const lastCycleTs = Number(data.last_cycle_timestamp);

      const existing = episodes.get(incidentKey);
      if (!existing) {
        episodes.set(incidentKey, {
          firstTsMs: tsMs,
          lastTsMs: tsMs,
          lastStatus: status,
          lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null,
        });
        continue;
      }
      existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
      if (tsMs >= existing.lastTsMs) {
        existing.lastTsMs = tsMs;
        existing.lastStatus = status;
      }
    }

    let best: ActiveEpisode | null = null;
    for (const ep of episodes.values()) {
      if (ep.lastStatus !== "active") continue;
      if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue;
      // Prefer the freshest active episode (highest lastTsMs)
      if (!best || ep.lastTsMs > best.lastTsMs) {
        best = {
          startedAtMs: ep.lastCycleTs ?? ep.firstTsMs,
          lastTsMs: ep.lastTsMs,
        };
      }
    }
    return best;
  }

  function statusFromMachine(
    machine: RecapMachine,
    endMs: number,
    events?: TimelineEventRow[]
  ): {
    status: RecapMachineStatus;
    result: MachineStateResult;
    stateContext: RecapStateContext;
    lastSeenMs: number | null;
    offlineForMin: number | null;
    ongoingStopMin: number | null;
  } {
    const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
    const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
    const heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS;

    const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs);
    const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs);

    // Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries
    // we don't yet plumb here. We approximate from the legacy fields:
    //   - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on)
    //                      OR when an active WO exists and machine.workOrders.moldChangeInProgress is false.
    //                      This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read.
    //   - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI)
    //
    // Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking
    // is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet.
    // IDLE fires correctly when there's no WO and no recent activity.
    const hasActiveWorkOrder = machine.workOrders.active != null;
    const trackingEnabledApprox = hasActiveWorkOrder; // see comment above

    const lastCycleTsMs = (() => {
      // Best-effort: use the machine's heartbeat as a "recent activity" proxy.
      // The Pi only heartbeats every minute regardless of cycles, so this is a weak signal.
      // Round 3 will pass the actual latest cycle ts.
      return lastSeenMs;
    })();

    const result = classifyMachineState(
      {
        heartbeatAlive,
        lastSeenMs,
        offlineForMs,
        trackingEnabled: trackingEnabledApprox,
        hasActiveWorkOrder,
        activeMoldChange,
        activeMacrostop,
        lastCycleTsMs,
      },
      endMs
    );

    // Map the rich classifier result back to the existing RecapMachineStatus union
    const status: RecapMachineStatus = result.state;

    // Pull common fields out for the caller's convenience
    let ongoingStopMin: number | null = null;
    if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin;

    const stateContext: RecapStateContext = {};


    return {
      status,
      result,
      stateContext,
      lastSeenMs,
      offlineForMin: result.state === "offline" ? result.offlineForMin : null,
      ongoingStopMin,
    };
  }

  async function loadTimelineRowsForMachines(params: {
    orgId: string;
    machineIds: string[];
    start: Date;
    end: Date;
  }) {
    if (!params.machineIds.length) {
      return {
        cyclesByMachine: new Map<string, TimelineCycleRow[]>(),
        eventsByMachine: new Map<string, TimelineEventRow[]>(),
      };
    }

    const [cycles, events] = await Promise.all([
      prisma.machineCycle.findMany({
        where: {
          orgId: params.orgId,
          machineId: { in: params.machineIds },
          ts: {
            gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
            lte: params.end,
          },
        },
        orderBy: [{ machineId: "asc" }, { ts: "asc" }],
        select: {
          machineId: true,
          ts: true,
          cycleCount: true,
          actualCycleTime: true,
          theoreticalCycleTime: true,
          workOrderId: true,
          sku: true,
        },
      }),
      prisma.machineEvent.findMany({
        where: {
          orgId: params.orgId,
          machineId: { in: params.machineIds },
          eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
          ts: {
            gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
            lte: params.end,
          },
        },
        orderBy: [{ machineId: "asc" }, { ts: "asc" }],
        select: {
          machineId: true,
          ts: true,
          eventType: true,
          data: true,
        },
      }),
    ]);

    const cyclesByMachine = new Map<string, TimelineCycleRow[]>();
    const eventsByMachine = new Map<string, TimelineEventRow[]>();

    for (const row of cycles) {
      const list = cyclesByMachine.get(row.machineId) ?? [];
        list.push({
        ts: row.ts,
        cycleCount: row.cycleCount,
        actualCycleTime: row.actualCycleTime,
        theoreticalCycleTime: row.theoreticalCycleTime ?? null,
        workOrderId: row.workOrderId,
        sku: row.sku,
      });
      cyclesByMachine.set(row.machineId, list);
    }

    for (const row of events) {
      const list = eventsByMachine.get(row.machineId) ?? [];
      list.push({
        ts: row.ts,
        eventType: row.eventType,
        data: row.data,
      });
      eventsByMachine.set(row.machineId, list);
    }

    return { cyclesByMachine, eventsByMachine };
  }

  function toSummaryMachine(params: {
    machine: RecapMachine;
    miniTimeline: ReturnType<typeof compressTimelineSegments>;
    rangeEndMs: number;
    events?: TimelineEventRow[];
  }): RecapSummaryMachine {
    const { machine, miniTimeline, rangeEndMs, events } = params;
    const status = statusFromMachine(machine, rangeEndMs, events);

    return {
      machineId: machine.machineId,
      name: machine.machineName,
      location: machine.location,
      status: status.status,
      oee: machine.oee.avg,
      goodParts: machine.production.goodParts,
      scrap: machine.production.scrapParts,
      stopsCount: machine.downtime.stopsCount,
      lastSeenMs: status.lastSeenMs,
      lastActivityMin:
        status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
      offlineForMin: status.offlineForMin,
      ongoingStopMin: status.ongoingStopMin,
      stateContext: status.stateContext,
      activeWorkOrderId: machine.workOrders.active?.id ?? null,
      moldChange: {
        active: machine.workOrders.moldChangeInProgress,
        startMs: machine.workOrders.moldChangeStartMs,
        elapsedMin:
          machine.workOrders.moldChangeStartMs == null
            ? null
            : Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
      },
      miniTimeline,
    };
  }

  async function computeRecapSummary(params: { orgId: string; hours: number }) {
    const now = new Date();
    const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
    const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);

    const recap = await getRecapDataCached({
      orgId: params.orgId,
      start,
      end,
    });

    const machineIds = recap.machines.map((machine) => machine.machineId);
    const timelineRows = await loadTimelineRowsForMachines({
      orgId: params.orgId,
      machineIds,
      start,
      end,
    });

    const machines = recap.machines.map((machine) => {
      const segments = buildTimelineSegments({
        cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
        events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
        rangeStart: start,
        rangeEnd: end,
      });
      const miniTimeline = compressTimelineSegments({
        segments,
        rangeStart: start,
        rangeEnd: end,
        maxSegments: 60,
      });

      return toSummaryMachine({
        machine,
        miniTimeline,
        rangeEndMs: end.getTime(),
        events: timelineRows.eventsByMachine.get(machine.machineId),
      });
    });

    const response: RecapSummaryResponse = {
      generatedAt: new Date().toISOString(),
      range: {
        start: start.toISOString(),
        end: end.toISOString(),
        hours: params.hours,
      },
      machines,
    };

    return response;
  }

  function normalizedRangeMode(mode?: string | null): RecapRangeMode {
    const raw = String(mode ?? "").trim().toLowerCase();
    if (raw === "shift") return "shift";
    if (raw === "yesterday") return "yesterday";
    if (raw === "custom") return "custom";
    return "24h";
  }

  async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
    const settings = await prisma.orgSettings.findUnique({
      where: { orgId: params.orgId },
      select: {
        timezone: true,
        shiftScheduleOverridesJson: true,
      },
    });
    const shifts = await prisma.orgShift.findMany({
      where: { orgId: params.orgId },
      orderBy: { sortOrder: "asc" },
      select: {
        name: true,
        startTime: true,
        endTime: true,
        enabled: true,
        sortOrder: true,
      },
    });

    const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
    if (!enabledShifts.length) {
      return {
        hasEnabledShifts: false,
        range: null,
      } as const;
    }

    const timeZone = settings?.timezone || "UTC";
    const local = getLocalParts(params.now, timeZone);
    const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
    const dayOverrides = overrides?.[local.weekday];
    const activeShifts = (dayOverrides?.length
      ? dayOverrides.map((shift) => ({
          enabled: shift.enabled !== false,
          start: shift.start,
          end: shift.end,
        }))
      : enabledShifts.map((shift) => ({
          enabled: shift.enabled !== false,
          start: shift.startTime,
          end: shift.endTime,
        }))
    ).filter((shift) => shift.enabled);

    for (const shift of activeShifts) {
      const startMin = parseTimeMinutes(shift.start ?? null);
      const endMin = parseTimeMinutes(shift.end ?? null);
      if (startMin == null || endMin == null) continue;

      const minutesNow = local.minutesOfDay;
      let inRange = false;
      let startDate = { year: local.year, month: local.month, day: local.day };
      let endDate = { year: local.year, month: local.month, day: local.day };

      if (startMin <= endMin) {
        inRange = minutesNow >= startMin && minutesNow < endMin;
      } else {
        inRange = minutesNow >= startMin || minutesNow < endMin;
        if (minutesNow >= startMin) {
          endDate = addDays(endDate, 1);
        } else {
          startDate = addDays(startDate, -1);
        }
      }

      if (!inRange) continue;

      const start = zonedToUtcDate({
        ...startDate,
        hours: Math.floor(startMin / 60),
        minutes: startMin % 60,
        timeZone,
      });
      const shiftEndUtc = zonedToUtcDate({
        ...endDate,
        hours: Math.floor(endMin / 60),
        minutes: endMin % 60,
        timeZone,
      });

      if (shiftEndUtc <= start) continue;

      // Cap end at "now" so we render shift-so-far, not shift-as-planned.
      // Without cap:
      //   - timeline fills future minutes with idle (visual lie)
      //   - offline calc = (shift_end_future - last_seen) = looks 5h offline
      //     even on a machine producing right now
      const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;

      return {
        hasEnabledShifts: true,
        range: { start, end },
      };
    }

    return {
      hasEnabledShifts: true,
      range: null,
    } as const;
  }

  async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
    const now = new Date(Math.floor(Date.now() / 60000) * 60000);
    const requestedMode = normalizedRangeMode(params.input.mode);
    const shiftEnabledCount = await prisma.orgShift.count({
      where: {
        orgId: params.orgId,
        enabled: { not: false },
      },
    });
    const shiftAvailable = shiftEnabledCount > 0;

    if (requestedMode === "custom") {
      const start = parseDate(params.input.start);
      const end = parseDate(params.input.end);
      if (start && end && end > start) {
        return {
          requestedMode,
          mode: requestedMode,
          start,
          end,
          shiftAvailable,
        } as const;
      }
    }

    if (requestedMode === "yesterday") {
      const settings = await prisma.orgSettings.findUnique({
        where: { orgId: params.orgId },
        select: { timezone: true },
      });
      const timeZone = settings?.timezone || "America/Mexico_City";
      const localNow = getLocalParts(now, timeZone);
      const today = { year: localNow.year, month: localNow.month, day: localNow.day };
      const yesterday = addDays(today, -1);
      const start = zonedToUtcDate({
        ...yesterday,
        hours: 0,
        minutes: 0,
        timeZone,
      });
      const end = zonedToUtcDate({
        ...today,
        hours: 0,
        minutes: 0,
        timeZone,
      });
      return {
        requestedMode,
        mode: requestedMode,
        start,
        end,
        shiftAvailable,
      } as const;
    }

    if (requestedMode === "shift") {
      const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
      if (shiftRange.range) {
        return {
          requestedMode,
          mode: requestedMode,
          start: shiftRange.range.start,
          end: shiftRange.range.end,
          shiftAvailable,
        } as const;
      }
      if (!shiftRange.hasEnabledShifts) {
        return {
          requestedMode,
          mode: "24h" as const,
          start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
          end: now,
          shiftAvailable,
          fallbackReason: "shift-unavailable" as const,
        } as const;
      }
      return {
        requestedMode,
        mode: "24h" as const,
        start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
        end: now,
        shiftAvailable,
        fallbackReason: "shift-inactive" as const,
      } as const;
    }

    return {
      requestedMode,
      mode: "24h" as const,
      start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
      end: now,
      shiftAvailable,
    } as const;
  }

  async function computeRecapMachineDetail(params: {
    orgId: string;
    machineId: string;
    range: {
      requestedMode: RecapRangeMode;
      mode: RecapRangeMode;
      start: Date;
      end: Date;
      shiftAvailable: boolean;
      fallbackReason?: "shift-unavailable" | "shift-inactive";
    };
  }) {
    const { range } = params;

    const recap = await getRecapDataCached({
      orgId: params.orgId,
      machineId: params.machineId,
      start: range.start,
      end: range.end,
    });

    const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null;
    if (!machine) return null;

    const timelineRows = await loadTimelineRowsForMachines({
      orgId: params.orgId,
      machineIds: [params.machineId],
      start: range.start,
      end: range.end,
    });

    const timeline = buildTimelineSegments({
      cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [],
      events: timelineRows.eventsByMachine.get(params.machineId) ?? [],
      rangeStart: range.start,
      rangeEnd: range.end,
    });

    const status = statusFromMachine(
      machine,
      range.end.getTime(),
      timelineRows.eventsByMachine.get(params.machineId)
    );

    const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
    const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
      reasonLabel: row.reasonLabel,
      minutes: row.minutes,
      count: row.count,
      percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0,
    }));

    const machineDetail: RecapMachineDetail = {
      machineId: machine.machineId,
      name: machine.machineName,
      location: machine.location,
      status: status.status,
      oee: machine.oee.avg,
      goodParts: machine.production.goodParts,
      scrap: machine.production.scrapParts,
      stopsCount: machine.downtime.stopsCount,
      stopMinutes: downtimeTotalMin,
      activeWorkOrderId: machine.workOrders.active?.id ?? null,
      lastSeenMs: status.lastSeenMs,
      offlineForMin: status.offlineForMin,
      ongoingStopMin: status.ongoingStopMin,
      stateContext: status.stateContext,

      moldChange: {
        active: machine.workOrders.moldChangeInProgress,
        startMs: machine.workOrders.moldChangeStartMs,
      },
      timeline,
      productionBySku: machine.production.bySku,
      downtimeTop,
      workOrders: {
        completed: machine.workOrders.completed,
        active: machine.workOrders.active,
      },
      heartbeat: {
        lastSeenAt: machine.heartbeat.lastSeenAt,
        uptimePct: machine.heartbeat.uptimePct,
        connectionStatus: status.status === "offline" ? "offline" : "online",
      },
    };

    const response: RecapDetailResponse = {
      generatedAt: new Date().toISOString(),
      range: {
        requestedMode: range.requestedMode,
        mode: range.mode,
        start: range.start.toISOString(),
        end: range.end.toISOString(),
        shiftAvailable: range.shiftAvailable,
        fallbackReason: range.fallbackReason,
      },
      machine: machineDetail,
    };

    return response;
  }

  function summaryCacheKey(params: { orgId: string; hours: number }) {
    return ["recap-summary-v1", params.orgId, String(params.hours)];
  }

  function detailCacheKey(params: {
    orgId: string;
    machineId: string;
    requestedMode: RecapRangeMode;
    mode: RecapRangeMode;
    shiftAvailable: boolean;
    fallbackReason?: "shift-unavailable" | "shift-inactive";
    startMs: number;
    endMs: number;
  }) {
    return [
      "recap-detail-v1",
      params.orgId,
      params.machineId,
      params.requestedMode,
      params.mode,
      params.shiftAvailable ? "shift-on" : "shift-off",
      params.fallbackReason ?? "",
      String(Math.trunc(params.startMs / 60000)),
      String(Math.trunc(params.endMs / 60000)),
    ];
  }

  export function parseRecapSummaryHours(raw: string | null) {
    return parseHours(raw);
  }

  export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record<string, string | string[] | undefined>) {
    if (searchParams instanceof URLSearchParams) {
      return {
        mode: searchParams.get("range") ?? undefined,
        start: searchParams.get("start") ?? undefined,
        end: searchParams.get("end") ?? undefined,
      };
    }

    const pick = (key: string) => {
      const value = searchParams[key];
      if (Array.isArray(value)) return value[0] ?? undefined;
      return value ?? undefined;
    };

    return {
      mode: pick("range"),
      start: pick("start"),
      end: pick("end"),
    };
  }

  export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
    const cache = unstable_cache(
      () => computeRecapSummary(params),
      summaryCacheKey(params),
      {
        revalidate: RECAP_CACHE_TTL_SEC,
        tags: [`recap:${params.orgId}`],
      }
    );

    return cache();
  }

  export async function getRecapMachineDetailCached(params: {
    orgId: string;
    machineId: string;
    input: DetailRangeInput;
  }) {
    const resolved = await resolveDetailRange({
      orgId: params.orgId,
      input: params.input,
    });

    const cache = unstable_cache(
      () =>
        computeRecapMachineDetail({
          orgId: params.orgId,
          machineId: params.machineId,
          range: {
            requestedMode: resolved.requestedMode,
            mode: resolved.mode,
            start: resolved.start,
            end: resolved.end,
            shiftAvailable: resolved.shiftAvailable,
            fallbackReason: resolved.fallbackReason,
          },
        }),
      detailCacheKey({
        orgId: params.orgId,
        machineId: params.machineId,
        requestedMode: resolved.requestedMode,
        mode: resolved.mode,
        shiftAvailable: resolved.shiftAvailable,
        fallbackReason: resolved.fallbackReason,
        startMs: resolved.start.getTime(),
        endMs: resolved.end.getTime(),
      }),
      {
        revalidate: RECAP_CACHE_TTL_SEC,
        tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
      }
    );

    return cache();
  }
