import type { DatabaseSync } from './sqlite';
import type { ImmersionSessionRollupRow } from './types';
import {
  ACTIVE_SESSION_METRICS_CTE,
  currentDbTimestamp,
  getLocalDayOfWeek,
  getLocalEpochDay,
  getLocalHourOfDay,
  getLocalMonthKey,
  getShiftedLocalDayTimestamp,
  makePlaceholders,
  sessionDisplayWordsExpr,
  toDbTimestamp,
} from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
import { areLexicalDailyRollupsReady, getLexicalDailyRollups } from './lexical-rollups';

type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
type TrendGroupBy = 'day' | 'month';

interface TrendChartPoint {
  label: string;
  value: number;
}

interface TrendPerAnimePoint {
  epochDay: number;
  animeTitle: string;
  value: number;
}

export interface LibrarySummaryRow {
  title: string;
  watchTimeMin: number;
  videos: number;
  sessions: number;
  cards: number;
  words: number;
  lookups: number;
  lookupsPerHundred: number | null;
  firstWatched: number;
  lastWatched: number;
}

interface TrendSessionMetricRow {
  startedAtMs: number;
  epochDay: number;
  monthKey: number;
  dayOfWeek: number;
  hourOfDay: number;
  videoId: number | null;
  canonicalTitle: string | null;
  animeTitle: string | null;
  activeWatchedMs: number;
  tokensSeen: number;
  cardsMined: number;
  yomitanLookupCount: number;
}

export interface TrendsDashboardQueryResult {
  activity: {
    watchTime: TrendChartPoint[];
    cards: TrendChartPoint[];
    words: TrendChartPoint[];
    sessions: TrendChartPoint[];
  };
  progress: {
    watchTime: TrendChartPoint[];
    sessions: TrendChartPoint[];
    words: TrendChartPoint[];
    newWords: TrendChartPoint[];
    cards: TrendChartPoint[];
    episodes: TrendChartPoint[];
    lookups: TrendChartPoint[];
  };
  ratios: {
    lookupsPerHundred: TrendChartPoint[];
    cardsPerHour: TrendChartPoint[];
    readingSpeed: TrendChartPoint[];
  };
  animeCumulative: {
    watchTime: TrendPerAnimePoint[];
    episodes: TrendPerAnimePoint[];
    cards: TrendPerAnimePoint[];
    words: TrendPerAnimePoint[];
  };
  patterns: {
    watchTimeByDayOfWeek: TrendChartPoint[];
    watchTimeByHour: TrendChartPoint[];
  };
  librarySummary: LibrarySummaryRow[];
}

const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
  '7d': 7,
  '30d': 30,
  '90d': 90,
  '365d': 365,
};

const MONTH_NAMES = [
  'Jan',
  'Feb',
  'Mar',
  'Apr',
  'May',
  'Jun',
  'Jul',
  'Aug',
  'Sep',
  'Oct',
  'Nov',
  'Dec',
];

const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

function getTrendDayLimit(range: TrendRange): number {
  return range === 'all' ? 365 : TREND_DAY_LIMITS[range];
}

function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number {
  if (range === 'all') {
    return 120;
  }
  const currentTimestamp = currentDbTimestamp();
  const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
  const cutoffMs = getShiftedLocalDayTimestamp(
    db,
    currentTimestamp,
    -(TREND_DAY_LIMITS[range] - 1),
  );
  const currentMonthKey = getLocalMonthKey(db, todayStartMs);
  const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
  const currentYear = Math.floor(currentMonthKey / 100);
  const currentMonth = currentMonthKey % 100;
  const cutoffYear = Math.floor(cutoffMonthKey / 100);
  const cutoffMonth = cutoffMonthKey % 100;
  return Math.max(1, (currentYear - cutoffYear) * 12 + currentMonth - cutoffMonth + 1);
}

