// Shared business-time module — the single source of truth for the time
// convention (see docs/TIME.md and TIME_UNIFICATION_PLAN.md).
//
// THE CONVENTION: a business day runs 1:00am Eastern → 1:00am Eastern the next
// day. Because 1am Eastern == midnight Central year-round (both observe US
// DST), this is identical to: business date = the America/Chicago calendar
// date. All boundary math here therefore uses Chicago days; Eastern exists
// only for display formatting.
//
// MySQL DATETIME columns on 192.168.1.5 store Central wall-clock literals, so
// toMySqlBound()/parseMySql() convert to/from Chicago wall-clock strings with
// no hour shift. Never hand-roll an hour offset and never use a fixed
// 'UTC-05:00' zone (wrong every winter).

import { DateTime } from 'luxon';

export const BUSINESS_TZ = 'America/Chicago'; // canonical
export const OFFICE_TZ = 'America/New_York'; // display only

const MYSQL_DATETIME_FORMAT = 'yyyy-LL-dd HH:mm:ss';
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

// ---------------------------------------------------------------------------
// Parsing / coercion
// ---------------------------------------------------------------------------

/**
 * Coerce any supported input (DateTime, Date, millis, ISO string, SQL string)
 * to a luxon DateTime in the business zone. Date-only strings are interpreted
 * as business dates (Chicago midnight). Zoned ISO strings keep their instant.
 * Returns null for unparseable input.
 */
export function toBusinessTime(value) {
  if (value == null || value === '') return null;

  if (DateTime.isDateTime(value)) return value.setZone(BUSINESS_TZ);
  if (value instanceof Date) return DateTime.fromJSDate(value).setZone(BUSINESS_TZ);
  if (typeof value === 'number') return DateTime.fromMillis(value).setZone(BUSINESS_TZ);

  if (typeof value === 'string') {
    let dt = DateTime.fromISO(value, { zone: BUSINESS_TZ, setZone: false });
    if (!dt.isValid) dt = DateTime.fromSQL(value, { zone: BUSINESS_TZ });
    return dt.isValid ? dt : null;
  }

  return null;
}

/** True if the input is a date-only string ('YYYY-MM-DD'). */
export const isDateOnly = (value) => typeof value === 'string' && DATE_ONLY_RE.test(value);

/**
 * Parse a MySQL DATETIME/TIMESTAMP literal (Central wall-clock) into a
 * DateTime in the business zone. Returns null for empty / zero dates.
 */
export function parseMySql(value) {
  if (!value || value === '0000-00-00 00:00:00' || value === '0000-00-00') return null;
  if (value instanceof Date) {
    // Defensive: a driver not configured with dateStrings handed us a Date.
    // Its instant depends on that driver's timezone config — pass it through
    // as an instant rather than guessing.
    return DateTime.fromJSDate(value).setZone(BUSINESS_TZ);
  }
  const dt = DateTime.fromSQL(String(value), { zone: BUSINESS_TZ });
  return dt.isValid ? dt : null;
}

/**
 * Serialize an instant to a MySQL bound: Central wall-clock
 * 'YYYY-MM-DD HH:mm:ss'. Use with half-open intervals: col >= ? AND col < ?.
 */
export function toMySqlBound(value) {
  const dt = toBusinessTime(value);
  if (!dt) throw new Error('toMySqlBound: invalid datetime input');
  return dt.toFormat(MYSQL_DATETIME_FORMAT);
}

// ---------------------------------------------------------------------------
// "Today" and day boundaries
// ---------------------------------------------------------------------------

/** Now, as a DateTime in the business zone. */
export const businessNow = () => DateTime.now().setZone(BUSINESS_TZ);

/** Start of the current business day (Chicago midnight = 1am Eastern). */
export const businessToday = () => businessNow().startOf('day');

/** The current business date as 'YYYY-MM-DD'. The only sanctioned "today". */
export const businessTodayStr = () => businessNow().toISODate();

/** Start of the business day containing the given instant/date. */
export function businessDayStart(value = businessNow()) {
  const dt = toBusinessTime(value);
  if (!dt) throw new Error('businessDayStart: invalid datetime input');
  return dt.startOf('day');
}

