import { describe, test, expect, beforeEach } from 'vitest';
import { openDatabase, PRIORITY_CAP } from './index.js';
import type { Database } from './index.js';

let db: Database;

beforeEach(() => {
  db = openDatabase(':memory:');
});

describe('groups', () => {
  test('default 미분류 group exists after init', () => {
    expect(db.groups.list().some((g) => g.name === '미분류')).toBe(true);
  });

  test('create a new group and list it', () => {
    db.groups.create('인테리어');
    expect(db.groups.list().map((g) => g.name)).toContain('인테리어');
  });
});

describe('accounts', () => {
  test('create then fetch an account; auto-collect defaults off', () => {
    const id = db.accounts.create({ username: 'tem.duck' });
    const acc = db.accounts.getByUsername('tem.duck');
    expect(acc?.id).toBe(id);
    expect(acc?.autoCollect).toBe(false);
  });

  test('rename keeps the same id (handle remap)', () => {
    const id = db.accounts.create({ username: 'old.name' });
    db.accounts.rename(id, 'new.name');
    expect(db.accounts.getByUsername('old.name')).toBeUndefined();
    expect(db.accounts.getByUsername('new.name')?.id).toBe(id);
  });

  test('delete removes the account', () => {
    const id = db.accounts.create({ username: 'gone' });
    db.accounts.delete(id);
    expect(db.accounts.getByUsername('gone')).toBeUndefined();
  });

  test('createMany adds new accounts, skips duplicates, defaults auto-collect on', () => {
    db.accounts.create({ username: 'dupe' });
    const r = db.accounts.createMany(['dupe', 'new1', 'new2']);
    expect(r).toEqual({ added: 2, duplicate: 1 });
    expect(db.accounts.getByUsername('new1')?.autoCollect).toBe(true);
    expect(db.accounts.list().length).toBe(3);
  });
});