function getTrendCutoffMs(db: DatabaseSync, range: TrendRange): string | null {
  if (range === 'all') {
    return null;
  }
  return getShiftedLocalDayTimestamp(db, currentDbTimestamp(), -(getTrendDayLimit(range) - 1));
}

function dayPartsFromEpochDay(epochDay: number): { year: number; month: number; day: number } {
  const z = epochDay + 719468;
  const era = Math.floor(z / 146097);
  const doe = z - era * 146097;
  const yoe = Math.floor(
    (doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365,
  );
  let year = yoe + era * 400;
  const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
  const mp = Math.floor((5 * doy + 2) / 153);
  const day = doy - Math.floor((153 * mp + 2) / 5) + 1;
  const month = mp < 10 ? mp + 3 : mp - 9;
  if (month <= 2) {
    year += 1;
  }
  return { year, month, day };
}

function makeTrendLabel(value: number): string {
  if (value > 100_000) {
    const year = Math.floor(value / 100);
    const month = value % 100;
    return `${MONTH_NAMES[month - 1]} ${String(year).slice(-2)}`;
  }

  const { month, day } = dayPartsFromEpochDay(value);
  return `${MONTH_NAMES[month - 1]} ${day}`;
}

function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number {
  return session.tokensSeen;
}

function looksLikeJellyfinStreamTitle(title: string): boolean {
  const lowered = title.toLowerCase();
  const hasApiKey = /api[\s_-]*key(?:\s|=|$)/i.test(title);
  return (
    hasApiKey &&
    (lowered.includes('stream?') ||
      lowered.includes('/stream?') ||
      lowered.includes('/videos/') ||
      lowered.includes('mediasourceid'))
  );
}

function sanitizeTrendTitle(title: string): string {
  const normalized = title.trim();
  if (!normalized) {
    return 'Unknown';
  }
  return looksLikeJellyfinStreamTitle(normalized) ? 'Jellyfin Video' : normalized;
}

function resolveTrendAnimeTitle(value: {
  animeTitle: string | null;
  canonicalTitle: string | null;
}): string {
  return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown');
}

// Ordered list of bucket keys (epoch days or YYYYMM months) covering the
// selected range, so charts render one point per calendar bucket instead of
// silently collapsing to only the buckets that have activity. Returns null for
// the unbounded "all" range, where builders fall back to the buckets in data.
function buildBucketAxis(
  db: DatabaseSync,
  groupBy: TrendGroupBy,
  cutoffMs: string | null,
  referenceMs: string,
): number[] | null {
  if (cutoffMs === null) {
    return null;
  }

  if (groupBy === 'month') {
    const startKey = getLocalMonthKey(db, cutoffMs);
    const endKey = getLocalMonthKey(db, referenceMs);
    const keys: number[] = [];
    let year = Math.floor(startKey / 100);
    let month = startKey % 100;
    const endYear = Math.floor(endKey / 100);
    const endMonth = endKey % 100;
    while (year < endYear || (year === endYear && month <= endMonth)) {
      keys.push(year * 100 + month);
      month += 1;
      if (month > 12) {
        month = 1;
        year += 1;
      }
    }
    return keys;
  }

  const startDay = getLocalEpochDay(db, cutoffMs);
  const endDay = getLocalEpochDay(db, referenceMs);
  const keys: number[] = [];
  for (let day = startDay; day <= endDay; day += 1) {
    keys.push(day);
  }
  return keys;
}

// Project a bucket→value map onto the axis, filling absent buckets with zero.
// Without an axis (the "all" range) it falls back to the populated buckets in
// ascending order, preserving the previous behaviour.
function fillAxisPoints(
  axis: number[] | null,
  valueByBucket: Map<number, number>,
): TrendChartPoint[] {
  const keys = axis ?? [...valueByBucket.keys()].sort((left, right) => left - right);
  return keys.map((key) => ({ label: makeTrendLabel(key), value: valueByBucket.get(key) ?? 0 }));
}

function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
  let sum = 0;
  return points.map((point) => {
    sum += point.value;
    return {
      label: point.label,
      value: sum,
    };
  });
}

