import { ChallengeProgressStatus } from "src/common/enums/challenges.enum";

const MS_PER_DAY = 24 * 60 * 60 * 1000;

/**
 * Cadence of a HABIT challenge, read off the challenge document.
 * A challenge is split into consecutive windows of `period_days` CALENDAR days,
 * each needing `times_per_period` completions. Windows are counted in whole days
 * from `started_at`'s calendar day (window 0 = start day .. start day +
 * period_days - 1). Dates are treated as opaque calendar days (the client sends
 * its local "YYYY-MM-DD" and the server never derives a day from a timezone), so
 * the boundary stays timezone-correct.
 */
export interface HabitCadence {
  duration: number; // total challenge length in days
  period_days: number;
  times_per_period: number;
  allowed_misses: number;
  target: number; // goal.value — total completions needed
}

export interface HabitProgressState {
  total_windows: number;
  /** 0-based index of the window `now` falls in; equals total_windows once over. */
  current_window: number;
  /** Completions logged inside the current window. */
  current_window_count: number;
  /** Still needed inside the current window (0 once fulfilled). */
  current_window_remaining: number;
  /** When the current window closes (null once the challenge period is over). */
  current_window_ends_at: Date | null;
  /** Fully-closed windows that did not get their required completions. */
  missed_windows: number;
  completed_count: number;
  target: number;
  /** Sessions still to complete (target - completed_count). */
  remaining: number;
  /**
   * CALENDAR days left, independent of windows/sessions: the challenge's
   * duration minus the days already elapsed, minus today once it has been
   * logged. A 7-day challenge shows 7 before today's session and 6 after.
   */
  remaining_days: number;
  /**
   * Per-PERIOD state (one entry per window). `status` mirrors the history:
   *   completed   -> reached times_per_period
   *   missing     -> window closed short of the target
   *   in_progress -> the window "now" is in, not yet complete
   *   upcoming    -> a future window that hasn't started
   * For times_per_period == 1 there is one window per session, so this is also
   * the dot row. For 2x-a-week etc. use `slots` below for one dot per session.
   */
  windows: {
    index: number;
    fulfilled: boolean;
    /** completions logged in this window */
    count: number;
    /** completions required in this window (times_per_period) */
    required: number;
    status: HabitWindowStatus;
  }[];
  /**
   * Per-SESSION state — the flat dot row the UI renders (length == total
   * sessions == total_windows * times_per_period). "Farmers carry 2x a week"
   * over one week => 2 slots; a completion fills the next slot in its window.
   */
  slots: {
    index: number; // 0-based across the whole challenge
    window: number; // which window this session belongs to
    status: HabitWindowStatus;
  }[];
  status: ChallengeProgressStatus;
}

export type HabitWindowStatus =
  | "completed"
  | "missing"
  | "in_progress"
  | "upcoming";

export function resolveHabitCadence(challenge: any): HabitCadence {
  const period_days = Number(challenge?.period_days) || 1;
  const times_per_period = Number(challenge?.times_per_period) || 1;
  const duration = Number(challenge?.duration) || 0;
  // ceil, so a trailing partial period still counts as a window: 21 days every
  // 2 days = 11 windows (the last one a single day), not 10.
  const total_windows = period_days > 0 ? Math.ceil(duration / period_days) : 0;
  return {
    duration,
    period_days,
    times_per_period,
    allowed_misses: Number(challenge?.allowed_misses) || 0,
    // fall back to the derived target when the challenge has no explicit goal
    target: Number(challenge?.goal?.value) || times_per_period * total_windows,
  };
}

/**
 * The day-offset at which window `i` closes — normally (i+1)*period, but the
 * final window is clamped to the challenge's own length so it never runs past
 * the duration. For 21 days / every 2 days, window 10 closes at day 21, not 22.
 */
function windowEndDay(i: number, periodDays: number, duration: number): number {
  return Math.min((i + 1) * periodDays, duration);
}