describe('posts + daily stats growth', () => {
  const seedPost = (shortcode = 'ABC', uploadedAt = '2026-01-30T12:00:00Z') => {
    const accountId = db.accounts.create({ username: 'tem.duck' });
    const id = db.posts.upsert({
      accountId,
      shortcode,
      type: 'reel',
      url: `https://instagram.com/reel/${shortcode}`,
      uploadedAt,
    });
    return { accountId, id };
  };

  test('upsert is idempotent on (accountId, shortcode)', () => {
    const { accountId } = seedPost('X');
    const id1 = db.posts.upsert({ accountId, shortcode: 'X', type: 'reel', url: 'u', uploadedAt: '2026-01-01T00:00:00Z' });
    const id2 = db.posts.upsert({ accountId, shortcode: 'X', type: 'reel', url: 'u2', uploadedAt: '2026-01-01T00:00:00Z' });
    expect(id2).toBe(id1);
  });

  test('upsert preserves a good view_count when a later crawl misses it (null/0)', () => {
    const { accountId } = seedPost('V');
    const base = { accountId, shortcode: 'V', type: 'reel' as const, url: 'u', uploadedAt: '2026-01-01T00:00:00Z' };
    const id = db.posts.upsert({ ...base, viewCount: 50000 }); // first crawl captured views
    db.posts.upsert({ ...base, viewCount: null });             // later crawl missed them
    expect(db.posts.getById(id)!.viewCount).toBe(50000);       // not clobbered
    db.posts.upsert({ ...base, viewCount: 0 });                // 0 also shouldn't wipe it
    expect(db.posts.getById(id)!.viewCount).toBe(50000);
    db.posts.upsert({ ...base, viewCount: 61000 });            // a real newer count updates
    expect(db.posts.getById(id)!.viewCount).toBe(61000);
  });

  test('recordSnapshot accumulates a time series and 3/7-day growth is computed', () => {
    const { accountId, id } = seedPost();
    db.dailyStats.record({ postId: id, date: '2026-02-01', commentCount: 30, followerCount: 100 });
    db.dailyStats.record({ postId: id, date: '2026-02-05', commentCount: 80, followerCount: 105 });
    db.dailyStats.record({ postId: id, date: '2026-02-08', commentCount: 100, followerCount: 110 });

    // Anchor "now" on the latest snapshot day so growth3d measures its real recent window.
    const rows = db.posts.withGrowth(accountId, undefined, '2026-02-08T12:00:00Z');
    const p = rows.find((x) => x.id === id)!;
    expect(p.commentCount).toBe(100);
    expect(p.growth3d).toBe(20); // today-anchored: baseline = 02-05 (last snapshot at/before 02-05 cutoff)
    expect(p.growth7d).toBe(70); // latest-anchored: baseline = 02-01
    expect(p.growth1d).toBe(20); // no snapshot inside the 1d window -> falls back to the nearest older one
    expect(p.isViral).toBe(false);
  });

  test('growth3d is 0 for a reel not collected in the last 3 days (excluded from 지금 크는 banner)', () => {
    const { accountId, id } = seedPost();
    db.dailyStats.record({ postId: id, date: '2026-02-01', commentCount: 30, followerCount: 100 });
    db.dailyStats.record({ postId: id, date: '2026-02-08', commentCount: 100, followerCount: 110 });

    // "now" is 10 days after the last snapshot: no recent activity, so growth3d collapses to 0
    // (baseline = latest), while growth7d stays latest-anchored and still reflects tracked growth.
    const p = db.posts.withGrowth(accountId, undefined, '2026-02-18T00:00:00Z').find((x) => x.id === id)!;
    expect(p.growth3d).toBe(0);
    expect(p.growth7d).toBe(70);
  });

  test('daily commentDelta is day-over-day, and null across a gap', () => {
    const { id } = seedPost();
    db.dailyStats.record({ postId: id, date: '2026-02-01', commentCount: 30, followerCount: 100 });
    db.dailyStats.record({ postId: id, date: '2026-02-02', commentCount: 50, followerCount: 120 });
    db.dailyStats.record({ postId: id, date: '2026-02-08', commentCount: 200, followerCount: 130 }); // 6-day gap
    const byDate = new Map(db.dailyStats.listByPost(id).map((r) => [r.date, r.commentDelta]));
    expect(byDate.get('2026-02-01')).toBeNull(); // first ever
    expect(byDate.get('2026-02-02')).toBe(20); // consecutive day
    expect(byDate.get('2026-02-08')).toBeNull(); // gap → not a real 1-day delta
  });

  test('likes/views are recorded with day-over-day deltas and surfaced as latest', () => {
    const { accountId, id } = seedPost();
    db.dailyStats.record({ postId: id, date: '2026-02-01', commentCount: 30, likeCount: 500, viewCount: 9000 });
    db.dailyStats.record({ postId: id, date: '2026-02-02', commentCount: 50, likeCount: 650, viewCount: 12000 });
    const byDate = new Map(db.dailyStats.listByPost(id).map((r) => [r.date, r]));
    expect(byDate.get('2026-02-01')!.likeDelta).toBeNull(); // first ever
    expect(byDate.get('2026-02-02')!.likeDelta).toBe(150);
    expect(byDate.get('2026-02-02')!.viewDelta).toBe(3000);
    const p = db.posts.withGrowth(accountId).find((x) => x.id === id)!;
    expect(p.likeCount).toBe(650); // latest snapshot
    expect(p.viewCount).toBe(12000);
    expect(p.viewGrowth1d).toBe(3000); // 12000 - 9000 over the 1-day window
  });

  test('hidden likes (null) record without a spurious delta', () => {
    const { id } = seedPost();
    db.dailyStats.record({ postId: id, date: '2026-02-01', commentCount: 30, likeCount: null, viewCount: null });
    db.dailyStats.record({ postId: id, date: '2026-02-02', commentCount: 40, likeCount: null, viewCount: null });
    const row = db.dailyStats.listByPost(id).find((r) => r.date === '2026-02-02')!;
    expect(row.likeDelta).toBeNull();
    expect(row.likeCount).toBeNull();
  });

  test('a recent post crossing the threshold is flagged viral', () => {
    // 떡상 rule is age-windowed (24h), so the post must be fresh vs now.
    const { accountId, id } = seedPost('ABC', new Date().toISOString());
    db.dailyStats.record({ postId: id, date: '2026-02-01', commentCount: 1200, followerCount: 100 });
    const p = db.posts.withGrowth(accountId).find((x) => x.id === id)!;
    expect(p.isViral).toBe(true);
  });

  test('daysTracked reflects number of daily_stats snapshots', () => {
    const { accountId, id } = seedPost('TRACK');
    // No snapshots yet → daysTracked === 0
    expect(db.posts.withGrowth(accountId).find((x) => x.id === id)!.daysTracked).toBe(0);
    // One snapshot → daysTracked === 1
    db.dailyStats.record({ postId: id, date: '2026-02-01', commentCount: 10 });
    expect(db.posts.withGrowth(accountId).find((x) => x.id === id)!.daysTracked).toBe(1);
    // Two snapshots → daysTracked === 2
    db.dailyStats.record({ postId: id, date: '2026-02-02', commentCount: 20 });
    expect(db.posts.withGrowth(accountId).find((x) => x.id === id)!.daysTracked).toBe(2);
  });

  test('purgeUploadedBefore removes old posts (+cascades stats), keeps recent and benchmarked', () => {
    const accountId = db.accounts.create({ username: 'tem.duck' });
    const mk = (sc: string, uploadedAt: string) =>
      db.posts.upsert({ accountId, shortcode: sc, type: 'reel', url: 'u', uploadedAt });
    const oldId = mk('OLD', '2026-01-01T00:00:00Z');
    const oldBench = mk('OLDBENCH', '2026-01-01T00:00:00Z');
    const recentId = mk('NEW', '2026-06-01T00:00:00Z');
    db.dailyStats.record({ postId: oldId, date: '2026-01-02', commentCount: 5 });
    db.posts.setBenchmark(oldBench, true);

    const deleted = db.posts.purgeUploadedBefore('2026-03-01T00:00:00Z');
    expect(deleted).toBe(1); // only the old, non-benchmarked post
    expect(db.posts.getById(oldId)).toBeUndefined();
    expect(db.dailyStats.listByPost(oldId)).toHaveLength(0); // cascaded
    expect(db.posts.getById(oldBench)).toBeDefined(); // benchmark preserved
    expect(db.posts.getById(recentId)).toBeDefined(); // recent preserved
  });

  test('setBenchmark adds the post to the benchmark list', () => {
    const { id } = seedPost();
    db.posts.setBenchmark(id, true);
    expect(db.posts.listBenchmarks().some((p) => p.id === id)).toBe(true);
    db.posts.setBenchmark(id, false);
    expect(db.posts.listBenchmarks().some((p) => p.id === id)).toBe(false);
  });
});