function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[], axis: number[] | null) {
  const byKey = new Map<
    number,
    { activeMin: number; cards: number; words: number; sessions: number }
  >();

  for (const rollup of rollups) {
    const existing = byKey.get(rollup.rollupDayOrMonth) ?? {
      activeMin: 0,
      cards: 0,
      words: 0,
      sessions: 0,
    };
    existing.activeMin += rollup.totalActiveMin;
    existing.cards += rollup.totalCards;
    existing.words += rollup.totalTokensSeen;
    existing.sessions += rollup.totalSessions;
    byKey.set(rollup.rollupDayOrMonth, existing);
  }

  const keys = axis ?? Array.from(byKey.keys()).sort((left, right) => left - right);
  return keys.map((key) => {
    const value = byKey.get(key) ?? { activeMin: 0, cards: 0, words: 0, sessions: 0 };
    return {
      label: makeTrendLabel(key),
      activeMin: Math.round(value.activeMin),
      cards: value.cards,
      words: value.words,
      sessions: value.sessions,
    };
  });
}

function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
  cardsPerHour: TrendChartPoint[];
  readingSpeed: TrendChartPoint[];
} {
  const cardsPerHour: TrendChartPoint[] = [];
  const readingSpeed: TrendChartPoint[] = [];
  for (const row of rows) {
    const hours = row.activeMin / 60;
    cardsPerHour.push({
      label: row.label,
      value: hours > 0 ? +(row.cards / hours).toFixed(1) : 0,
    });
    readingSpeed.push({
      label: row.label,
      value: row.activeMin > 0 ? +(row.words / row.activeMin).toFixed(1) : 0,
    });
  }
  return { cardsPerHour, readingSpeed };
}

function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
  const totals = new Array(7).fill(0);
  for (const session of sessions) {
    totals[session.dayOfWeek] += session.activeWatchedMs;
  }
  return DAY_NAMES.map((name, index) => ({
    label: name,
    value: Math.round(totals[index] / 60_000),
  }));
}

function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
  const totals = new Array(24).fill(0);
  for (const session of sessions) {
    totals[session.hourOfDay] += session.activeWatchedMs;
  }
  return totals.map((ms, index) => ({
    label: `${String(index).padStart(2, '0')}:00`,
    value: Math.round(ms / 60_000),
  }));
}

function buildSessionSeries(
  sessions: TrendSessionMetricRow[],
  groupBy: TrendGroupBy,
  getValue: (session: TrendSessionMetricRow) => number,
  axis: number[] | null,
): TrendChartPoint[] {
  const byBucket = new Map<number, number>();
  for (const session of sessions) {
    const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
    byBucket.set(bucketKey, (byBucket.get(bucketKey) ?? 0) + getValue(session));
  }
  return fillAxisPoints(axis, byBucket);
}

function buildLookupsPerHundredWords(
  sessions: TrendSessionMetricRow[],
  groupBy: TrendGroupBy,
  axis: number[] | null,
): TrendChartPoint[] {
  const lookupsByBucket = new Map<number, number>();
  const wordsByBucket = new Map<number, number>();

  for (const session of sessions) {
    const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
    lookupsByBucket.set(
      bucketKey,
      (lookupsByBucket.get(bucketKey) ?? 0) + session.yomitanLookupCount,
    );
    wordsByBucket.set(
      bucketKey,
      (wordsByBucket.get(bucketKey) ?? 0) + getTrendSessionWordCount(session),
    );
  }

  const ratioByBucket = new Map<number, number>();
  for (const [bucketKey, lookups] of lookupsByBucket) {
    const words = wordsByBucket.get(bucketKey) ?? 0;
    ratioByBucket.set(bucketKey, words > 0 ? +((lookups / words) * 100).toFixed(1) : 0);
  }
  return fillAxisPoints(axis, ratioByBucket);
}