/**
 * UTC-midnight timestamp of a value's calendar day. Reducing every date to its
 * calendar day here is what makes the window math timezone-opaque: two moments
 * on the same calendar date collapse to the same number, so time-of-day (and the
 * server's own offset) can never shift which day/window something lands in.
 * Returns NaN for a missing/invalid date, which the callers filter out.
 */
export function utcDateOnly(d: Date | string): number {
  const dt = new Date(d);
  const t = dt.getTime();
  if (!Number.isFinite(t)) return NaN;
  return Date.UTC(dt.getUTCFullYear(), dt.getUTCMonth(), dt.getUTCDate());
}

/** Whole calendar days from `start`'s day to `at`'s day (can be negative). */
export function dayDiff(at: Date | string, start: Date | string): number {
  return Math.round((utcDateOnly(at) - utcDateOnly(start)) / MS_PER_DAY);
}

/** Which window a calendar date falls in, relative to the start day. */
export function windowIndexOf(
  at: Date | string,
  startedAt: Date | string,
  periodDays: number
): number {
  const days = dayDiff(at, startedAt);
  return Math.floor(days / periodDays);
}

/**
 * Full habit state for one membership. `progress` entries are counted by the
 * window their `date` falls into; only fully-closed windows can be "missed", so
 * the window the user is currently inside never counts against them.
 */
export function calculateHabitProgress(
  cadence: HabitCadence,
  startedAt: Date | string,
  progress: any[] = [],
  now: Date = new Date()
): HabitProgressState {
  const { period_days, times_per_period, allowed_misses, target, duration } =
    cadence;
  const total_windows =
    period_days > 0 ? Math.ceil(duration / period_days) : 0;

  // start of window 0, as a UTC-midnight calendar day
  const startDay = utcDateOnly(startedAt);

  // tally completions per window (counting each entry's calendar day)
  const counts = new Map<number, number>();
  let completed_count = 0;
  for (const entry of progress || []) {
    if (entry?.completed === false) continue;
    const idx = windowIndexOf(entry?.date, startedAt, period_days);
    // Number.isFinite also rejects NaN, which an entry with a missing/invalid
    // date produces — without it such an entry inflates completed_count while
    // never showing up against any window.
    if (!Number.isFinite(idx) || idx < 0 || idx >= total_windows) continue;
    counts.set(idx, (counts.get(idx) || 0) + 1);
    completed_count++;
  }

  // the challenge is over once its full duration has elapsed — NOT when the last
  // (possibly partial) window's nominal period would end.
  const daysElapsed = dayDiff(now, startedAt);
  const rawIndex = period_days > 0 ? Math.floor(daysElapsed / period_days) : 0;
  const periodOver = duration > 0 && daysElapsed >= duration;
  const current_window = periodOver
    ? total_windows
    : Math.max(Math.min(rawIndex, total_windows - 1), 0);

  // a window can be missed only once it has fully closed (its clamped end day
  // has passed) without its required completions.
  let missed_windows = 0;
  const windows: HabitProgressState["windows"] = [];
  const slots: HabitProgressState["slots"] = [];
  for (let i = 0; i < total_windows; i++) {
    const count = counts.get(i) || 0;
    const fulfilled = count >= times_per_period;
    const closed = daysElapsed >= windowEndDay(i, period_days, duration);
    if (closed && !fulfilled) missed_windows++;

    let wStatus: HabitWindowStatus;
    if (fulfilled) wStatus = "completed";
    else if (closed) wStatus = "missing";
    else if (!periodOver && i === current_window) wStatus = "in_progress";
    else wStatus = "upcoming";

    windows.push({
      index: i,
      fulfilled,
      count,
      required: times_per_period,
      status: wStatus,
    });

    // one dot per required session in this window: the first `count` are done,
    // the rest are missing (window closed), still doable (current window), or
    // upcoming (future window).
    for (let j = 0; j < times_per_period; j++) {
      let sStatus: HabitWindowStatus;
      if (j < count) sStatus = "completed";
      else if (closed) sStatus = "missing";
      else if (!periodOver && i === current_window) sStatus = "in_progress";
      else sStatus = "upcoming";
      slots.push({ index: slots.length, window: i, status: sStatus });
    }
  }

  const current_window_count = periodOver ? 0 : counts.get(current_window) || 0;

  // Calendar days left — purely date-based, NOT derived from windows/sessions.
  // Today is only counted off once it has actually been logged, so a 7-day
  // challenge reads 7 before today's session and 6 right after it.
  const todayUtc = utcDateOnly(now);
  const markedToday = (progress || []).some(
    (p: any) => p?.completed !== false && utcDateOnly(p?.date) === todayUtc
  );
  const remaining_days =
    duration > 0
      ? Math.max(duration - daysElapsed - (markedToday ? 1 : 0), 0)
      : 0;

  let status = ChallengeProgressStatus.ACTIVE;
  if (missed_windows > allowed_misses) {
    status = ChallengeProgressStatus.FAILED;
  } else if (target > 0 && completed_count >= target) {
    // goal met — nothing further can be logged
    status = ChallengeProgressStatus.COMPLETED;
  } else if (periodOver) {
    // ran to the end within the allowed misses
    status = ChallengeProgressStatus.COMPLETED;
  }

  return {
    total_windows,
    current_window,
    current_window_count,
    current_window_remaining: periodOver
      ? 0
      : Math.max(times_per_period - current_window_count, 0),
    // the UTC-midnight day on which the current window closes (clamped to the
    // challenge end for the final partial window)
    current_window_ends_at: periodOver
      ? null
      : new Date(
          startDay +
            windowEndDay(current_window, period_days, duration) * MS_PER_DAY
        ),
    missed_windows,
    completed_count,
    target,
    remaining: Math.max(target - completed_count, 0),
    remaining_days,
    windows,
    slots,
    status,
  };
}