/** Half-open range [start, nextStart) of the business day containing value. */
export function businessDayRange(value = businessNow()) {
  const start = businessDayStart(value);
  return { start, end: start.plus({ days: 1 }) };
}

/** Business date ('YYYY-MM-DD') of an instant. */
export function businessDateOf(value) {
  const dt = toBusinessTime(value);
  return dt ? dt.toISODate() : null;
}

/**
 * Format a node-postgres DATE-column value as 'YYYY-MM-DD'.
 *
 * node-postgres materializes DATE columns as a JS Date at process-local
 * midnight, so formatting in LOCAL time round-trips to the same calendar date
 * regardless of the process zone. (The old .toISOString().split('T')[0] idiom
 * formatted in UTC, which emitted the PREVIOUS day for any zone east of UTC.)
 * Strings (e.g. from ::text casts) pass through.
 */
export function formatDateCol(value) {
  if (value == null) return null;
  if (typeof value === 'string') return value.slice(0, 10);
  if (value instanceof Date) {
    const y = value.getFullYear();
    const m = String(value.getMonth() + 1).padStart(2, '0');
    const d = String(value.getDate()).padStart(2, '0');
    return `${y}-${m}-${d}`;
  }
  return businessDateOf(value);
}

/** Format an instant in office (Eastern) time for display. */
export function formatOffice(value, fmt = 'LLL d, yyyy h:mm a') {
  const dt = toBusinessTime(value);
  return dt ? dt.setZone(OFFICE_TZ).toFormat(fmt) : '';
}

// ---------------------------------------------------------------------------
// Named range presets — ONE vocabulary for every server
// ---------------------------------------------------------------------------

export const RANGE_PRESETS = [
  'today',
  'yesterday',
  'twoDaysAgo',
  'thisWeek',
  'lastWeek',
  'thisMonth',
  'lastMonth',
  'last7days',
  'last30days',
  'last90days',
  'previous7days',
  'previous30days',
  'previous90days',
  'custom',
];

export const isKnownRange = (name) => RANGE_PRESETS.includes(name);

// Weeks start on Sunday (business convention).
function weekStart(dt) {
  const day = dt.startOf('day');
  // luxon weekday: 1 = Monday … 7 = Sunday
  return day.weekday === 7 ? day : day.minus({ days: day.weekday });
}

/**
 * Resolve a named preset to a half-open range { start, end } of business-zone
 * DateTimes (end is EXCLUSIVE — the start of the day after the last included
 * business day). Throws on unknown names; 'custom' must be resolved by the
 * caller via customRange().
 */
export function rangeForPreset(name, now = businessNow()) {
  const current = toBusinessTime(now);
  if (!current) throw new Error('rangeForPreset: invalid reference time');
  const todayStart = current.startOf('day');
  const tomorrowStart = todayStart.plus({ days: 1 });

  switch (name) {
    case 'today':
      return { start: todayStart, end: tomorrowStart };
    case 'yesterday':
      return { start: todayStart.minus({ days: 1 }), end: todayStart };
    case 'twoDaysAgo':
      return { start: todayStart.minus({ days: 2 }), end: todayStart.minus({ days: 1 }) };
    case 'thisWeek':
      return { start: weekStart(current), end: tomorrowStart };
    case 'lastWeek': {
      const start = weekStart(current).minus({ weeks: 1 });
      return { start, end: start.plus({ weeks: 1 }) };
    }
    case 'thisMonth':
      return { start: todayStart.startOf('month'), end: tomorrowStart };
    case 'lastMonth': {
      const start = todayStart.startOf('month').minus({ months: 1 });
      return { start, end: start.plus({ months: 1 }) };
    }
    case 'last7days':
      return { start: todayStart.minus({ days: 6 }), end: tomorrowStart };
    case 'last30days':
      return { start: todayStart.minus({ days: 29 }), end: tomorrowStart };
    case 'last90days':
      return { start: todayStart.minus({ days: 89 }), end: tomorrowStart };
    case 'previous7days': {
      const currentStart = todayStart.minus({ days: 6 });
      return { start: currentStart.minus({ days: 7 }), end: currentStart };
    }
    case 'previous30days': {
      const currentStart = todayStart.minus({ days: 29 });
      return { start: currentStart.minus({ days: 30 }), end: currentStart };
    }
    case 'previous90days': {
      const currentStart = todayStart.minus({ days: 89 });
      return { start: currentStart.minus({ days: 90 }), end: currentStart };
    }
    default:
      throw new Error(`Unknown time range: ${name}`);
  }
}