function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
  const byAnime = new Map<string, Map<number, number>>();
  const allDays = new Set<number>();

  for (const point of points) {
    const dayMap = byAnime.get(point.animeTitle) ?? new Map();
    dayMap.set(point.epochDay, (dayMap.get(point.epochDay) ?? 0) + point.value);
    byAnime.set(point.animeTitle, dayMap);
    allDays.add(point.epochDay);
  }

  const sortedDays = [...allDays].sort((left, right) => left - right);
  if (sortedDays.length === 0) {
    return [];
  }

  const minDay = sortedDays[0]!;
  const maxDay = sortedDays[sortedDays.length - 1]!;
  const result: TrendPerAnimePoint[] = [];

  for (const [animeTitle, dayMap] of byAnime) {
    const firstDay = Math.min(...dayMap.keys());
    let cumulative = 0;
    for (let epochDay = minDay; epochDay <= maxDay; epochDay += 1) {
      if (epochDay < firstDay) {
        continue;
      }
      cumulative += dayMap.get(epochDay) ?? 0;
      result.push({ epochDay, animeTitle, value: cumulative });
    }
  }

  return result;
}

function buildLibrarySummary(
  rollups: ImmersionSessionRollupRow[],
  sessions: TrendSessionMetricRow[],
  titlesByVideoId: Map<number, string>,
): LibrarySummaryRow[] {
  type Accum = {
    watchTimeMin: number;
    videos: Set<number>;
    cards: number;
    words: number;
    firstWatched: number;
    lastWatched: number;
    sessions: number;
    lookups: number;
  };

  const byTitle = new Map<string, Accum>();

  const ensure = (title: string): Accum => {
    const existing = byTitle.get(title);
    if (existing) return existing;
    const created: Accum = {
      watchTimeMin: 0,
      videos: new Set<number>(),
      cards: 0,
      words: 0,
      firstWatched: Number.POSITIVE_INFINITY,
      lastWatched: Number.NEGATIVE_INFINITY,
      sessions: 0,
      lookups: 0,
    };
    byTitle.set(title, created);
    return created;
  };

  for (const rollup of rollups) {
    if (rollup.videoId === null) continue;
    const title = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId);
    const acc = ensure(title);
    acc.watchTimeMin += rollup.totalActiveMin;
    acc.cards += rollup.totalCards;
    acc.words += rollup.totalTokensSeen;
    acc.videos.add(rollup.videoId);
    if (rollup.rollupDayOrMonth < acc.firstWatched) {
      acc.firstWatched = rollup.rollupDayOrMonth;
    }
    if (rollup.rollupDayOrMonth > acc.lastWatched) {
      acc.lastWatched = rollup.rollupDayOrMonth;
    }
  }

  for (const session of sessions) {
    const title = resolveTrendAnimeTitle(session);
    if (!byTitle.has(title)) continue;
    const acc = byTitle.get(title)!;
    acc.sessions += 1;
    acc.lookups += session.yomitanLookupCount;
  }

  const rows: LibrarySummaryRow[] = [];
  for (const [title, acc] of byTitle) {
    if (!Number.isFinite(acc.firstWatched) || !Number.isFinite(acc.lastWatched)) {
      continue;
    }
    rows.push({
      title,
      watchTimeMin: Math.round(acc.watchTimeMin),
      videos: acc.videos.size,
      sessions: acc.sessions,
      cards: acc.cards,
      words: acc.words,
      lookups: acc.lookups,
      lookupsPerHundred: acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
      firstWatched: acc.firstWatched,
      lastWatched: acc.lastWatched,
    });
  }

  rows.sort((a, b) => b.watchTimeMin - a.watchTimeMin || a.title.localeCompare(b.title));
  return rows;
}