/** ISO-8601 week number of a UTC-midnight timestamp. */
function isoWeek(ms: number): number {
  const d = new Date(ms);
  const date = new Date(
    Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
  );
  const day = date.getUTCDay() || 7;
  date.setUTCDate(date.getUTCDate() + 4 - day);
  const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
  return Math.ceil(
    ((date.getTime() - yearStart.getTime()) / MS_PER_DAY + 1) / 7
  );
}

export interface HabitHistoryRow {
  window: number; // 0-based window index
  /** Ready-to-render label: "W27" for weekly cadences, else the ISO date. */
  label: string;
  /** Completion day when logged, else the window's start day. */
  date: Date;
  period_start: Date; // first calendar day of the window
  period_end: Date; // inclusive last calendar day of the window
  iso_week: number; // ISO week of period_start
  count: number; // completions logged in this window
  required: number; // times_per_period
  status: "completed" | "missing" | "in_progress";
}

/**
 * History rows for the habit detail screen, one per STARTED window — derived,
 * never stored (a miss is just the absence of a completion in a closed window).
 * Per window it reports count / required and a status:
 *   completed   -> reached times_per_period            (design: "Completed (2/2)")
 *   missing     -> window closed short of the target   (design: "Missed (1/2)")
 *   in_progress -> current window, partially done       (design: "In Progress (1/2)")
 * History is PAST/ACTUAL activity only: the current window appears only once it
 * has partial progress (count > 0). An untouched current window is omitted — it
 * is ongoing, already conveyed by the dots / current_window — so a challenge
 * with no completions returns an empty history. A closed window with zero is
 * still shown as missing. Newest first.
 */