/**
 * Resolve a custom range to half-open { start, end }. Date-only strings are
 * business dates: start = that Chicago midnight, end = the day AFTER the end
 * date (so both endpoints are included as full business days). Zoned ISO
 * instants are used as-is (end treated as exclusive).
 */
export function customRange(startInput, endInput) {
  if (!startInput || !endInput) throw new Error('customRange: both bounds required');
  const start = isDateOnly(startInput)
    ? DateTime.fromISO(startInput, { zone: BUSINESS_TZ }).startOf('day')
    : toBusinessTime(startInput);
  let end = isDateOnly(endInput)
    ? DateTime.fromISO(endInput, { zone: BUSINESS_TZ }).startOf('day').plus({ days: 1 })
    : toBusinessTime(endInput);
  if (!start || !start.isValid || !end || !end.isValid) {
    throw new Error('customRange: invalid bounds');
  }
  if (end < start) throw new Error('customRange: end before start');
  return { start, end };
}

/**
 * Previous comparable range. Accepts a preset name (uses the conventional
 * predecessor: today→yesterday, thisWeek→lastWeek, …) or a { start, end }
 * half-open range (shifts back by its own duration).
 */
export function previousRange(nameOrRange, now = businessNow()) {
  if (typeof nameOrRange === 'string') {
    const map = {
      today: 'yesterday',
      yesterday: 'twoDaysAgo',
      thisWeek: 'lastWeek',
      thisMonth: 'lastMonth',
      last7days: 'previous7days',
      last30days: 'previous30days',
      last90days: 'previous90days',
    };
    const prev = map[nameOrRange];
    if (prev) return rangeForPreset(prev, now);
    // Calendar-unit presets without a named predecessor.
    if (nameOrRange === 'twoDaysAgo') {
      const { start } = rangeForPreset('twoDaysAgo', now);
      return { start: start.minus({ days: 1 }), end: start };
    }
    if (nameOrRange === 'lastWeek') {
      const { start } = rangeForPreset('lastWeek', now);
      return { start: start.minus({ weeks: 1 }), end: start };
    }
    if (nameOrRange === 'lastMonth') {
      const { start } = rangeForPreset('lastMonth', now);
      return { start: start.minus({ months: 1 }), end: start };
    }
    return null;
  }
  const { start, end } = nameOrRange || {};
  if (!start || !end) return null;
  const duration = end.diff(start);
  return { start: start.minus(duration), end: start };
}

// ---------------------------------------------------------------------------
// MySQL query helpers (acot-server style)
// ---------------------------------------------------------------------------

const RANGE_LABELS = {
  today: 'Today',
  yesterday: 'Yesterday',
  twoDaysAgo: 'Two Days Ago',
  thisWeek: 'This Week',
  lastWeek: 'Last Week',
  thisMonth: 'This Month',
  lastMonth: 'Last Month',
  last7days: 'Last 7 Days',
  last30days: 'Last 30 Days',
  last90days: 'Last 90 Days',
  previous7days: 'Previous 7 Days',
  previous30days: 'Previous 30 Days',
  previous90days: 'Previous 90 Days',
};

export const getTimeRangeLabel = (timeRange) => RANGE_LABELS[timeRange] || timeRange;

/** Eastern display date for a range label ('Jun 12, 2026'). */
export function formatBusinessDate(value) {
  const dt = toBusinessTime(value);
  return dt ? dt.setZone(OFFICE_TZ).toFormat('LLL d, yyyy') : '';
}

/**
 * Build a half-open MySQL WHERE fragment + Central wall-clock bound params
 * for a named or custom range. The where clause repeats the column for both
 * bounds so callers re-targeting another column MUST replace globally
 * (/date_placed/g).
 */