function getVideoAnimeTitleMap(
  db: DatabaseSync,
  videoIds: Array<number | null>,
): Map<number, string> {
  const uniqueIds = [
    ...new Set(videoIds.filter((value): value is number => typeof value === 'number')),
  ];
  if (uniqueIds.length === 0) {
    return new Map();
  }

  const rows = db
    .prepare(
      `
        SELECT
          v.video_id AS videoId,
          COALESCE(a.canonical_title, v.canonical_title, 'Unknown') AS animeTitle
        FROM imm_videos v
        LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
        WHERE v.video_id IN (${makePlaceholders(uniqueIds)})
      `,
    )
    .all(...uniqueIds) as Array<{ videoId: number; animeTitle: string }>;

  return new Map(rows.map((row) => [row.videoId, sanitizeTrendTitle(row.animeTitle)]));
}

function resolveVideoAnimeTitle(
  videoId: number | null,
  titlesByVideoId: Map<number, string>,
): string {
  if (videoId === null) {
    return 'Unknown';
  }
  return titlesByVideoId.get(videoId) ?? 'Unknown';
}

function buildPerAnimeFromDailyRollups(
  rollups: ImmersionSessionRollupRow[],
  titlesByVideoId: Map<number, string>,
  getValue: (rollup: ImmersionSessionRollupRow) => number,
): TrendPerAnimePoint[] {
  const byAnime = new Map<string, Map<number, number>>();

  for (const rollup of rollups) {
    const animeTitle = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId);
    const dayMap = byAnime.get(animeTitle) ?? new Map();
    dayMap.set(
      rollup.rollupDayOrMonth,
      (dayMap.get(rollup.rollupDayOrMonth) ?? 0) + getValue(rollup),
    );
    byAnime.set(animeTitle, dayMap);
  }

  const result: TrendPerAnimePoint[] = [];
  for (const [animeTitle, dayMap] of byAnime) {
    for (const [epochDay, value] of dayMap) {
      result.push({ epochDay, animeTitle, value });
    }
  }
  return result;
}

function buildEpisodesPerAnimeFromDailyRollups(
  rollups: ImmersionSessionRollupRow[],
  titlesByVideoId: Map<number, string>,
): TrendPerAnimePoint[] {
  const byAnime = new Map<string, Map<number, Set<number>>>();

  for (const rollup of rollups) {
    if (rollup.videoId === null) {
      continue;
    }
    const animeTitle = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId);
    const dayMap = byAnime.get(animeTitle) ?? new Map();
    const videoIds = dayMap.get(rollup.rollupDayOrMonth) ?? new Set<number>();
    videoIds.add(rollup.videoId);
    dayMap.set(rollup.rollupDayOrMonth, videoIds);
    byAnime.set(animeTitle, dayMap);
  }

  const result: TrendPerAnimePoint[] = [];
  for (const [animeTitle, dayMap] of byAnime) {
    for (const [epochDay, videoIds] of dayMap) {
      result.push({ epochDay, animeTitle, value: videoIds.size });
    }
  }
  return result;
}

function buildEpisodesSeriesFromRollups(
  rollups: ImmersionSessionRollupRow[],
  axis: number[] | null,
): TrendChartPoint[] {
  const byBucket = new Map<number, Set<number>>();

  for (const rollup of rollups) {
    if (rollup.videoId === null) {
      continue;
    }
    const videoIds = byBucket.get(rollup.rollupDayOrMonth) ?? new Set<number>();
    videoIds.add(rollup.videoId);
    byBucket.set(rollup.rollupDayOrMonth, videoIds);
  }

  const counts = new Map<number, number>();
  for (const [bucketKey, videoIds] of byBucket) {
    counts.set(bucketKey, videoIds.size);
  }
  return fillAxisPoints(axis, counts);
}

