/**
 * Pure helper for the panel's Calendar tab (v1.5.0+).
 *
 * Given the loaded objects, a window in days, and an optional user filter,
 * produce a flat day-bucketed event list ready for rendering.
 *
 * Recurring projection (time-based tasks): the first occurrence is the task's
 * next_due (or "today" if overdue/triggered). Subsequent occurrences are
 * projected by adding interval_days repeatedly until the window end, capped
 * at MAX_OCCURRENCES_PER_TASK to prevent absurdly small intervals (e.g. a
 * 1-day-interval task in a 30-day window would otherwise produce 30 entries).
 *
 * Sensor-triggered tasks: only next_due is shown — we have no honest way to
 * predict when a sensor will next fire.
 */

import type { MaintenanceObjectResponse } from "../types";
import { intervalSpanDays } from "./interval";

export const MAX_OCCURRENCES_PER_TASK = 5;

export interface CalendarEvent {
  /** ISO date string (YYYY-MM-DD) — the day this event is bucketed under. */
  date: string;
  entry_id: string;
  task_id: string;
  task_name: string;
  object_name: string;
  status: string;        // "ok" | "due_soon" | "overdue" | "triggered"
  days_until_due: number | null;
  /** True if this is a projected recurrence (not the actual next_due). */
  projected: boolean;
  schedule_type: string;
  interval_days: number | null;
  /** v2.6.3 (#59): interval unit so the "every N …" label + projection match. */
  interval_unit?: string | null;
  responsible_user_id: string | null;
  avg_cost: number | null;
  /** v1.5.1: source indicator. */
  adaptive_enabled: boolean;
  /** v1.5.1: sensor prediction confidence ("low" | "medium" | "high") or null
   *  for time-based / no prediction available. */
  prediction_confidence: string | null;
  /** v2.2.0 — for past-window events, the original ISO timestamp of the
   *  underlying history entry. The history-edit WS uses this as the stable
   *  identifier (see ws_update_history_entry / original_timestamp). null
   *  for forward-projected / next_due events. */
  history_timestamp?: string | null;
  /** v2.2.0 — history entry type for past events
   *  ("completed" | "reset" | "skipped" | "triggered"). null for future. */
  history_type?: string | null;
  /** v2.2.0 — original cost / notes / duration from the history entry,
   *  so the past-events list shows what really happened (not avg_cost). */
  history_cost?: number | null;
  history_notes?: string | null;
  history_duration?: number | null;
}

export interface CalendarDayBucket {
  date: string;          // ISO date YYYY-MM-DD
  events: CalendarEvent[];
}

/**
 * Format a Date as ISO YYYY-MM-DD using LOCAL time components — matches what
 * the user sees on their wall clock, not UTC. The naive `.toISOString().slice(0,10)`
 * approach silently shifts dates near midnight in non-UTC timezones.
 */