export function getTimeRangeConditions(timeRange, startDate, endDate) {
  let range;
  let label;

  if (timeRange === 'custom' && startDate && endDate) {
    range = customRange(startDate, endDate);
    // Display the last *included* business day, not the exclusive endpoint.
    label = `${formatBusinessDate(range.start)} - ${formatBusinessDate(range.end.minus({ milliseconds: 1 }))}`;
  } else {
    const normalized = timeRange || 'today';
    range = rangeForPreset(normalized);
    label = getTimeRangeLabel(normalized);
  }

  return {
    whereClause: 'date_placed >= ? AND date_placed < ?',
    params: [toMySqlBound(range.start), toMySqlBound(range.end)],
    range,
    start: range.start.toJSDate(),
    end: range.end.toJSDate(), // EXCLUSIVE
    dateRange: {
      start: range.start.toUTC().toISO(),
      end: range.end.toUTC().toISO(), // EXCLUSIVE
      label,
    },
  };
}

/** Half-open JS Date bounds for a named range (end exclusive). */
export function getBusinessDayBounds(timeRange) {
  const range = rangeForPreset(timeRange);
  return { start: range.start.toJSDate(), end: range.end.toJSDate() };
}

/** Parse a MySQL datetime literal to a JS Date (legacy convenience). */
export function parseBusinessDate(mysqlDatetime) {
  const dt = parseMySql(mysqlDatetime);
  return dt ? dt.toUTC().toJSDate() : null;
}

/** Serialize any instant to a MySQL Central wall-clock string, null-safe. */
export function formatMySQLDate(value) {
  const dt = toBusinessTime(value);
  return dt ? dt.toFormat(MYSQL_DATETIME_FORMAT) : null;
}

// Legacy helpers kept for consumers that need raw day/week math. getDayEnd
// returns the half-open endpoint (next day start); prefer businessDayRange.
export const _internal = {
  getDayStart: (input) => businessDayStart(input ?? businessNow()),
  getDayEnd: (input) => businessDayStart(input ?? businessNow()).plus({ days: 1 }),
  getWeekStart: (input) => weekStart(toBusinessTime(input ?? businessNow())),
  getRangeForTimeRange: (timeRange, now) => rangeForPreset(timeRange, now),
  BUSINESS_DAY_START_HOUR: 1, // 1am Eastern == midnight Central
};

// ---------------------------------------------------------------------------
// TimeManager — compatibility class for the dashboard/klaviyo tree.
//
// Same method surface as the old dashboard/utils/time.utils.js, with the
// defects fixed:
//   - getDayEnd: was next-start minus 1 MINUTE (a 59.999s hole every day);
//     now next-start minus 1 millisecond. Kept inclusive (rather than
//     half-open) because consumers iterate `while (day <= end)` — a half-open
//     end would add a spurious trailing bucket. Klaviyo API bounds use
//     less-than(end), so the residual 1ms hole is negligible.
//   - groupEventsByInterval: grouped in the event string's own zone; now
//     groups days by business date and hour/week/month in office time.
// ---------------------------------------------------------------------------

export class TimeManager {
  constructor(dayStartHour = 1) {
    this.timezone = OFFICE_TZ;
    this.dayStartHour = dayStartHour; // retained for API compat; 1 == Chicago midnight
    this.weekStartDay = 7; // Sunday
  }

  getNow() {
    return DateTime.now().setZone(this.timezone);
  }

  toDateTime(date) {
    if (!date) return null;
    if (DateTime.isDateTime(date)) return date.setZone(this.timezone);
    const dt = DateTime.fromISO(date instanceof Date ? date.toISOString() : date);
    if (!dt.isValid) {
      console.error('[TimeManager] Invalid date input:', date);
      return null;
    }
    return dt.setZone(this.timezone);
  }

  getDayStart(dt = this.getNow()) {
    if (!dt.isValid) return this.getNow();
    return businessDayStart(dt).setZone(this.timezone);
  }

  getDayEnd(dt = this.getNow()) {
    if (!dt.isValid) return this.getNow();
    return businessDayStart(dt).plus({ days: 1 }).minus({ milliseconds: 1 }).setZone(this.timezone);
  }

  getWeekStart(dt = this.getNow()) {
    if (!dt.isValid) return this.getNow();
    return weekStart(toBusinessTime(dt)).setZone(this.timezone);
  }

  formatForAPI(date) {
    const dt = this.toDateTime(date);
    if (!dt || !dt.isValid) {
      console.error('[TimeManager] Invalid date for API:', date);
      return null;
    }
    return dt.toUTC().toISO();
  }