export function buildHabitHistory(
  cadence: HabitCadence,
  startedAt: Date | string,
  progress: any[] = [],
  now: Date = new Date()
): HabitHistoryRow[] {
  const { period_days, times_per_period, duration } = cadence;
  const total_windows =
    period_days > 0 ? Math.ceil(duration / period_days) : 0;
  if (total_windows <= 0) return [];

  const startDay = utcDateOnly(startedAt);
  const isWeekly = period_days >= 7 && period_days % 7 === 0;

  const entriesByWindow = new Map<number, any[]>();
  for (const entry of progress || []) {
    if (entry?.completed === false) continue;
    const idx = windowIndexOf(entry?.date, startedAt, period_days);
    if (!Number.isFinite(idx) || idx < 0 || idx >= total_windows) continue;
    const list = entriesByWindow.get(idx) || [];
    list.push(entry);
    entriesByWindow.set(idx, list);
  }

  const daysElapsed = dayDiff(now, startedAt);

  const rows: HabitHistoryRow[] = [];
  for (let i = 0; i < total_windows; i++) {
    // window hasn't started yet -> nothing to report
    if (daysElapsed < i * period_days) continue;

    const list = entriesByWindow.get(i) || [];
    const count = list.length;
    const closed = daysElapsed >= windowEndDay(i, period_days, duration);
    const fulfilled = count >= times_per_period;

    let status: HabitHistoryRow["status"];
    if (fulfilled) {
      status = "completed";
    } else if (closed) {
      status = "missing";
    } else if (count > 0) {
      // current, still-open window with partial progress ("this week — 1/2")
      status = "in_progress";
    } else {
      // current window with nothing logged yet -> not "history", it's ongoing
      // and already conveyed by the dots / current_window. Skip it, so an
      // untouched challenge returns an empty history.
      continue;
    }

    const periodStartMs = startDay + i * period_days * MS_PER_DAY;
    const periodEndMs =
      startDay + (windowEndDay(i, period_days, duration) - 1) * MS_PER_DAY;
    const week = isoWeek(periodStartMs);

    rows.push({
      window: i,
      label: isWeekly
        ? `W${week}`
        : new Date(periodStartMs).toISOString().slice(0, 10),
      date: count ? new Date(list[0].date) : new Date(periodStartMs),
      period_start: new Date(periodStartMs),
      period_end: new Date(periodEndMs),
      iso_week: week,
      count,
      required: times_per_period,
      status,
    });
  }

  return rows.reverse();
}

export interface NextHabitSession {
  /** true when a session can be logged right now (this window, not done today). */
  available_now: boolean;
  /** earliest calendar day the next session can be logged. */
  from: Date;
  /** latest day it must be logged by (the target window's last day). */
  by: Date;
  /** true = another session in the CURRENT window; false = the next window. */
  same_window: boolean;
  /** sessions still needed in the target window. */
  remaining_in_window: number;
}

/**
 * When the user's next session is due — drives "Great job! Next session due on
 * Aug 2" and, for multi-per-window cadences, "1 more session this week".
 *   - current window still owes sessions -> next is in the SAME window: today if
 *     not yet done today, else tomorrow; due by the window's last day.
 *   - current window fulfilled -> next is the NEXT window, from the day it opens.
 * Returns null when the challenge is finished or there is no further window.
 */
export function nextHabitSession(
  cadence: HabitCadence,
  startedAt: Date | string,
  state: HabitProgressState,
  markedToday: boolean,
  now: Date = new Date()
): NextHabitSession | null {
  if (state.status !== ChallengeProgressStatus.ACTIVE) return null;

  const { period_days, times_per_period, duration } = cadence;
  const total_windows =
    period_days > 0 ? Math.ceil(duration / period_days) : 0;
  const startDay = utcDateOnly(startedAt);
  const day = (offset: number) => new Date(startDay + offset * MS_PER_DAY);
  const todayUtc = utcDateOnly(now);

  // still owe sessions in the CURRENT window (e.g. 2x-a-week, slot 1 done)
  if (state.current_window_remaining > 0) {
    const lastDay = day(
      windowEndDay(state.current_window, period_days, duration) - 1
    );
    return {
      available_now: !markedToday,
      // one completion per day: if already marked today, the next is tomorrow
      from: markedToday ? new Date(todayUtc + MS_PER_DAY) : new Date(todayUtc),
      by: lastDay,
      same_window: true,
      remaining_in_window: state.current_window_remaining,
    };
  }

  // current window done -> look to the next window
  const next = state.current_window + 1;
  if (next >= total_windows) return null; // no further window; challenge ending
  return {
    available_now: false,
    from: day(next * period_days),
    by: day(windowEndDay(next, period_days, duration) - 1),
    same_window: false,
    remaining_in_window: times_per_period,
  };
}