describe('settings: auto-collect bookkeeping', () => {
  test('lastAutoCollectDate is null until recorded, then round-trips', () => {
    expect(db.settings.getLastAutoCollectDate()).toBeNull();
    db.settings.setLastAutoCollectDate('2026-06-22');
    expect(db.settings.getLastAutoCollectDate()).toBe('2026-06-22');
  });

  test('launch-at-login defaults to enabled and can be toggled off', () => {
    expect(db.settings.isLaunchAtLoginEnabled()).toBe(true);
    db.settings.setLaunchAtLoginEnabled(false);
    expect(db.settings.isLaunchAtLoginEnabled()).toBe(false);
    db.settings.setLaunchAtLoginEnabled(true);
    expect(db.settings.isLaunchAtLoginEnabled()).toBe(true);
  });

  test('auto-collect master switch defaults to enabled and can be toggled off', () => {
    expect(db.settings.isAutoCollectEnabled()).toBe(true);
    db.settings.setAutoCollectEnabled(false);
    expect(db.settings.isAutoCollectEnabled()).toBe(false);
    db.settings.setAutoCollectEnabled(true);
    expect(db.settings.isAutoCollectEnabled()).toBe(true);
  });

  test('OS scheduler defaults to enabled and round-trips', () => {
    expect(db.settings.isOsSchedulerEnabled()).toBe(true);
    db.settings.setOsSchedulerEnabled(false);
    expect(db.settings.isOsSchedulerEnabled()).toBe(false);
    db.settings.setOsSchedulerEnabled(true);
    expect(db.settings.isOsSchedulerEnabled()).toBe(true);
  });

  test('session validity defaults to valid (no false alarm) and round-trips', () => {
    expect(db.settings.isSessionValid()).toBe(true);
    db.settings.setSessionValid(false);
    expect(db.settings.isSessionValid()).toBe(false);
    db.settings.setSessionValid(true);
    expect(db.settings.isSessionValid()).toBe(true);
  });

  test('collectDays defaults to 7 and round-trips', () => {
    expect(db.settings.getCollectDays()).toBe(7);
    db.settings.setCollectDays(120);
    expect(db.settings.getCollectDays()).toBe(120);
  });

  test('dailyCap defaults to 300 and round-trips', () => {
    expect(db.settings.getDailyCap()).toBe(300);
    db.settings.setDailyCap(500);
    expect(db.settings.getDailyCap()).toBe(500);
  });

  test('logged-in account is null until set, round-trips, and clears to null', () => {
    expect(db.settings.getLoggedInAccount()).toBeNull();
    const me = { username: 'reel.radar', fullName: '릴스 레이더', avatarUrl: 'https://cdn/x.jpg' };
    db.settings.setLoggedInAccount(me);
    expect(db.settings.getLoggedInAccount()).toEqual(me);
    db.settings.setLoggedInAccount(null);
    expect(db.settings.getLoggedInAccount()).toBeNull();
  });
});