export function isoDateLocal(d: Date): string {
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

/** Build the list of N consecutive ISO dates starting today (local). */
export function buildWindowDates(today: Date, windowDays: number): string[] {
  const out: string[] = [];
  for (let i = 0; i < windowDays; i++) {
    const d = new Date(today);
    d.setDate(d.getDate() + i);
    d.setHours(0, 0, 0, 0);
    out.push(isoDateLocal(d));
  }
  return out;
}

/** Add `days` to an ISO date (local), return new ISO date. */
function addDaysIso(iso: string, days: number): string {
  const [y, m, d] = iso.split("-").map(Number);
  const date = new Date(y, m - 1, d);
  date.setDate(date.getDate() + days);
  return isoDateLocal(date);
}

/** Average recorded cost across history rows that HAVE a cost (mean of
 *  non-null `cost` values). Note this differs from the panel KPI, which divides
 *  total_cost by times_performed — completions without a recorded cost are
 *  counted differently. This mean is used only for the calendar tooltip. */
function computeAvgCost(history: Array<{ cost?: number | null }> | undefined): number | null {
  if (!history || history.length === 0) return null;
  const costs = history.map((h) => h.cost).filter((c): c is number => typeof c === "number");
  if (costs.length === 0) return null;
  return costs.reduce((a, b) => a + b, 0) / costs.length;
}

interface ProjectionInput {
  windowStart: string;
  windowEnd: string;   // inclusive
  task: any;           // panel's task shape from MaintenanceObjectResponse
  entryId: string;
  objectName: string;
}

/**
 * Project up to MAX_OCCURRENCES_PER_TASK occurrences for a single task.
 * Returns events keyed by their bucketed date.
 */
function projectTask(input: ProjectionInput): CalendarEvent[] {
  const { windowStart, windowEnd, task, entryId, objectName } = input;
  const out: CalendarEvent[] = [];

  const baseEvent = (date: string, projected: boolean): CalendarEvent => ({
    date,
    entry_id: entryId,
    task_id: task.id,
    task_name: task.name,
    object_name: objectName,
    // Projected recurrences are hypothetical future occurrences that assume
    // the current cycle resolves on schedule. If the parent task is currently
    // overdue/triggered, carrying that status forward to the projection (e.g.
    // "OVERDUE 211d" on the May 7 projection of a 7-day-interval task) is
    // misleading — the projection IS the assumption that the user completes
    // it today, so the projected slot should read as a fresh "ok" event.
    status: projected && (task.status === "overdue" || task.status === "triggered")
      ? "ok"
      : task.status,
    days_until_due: projected ? null : (task.days_until_due ?? null),
    projected,
    schedule_type: task.schedule_type,
    interval_days: task.interval_days ?? null,
    interval_unit: task.interval_unit ?? null,
    responsible_user_id: task.responsible_user_id ?? null,
    avg_cost: computeAvgCost(task.history),
    adaptive_enabled: !!task.adaptive_config?.enabled,
    prediction_confidence: task.threshold_prediction_confidence ?? null,
  });

  // Step between projected occurrences, unit-aware (issue #59): a weeks/months/
  // years interval must advance by its real day-span, not the raw count (else a
  // "1 year" task projects at 1-day steps and spams the window).
  const stepDays = Math.max(
    1, Math.round(intervalSpanDays(task.interval_days, task.interval_unit)),
  );

  // Overdue / triggered → bucket on today (windowStart) regardless of next_due
  if (task.status === "overdue" || task.status === "triggered") {
    out.push(baseEvent(windowStart, false));
    // Continue projecting from "today" forward for time-based tasks
    if (task.schedule_type === "time_based" && task.interval_days && task.interval_days > 0) {
      let cursor = addDaysIso(windowStart, stepDays);
      let count = 1;
      while (cursor <= windowEnd && count < MAX_OCCURRENCES_PER_TASK) {
        out.push(baseEvent(cursor, true));
        count++;
        cursor = addDaysIso(cursor, stepDays);
      }
    }
    return out;
  }

  // Non-actionable status (ok / due_soon): need a next_due
  const nextDue = task.next_due;
  if (typeof nextDue !== "string" || !nextDue) return out;
  const firstDate = nextDue.slice(0, 10);  // strip time portion if any

  // First occurrence must be in window
  if (firstDate >= windowStart && firstDate <= windowEnd) {
    out.push(baseEvent(firstDate, false));
  } else if (firstDate > windowEnd) {
    return out;  // entire occurrence chain is past window
  }

  // Subsequent projected occurrences (time-based only, with interval_days)
  if (task.schedule_type === "time_based" && task.interval_days && task.interval_days > 0) {
    let cursor = addDaysIso(firstDate, stepDays);
    let count = out.length;
    while (cursor <= windowEnd && count < MAX_OCCURRENCES_PER_TASK) {
      // Skip occurrences before window start (when next_due itself was past)
      if (cursor >= windowStart) {
        out.push(baseEvent(cursor, true));
        count++;
      }
      cursor = addDaysIso(cursor, stepDays);
    }
  }

  return out;
}

/** Status sort priority: lower = shown first. */
const STATUS_RANK: Record<string, number> = {
  overdue: 0,
  triggered: 1,
  due_soon: 2,
  ok: 3,
};

/**
 * Main entry point: build the day-bucketed event list for the Calendar tab.
 *
 * @param objects   The panel's loaded objects (with nested tasks).
 * @param today     "Today" as a Date (caller passes new Date() — easier for tests).
 * @param windowDays Number of days to include (7 / 14 / 30).
 * @param userFilter null/empty = all; otherwise filter tasks by responsible_user_id.
 */
export function buildCalendarBuckets(
  objects: MaintenanceObjectResponse[],
  today: Date,
  windowDays: number,
  userFilter: string | null = null,
): CalendarDayBucket[] {
  const days = buildWindowDates(today, windowDays);
  const windowStart = days[0];
  const windowEnd = days[days.length - 1];

  const allEvents: CalendarEvent[] = [];
  for (const obj of objects) {
    const objectName = obj.object?.name || "";
    const entryId = obj.entry_id;
    const tasks = obj.tasks || [];
    for (const task of tasks) {
      // User filter
      if (userFilter && task.responsible_user_id !== userFilter) continue;
      // Disabled tasks never produce calendar events
      if (task.enabled === false) continue;
      const projected = projectTask({
        windowStart, windowEnd, task, entryId, objectName,
      });
      allEvents.push(...projected);
    }
  }

  // Bucket by date
  const byDate = new Map<string, CalendarEvent[]>();
  for (const day of days) byDate.set(day, []);
  for (const ev of allEvents) {
    const bucket = byDate.get(ev.date);
    if (bucket) bucket.push(ev);
  }

  // Sort within day: status priority first, then projected last, then by name
  for (const [, evs] of byDate) {
    evs.sort((a, b) => {
      const rA = STATUS_RANK[a.status] ?? 99;
      const rB = STATUS_RANK[b.status] ?? 99;
      if (rA !== rB) return rA - rB;
      if (a.projected !== b.projected) return a.projected ? 1 : -1;
      const cmp = a.object_name.localeCompare(b.object_name);
      if (cmp !== 0) return cmp;
      return a.task_name.localeCompare(b.task_name);
    });
  }

  return days.map((d) => ({ date: d, events: byDate.get(d) ?? [] }));
}

// ── v2.2.0 — past-window bucketer ──────────────────────────────────────────
//
// Same shape as buildCalendarBuckets but walks the task HISTORY (not next_due)
// and buckets entries by their actual completion/reset/skip/trigger date.
// Used by the calendar card's past-mode chip ("← 30 d") to surface
// recently-performed maintenance for review or correction.
//
// Window: from (today − pastDays + 1) to today, inclusive — so passing 30
// gives 30 day-buckets ending today.

const HISTORY_TYPE_TO_STATUS: Record<string, string> = {
  completed: "ok",
  reset: "ok",
  skipped: "due_soon",
  // A cycle that was NOT done in time (skip-while-overdue writes it, and the
  // coordinator stamps it automatically) — must never paint green. Found by
  // the 2026-08 drift audit: the missing entry fell through to "ok".
  missed: "overdue",
  triggered: "triggered",
  trigger_replaced: "triggered",
  trigger_removed: "ok", // config change, not a due/triggered event
};

interface HistoryEntryShape {
  timestamp?: string;
  type?: string;
  notes?: string;
  cost?: number;
  duration?: number;
}

/** Build a list of N consecutive ISO dates ending today (local). */
export function buildPastWindowDates(today: Date, pastDays: number): string[] {
  const out: string[] = [];
  for (let i = pastDays - 1; i >= 0; i--) {
    const d = new Date(today);
    d.setDate(d.getDate() - i);
    d.setHours(0, 0, 0, 0);
    out.push(isoDateLocal(d));
  }
  return out;
}

/**
 * Build day-bucketed events for the *past* N days using task history.
 *
 * Each history entry whose timestamp falls inside the window becomes one
 * CalendarEvent with `history_timestamp` set — the calendar card uses that
 * timestamp as the stable key when dispatching an edit-history click event.
 */
export function buildPastBuckets(
  objects: MaintenanceObjectResponse[],
  today: Date,
  pastDays: number,
  userFilter: string | null = null,
): CalendarDayBucket[] {
  const days = buildPastWindowDates(today, pastDays);
  const windowStart = days[0];
  const windowEnd = days[days.length - 1];

  const byDate = new Map<string, CalendarEvent[]>();
  for (const day of days) byDate.set(day, []);

  for (const obj of objects) {
    const objectName = obj.object?.name || "";
    const entryId = obj.entry_id;
    const tasks = obj.tasks || [];
    for (const task of tasks) {
      if (userFilter && task.responsible_user_id !== userFilter) continue;
      const history = (task.history || []) as HistoryEntryShape[];
      for (const h of history) {
        if (typeof h?.timestamp !== "string") continue;
        const dateKey = h.timestamp.slice(0, 10);  // YYYY-MM-DD
        if (dateKey < windowStart || dateKey > windowEnd) continue;
        const bucket = byDate.get(dateKey);
        if (!bucket) continue;
        const evType = h.type ?? "completed";
        bucket.push({
          date: dateKey,
          entry_id: entryId,
          task_id: task.id,
          task_name: task.name,
          object_name: objectName,
          status: HISTORY_TYPE_TO_STATUS[evType] ?? "ok",
          days_until_due: null,
          projected: false,
          schedule_type: task.schedule_type,
          interval_days: task.interval_days ?? null,
          responsible_user_id: task.responsible_user_id ?? null,
          avg_cost: typeof h.cost === "number" ? h.cost : null,
          adaptive_enabled: !!task.adaptive_config?.enabled,
          prediction_confidence: null,
          history_timestamp: h.timestamp,
          history_type: evType,
          history_cost: typeof h.cost === "number" ? h.cost : null,
          history_notes: typeof h.notes === "string" ? h.notes : null,
          history_duration: typeof h.duration === "number" ? h.duration : null,
        });
      }
    }
  }

  // Sort within day: type priority (completed first, triggered last) then by
  // object/task name. Past events are never "projected" so no opacity dimming.
  const PAST_TYPE_RANK: Record<string, number> = {
    completed: 0,
    reset: 1,
    skipped: 2,
    triggered: 3,
    trigger_replaced: 4,
  };
  for (const [, evs] of byDate) {
    evs.sort((a, b) => {
      const rA = PAST_TYPE_RANK[a.history_type ?? ""] ?? 99;
      const rB = PAST_TYPE_RANK[b.history_type ?? ""] ?? 99;
      if (rA !== rB) return rA - rB;
      const cmp = a.object_name.localeCompare(b.object_name);
      if (cmp !== 0) return cmp;
      return a.task_name.localeCompare(b.task_name);
    });
  }

  return days.map((d) => ({ date: d, events: byDate.get(d) ?? [] }));
}