  formatForDisplay(date) {
    const dt = this.toDateTime(date);
    if (!dt || !dt.isValid) return '';
    return dt.toFormat('LLL d, yyyy h:mm a');
  }

  isValidDateRange(start, end) {
    const startDt = this.toDateTime(start);
    const endDt = this.toDateTime(end);
    return startDt && endDt && endDt > startDt;
  }

  getLastNHours(hours) {
    const now = this.getNow();
    return { start: now.minus({ hours }), end: now };
  }

  getLastNDays(days) {
    const now = this.getNow();
    return { start: this.getDayStart(now).minus({ days }), end: this.getDayEnd(now) };
  }

  getDateRange(period) {
    if (period === 'custom') {
      console.warn('[TimeManager] Custom ranges should use getCustomRange method');
      return null;
    }
    const normalized = period.startsWith('previous') ? period.replace('previous', 'last') : period;
    try {
      const { start, end } = rangeForPreset(normalized);
      return {
        start: start.setZone(this.timezone),
        end: end.minus({ milliseconds: 1 }).setZone(this.timezone),
      };
    } catch {
      console.warn(`[TimeManager] Unknown period: ${period}`);
      return null;
    }
  }

  getCustomRange(startDate, endDate) {
    if (!startDate || !endDate) {
      console.error('[TimeManager] Custom range requires both start and end dates');
      return null;
    }
    try {
      const { start, end } = customRange(startDate, endDate);
      const inclusiveEnd = isDateOnly(endDate) ? end.minus({ milliseconds: 1 }) : end;
      return { start: start.setZone(this.timezone), end: inclusiveEnd.setZone(this.timezone) };
    } catch (error) {
      console.error('[TimeManager] Invalid dates provided for custom range:', error.message);
      return null;
    }
  }

  getPreviousPeriod(period, now = this.getNow()) {
    const normalized = period.startsWith('previous') ? period.replace('previous', 'last') : period;
    const current = (() => {
      try {
        return rangeForPreset(normalized, now);
      } catch {
        return null;
      }
    })();
    if (!current) {
      console.warn(`[TimeManager] No previous period defined for: ${period}`);
      return null;
    }
    const prev =
      previousRange(normalized, now) ||
      previousRange(current, now);
    if (!prev) return null;
    return {
      start: prev.start.setZone(this.timezone),
      end: prev.end.minus({ milliseconds: 1 }).setZone(this.timezone),
    };
  }

  formatDuration(ms) {
    return DateTime.fromMillis(ms).toFormat("hh'h' mm'm' ss's'");
  }

  getRelativeTime(date) {
    const dt = this.toDateTime(date);
    if (!dt) return '';
    return dt.toRelative();
  }

  groupEventsByInterval(events, interval = 'day', property = null) {
    if (!events?.length) return [];

    const groupedData = new Map();

    for (const event of events) {
      const instant = DateTime.fromISO(event.attributes.datetime, { setZone: true });
      if (!instant.isValid) continue;

      let groupKey;
      switch (interval) {
        case 'hour':
          groupKey = instant.setZone(OFFICE_TZ).startOf('hour').toISO();
          break;
        case 'week':
          groupKey = weekStart(instant.setZone(BUSINESS_TZ)).setZone(OFFICE_TZ).toISO();
          break;
        case 'month':
          groupKey = instant.setZone(BUSINESS_TZ).startOf('month').setZone(OFFICE_TZ).toISO();
          break;
        case 'day':
        default:
          // Group by BUSINESS date (Chicago calendar day = 1am-ET day rule).
          groupKey = instant.setZone(BUSINESS_TZ).startOf('day').setZone(OFFICE_TZ).toISO();
      }

      const existingGroup = groupedData.get(groupKey) || { datetime: groupKey, count: 0, value: 0 };
      existingGroup.count++;

      if (property) {
        const props = event.attributes?.event_properties || event.attributes?.properties || {};
        const value =
          property === '$value' ? Number(event.attributes?.value || 0) : Number(props[property] || 0);
        existingGroup.value = (existingGroup.value || 0) + value;
      }

      groupedData.set(groupKey, existingGroup);
    }

    return Array.from(groupedData.values()).sort(
      (a, b) => DateTime.fromISO(a.datetime) - DateTime.fromISO(b.datetime)
    );
  }
}