describe('accounts: collection health', () => {
  test('new account defaults: backfill pending, no failures', () => {
    const id = db.accounts.create({ username: 'a' });
    const a = db.accounts.getById(id)!;
    expect(a.backfillState).toBe('pending');
    expect(a.consecutiveFailures).toBe(0);
    expect(a.lastSuccessAt).toBeNull();
    expect(a.lastError).toBeNull();
  });

  test('success resets failures + records time; failure increments + records error', () => {
    const id = db.accounts.create({ username: 'a' });
    db.accounts.recordCollectFailure(id, '2026-06-24T00:00:00Z', 'boom');
    db.accounts.recordCollectFailure(id, '2026-06-24T00:05:00Z', 'boom2');
    let a = db.accounts.getById(id)!;
    expect(a.consecutiveFailures).toBe(2);
    expect(a.lastError).toBe('boom2');
    db.accounts.recordCollectSuccess(id, '2026-06-24T01:00:00Z');
    a = db.accounts.getById(id)!;
    expect(a.consecutiveFailures).toBe(0);
    expect(a.lastError).toBeNull();
    expect(a.lastSuccessAt).toBe('2026-06-24T01:00:00Z');
  });

  test('listBackfillPending returns only pending; setBackfillState moves them', () => {
    const a = db.accounts.create({ username: 'a' });
    const b = db.accounts.create({ username: 'b' });
    db.accounts.setBackfillState(a, 'done');
    expect(db.accounts.listBackfillPending().map((x) => x.username)).toEqual(['b']);
    db.accounts.setBackfillState(b, 'unavailable');
    expect(db.accounts.listBackfillPending()).toEqual([]);
  });

  test('listRefreshDue: due when last_refresh_at null or older than cutoff; autoOnly filters', () => {
    const a = db.accounts.create({ username: 'a', autoCollect: true });
    const b = db.accounts.create({ username: 'b' }); // auto off
    db.accounts.markRefreshed(a, '2026-06-23T00:00:00Z'); // old
    // b never refreshed (null) but auto off
    const dueAuto = db.accounts.listRefreshDue('2026-06-24T00:00:00Z', true).map((x) => x.username);
    expect(dueAuto).toEqual(['a']);
    const dueAll = db.accounts.listRefreshDue('2026-06-24T00:00:00Z', false).map((x) => x.username).sort();
    expect(dueAll).toEqual(['a', 'b']);
    db.accounts.markRefreshed(a, '2026-06-24T00:00:01Z'); // now fresh
    expect(db.accounts.listRefreshDue('2026-06-24T00:00:00Z', true)).toEqual([]);
  });

  test('collect cooldown round-trips and clears', () => {
    expect(db.settings.getCollectCooldownUntil()).toBeNull();
    db.settings.setCollectCooldownUntil('2026-06-24T03:00:00Z');
    expect(db.settings.getCollectCooldownUntil()).toBe('2026-06-24T03:00:00Z');
    db.settings.setCollectCooldownUntil(null);
    expect(db.settings.getCollectCooldownUntil()).toBeNull();
  });
});

describe('account priority', () => {
  test('setPriority pins a channel and forces auto-collect on', () => {
    const id = db.accounts.create({ username: 'a' }); // auto-collect off by default
    expect(db.accounts.setPriority(id, true)).toBe(true);
    const a = db.accounts.getById(id)!;
    expect(a.priority).toBe(true);
    expect(a.autoCollect).toBe(true); // pinning implies daily collection
    expect(db.accounts.countPriority()).toBe(1);
  });

  test('setPriority(false) unpins (leaves auto-collect as-is)', () => {
    const id = db.accounts.create({ username: 'a', autoCollect: true });
    db.accounts.setPriority(id, true);
    expect(db.accounts.setPriority(id, false)).toBe(true);
    expect(db.accounts.getById(id)!.priority).toBe(false);
    expect(db.accounts.countPriority()).toBe(0);
  });

  test(`enforces the ${PRIORITY_CAP}-channel priority cap`, () => {
    for (let i = 0; i < PRIORITY_CAP; i += 1) {
      expect(db.accounts.setPriority(db.accounts.create({ username: `u${i}` }), true)).toBe(true);
    }
    const extra = db.accounts.create({ username: 'extra' });
    expect(db.accounts.setPriority(extra, true)).toBe(false); // over cap → rejected
    expect(db.accounts.getById(extra)!.priority).toBe(false);
    expect(db.accounts.countPriority()).toBe(PRIORITY_CAP);
  });

  test('listPriority returns only priority accounts', () => {
    const a = db.accounts.create({ username: 'a' });
    db.accounts.setPriority(a, true);
    db.accounts.create({ username: 'b', autoCollect: true });
    expect(db.accounts.listPriority().map((x) => x.username)).toEqual(['a']);
  });
});