function getTrendSessionMetrics(
  db: DatabaseSync,
  cutoffMs: string | null,
): TrendSessionMetricRow[] {
  const wordsExpr = sessionDisplayWordsExpr('s', 'swc', 'COALESCE(asm.tokensSeen, s.tokens_seen)');
  const whereClause = cutoffMs === null ? '' : 'WHERE s.started_at_ms >= ?';
  const cutoffValue = cutoffMs === null ? null : toDbTimestamp(cutoffMs);
  const prepared = db.prepare(`
    ${ACTIVE_SESSION_METRICS_CTE}
    SELECT
      s.started_at_ms AS startedAtMs,
      s.video_id AS videoId,
      v.canonical_title AS canonicalTitle,
      a.canonical_title AS animeTitle,
      COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
      ${wordsExpr} AS tokensSeen,
      COALESCE(asm.cardsMined, s.cards_mined, 0) AS cardsMined,
      COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0) AS yomitanLookupCount
    FROM imm_sessions s
    LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
    LEFT JOIN session_word_counts swc ON swc.sessionId = s.session_id
    LEFT JOIN imm_videos v ON v.video_id = s.video_id
    LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
    ${whereClause}
    ORDER BY s.started_at_ms ASC
  `);

  const rows = (cutoffValue === null ? prepared.all() : prepared.all(cutoffValue)) as Array<
    TrendSessionMetricRow & { startedAtMs: number | string }
  >;
  return rows.map((row) => ({
    ...row,
    startedAtMs: 0,
    epochDay: getLocalEpochDay(db, row.startedAtMs),
    monthKey: getLocalMonthKey(db, row.startedAtMs),
    dayOfWeek: getLocalDayOfWeek(db, row.startedAtMs),
    hourOfDay: getLocalHourOfDay(db, row.startedAtMs),
  }));
}

function buildNewWordsPerDay(
  db: DatabaseSync,
  cutoffMs: string | null,
  axis: number[] | null,
): TrendChartPoint[] {
  if (areLexicalDailyRollupsReady(db)) {
    // A trend range is defined in calendar buckets, so the rollup includes the
    // complete local cutoff day rather than applying a time-of-day boundary.
    const cutoffDay = cutoffMs === null ? null : getLocalEpochDay(db, cutoffMs);
    const rows = getLexicalDailyRollups(db).filter(
      (row) => cutoffDay === null || row.epochDay >= cutoffDay,
    );
    return fillAxisPoints(axis, new Map(rows.map((row) => [row.epochDay, row.wordCount])));
  }

  const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
  const prepared = db.prepare(`
    SELECT
      CAST(
        julianday(CAST(first_seen AS REAL), 'unixepoch', 'localtime') - 2440587.5
        AS INTEGER
      ) AS epochDay,
      COUNT(*) AS wordCount
    FROM imm_words
    WHERE first_seen IS NOT NULL
    ${whereClause}
    GROUP BY epochDay
    ORDER BY epochDay ASC
  `);

  const rows = (
    cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString())
  ) as Array<{
    epochDay: number;
    wordCount: number;
  }>;

  const byBucket = new Map<number, number>(rows.map((row) => [row.epochDay, row.wordCount]));
  return fillAxisPoints(axis, byBucket);
}

function buildNewWordsPerMonth(
  db: DatabaseSync,
  cutoffMs: string | null,
  axis: number[] | null,
): TrendChartPoint[] {
  if (areLexicalDailyRollupsReady(db)) {
    const cutoffDay = cutoffMs === null ? null : getLocalEpochDay(db, cutoffMs);
    const byMonth = new Map<number, number>();
    for (const row of getLexicalDailyRollups(db)) {
      if (cutoffDay !== null && row.epochDay < cutoffDay) continue;
      const { year, month } = dayPartsFromEpochDay(row.epochDay);
      const monthKey = year * 100 + month;
      byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + row.wordCount);
    }
    return fillAxisPoints(axis, byMonth);
  }

  const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
  const prepared = db.prepare(`
    SELECT
      CAST(
        strftime('%Y%m', CAST(first_seen AS REAL), 'unixepoch', 'localtime')
        AS INTEGER
      ) AS monthKey,
      COUNT(*) AS wordCount
    FROM imm_words
    WHERE first_seen IS NOT NULL
    ${whereClause}
    GROUP BY monthKey
    ORDER BY monthKey ASC
  `);

  const rows = (
    cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString())
  ) as Array<{
    monthKey: number;
    wordCount: number;
  }>;

  const byBucket = new Map<number, number>(rows.map((row) => [row.monthKey, row.wordCount]));
  return fillAxisPoints(axis, byBucket);
}

export function getTrendsDashboard(
  db: DatabaseSync,
  range: TrendRange = '30d',
  groupBy: TrendGroupBy = 'day',
  fillEmptyBuckets = true,
): TrendsDashboardQueryResult {
  const dayLimit = getTrendDayLimit(range);
  const monthlyLimit = getTrendMonthlyLimit(db, range);
  const cutoffMs = getTrendCutoffMs(db, range);
  const useMonthlyBuckets = groupBy === 'month';
  const dailyRollups = getDailyRollups(db, dayLimit);
  const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
  // A null axis makes the builders fall back to only the buckets present in the
  // data; the contiguous axis zero-fills every calendar bucket in the window.
  const bucketAxis = fillEmptyBuckets
    ? buildBucketAxis(db, groupBy, cutoffMs, currentDbTimestamp())
    : null;

  const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
  const sessions = getTrendSessionMetrics(db, cutoffMs);
  const titlesByVideoId = getVideoAnimeTitleMap(
    db,
    dailyRollups.map((rollup) => rollup.videoId),
  );

  const aggregatedRows = buildAggregatedTrendRows(chartRollups, bucketAxis);
  const efficiency = buildEfficiencyRates(aggregatedRows);
  const activity = {
    watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
    cards: aggregatedRows.map((row) => ({ label: row.label, value: row.cards })),
    words: aggregatedRows.map((row) => ({ label: row.label, value: row.words })),
    sessions: aggregatedRows.map((row) => ({ label: row.label, value: row.sessions })),
  };

  const animePerDay = {
    episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
    watchTime: buildPerAnimeFromDailyRollups(
      dailyRollups,
      titlesByVideoId,
      (rollup) => rollup.totalActiveMin,
    ),
    cards: buildPerAnimeFromDailyRollups(
      dailyRollups,
      titlesByVideoId,
      (rollup) => rollup.totalCards,
    ),
    words: buildPerAnimeFromDailyRollups(
      dailyRollups,
      titlesByVideoId,
      (rollup) => rollup.totalTokensSeen,
    ),
  };

  return {
    activity,
    progress: {
      watchTime: accumulatePoints(activity.watchTime),
      sessions: accumulatePoints(activity.sessions),
      words: accumulatePoints(activity.words),
      newWords: accumulatePoints(
        useMonthlyBuckets
          ? buildNewWordsPerMonth(db, cutoffMs, bucketAxis)
          : buildNewWordsPerDay(db, cutoffMs, bucketAxis),
      ),
      cards: accumulatePoints(activity.cards),
      episodes: accumulatePoints(
        buildEpisodesSeriesFromRollups(
          useMonthlyBuckets ? monthlyRollups : dailyRollups,
          bucketAxis,
        ),
      ),
      lookups: accumulatePoints(
        buildSessionSeries(sessions, groupBy, (session) => session.yomitanLookupCount, bucketAxis),
      ),
    },
    ratios: {
      lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy, bucketAxis),
      cardsPerHour: efficiency.cardsPerHour,
      readingSpeed: efficiency.readingSpeed,
    },
    animeCumulative: {
      watchTime: buildCumulativePerAnime(animePerDay.watchTime),
      episodes: buildCumulativePerAnime(animePerDay.episodes),
      cards: buildCumulativePerAnime(animePerDay.cards),
      words: buildCumulativePerAnime(animePerDay.words),
    },
    patterns: {
      watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
      watchTimeByHour: buildWatchTimeByHour(sessions),
    },
    librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
  };
}
