import { describe, test, expect, beforeEach } from 'vitest';
import { openDatabase } from '@insta-monitor/db';
import type { Database } from '@insta-monitor/db';
import type { ScrapedProfile } from '@insta-monitor/collector';
import { IgRateLimitError, IgAccountNotFoundError, IgPrivateAccountError } from '@insta-monitor/collector';
import { collectAccount, collectMany, FAIL_ABORT } from './index.js';
import type { ProfileSource } from './index.js';

const profile = (
  username: string,
  followers: number,
  posts: Array<{ shortcode: string; comments: number; type?: 'reel' | 'post' }>,
): ScrapedProfile => ({
  username,
  displayName: username,
  followerCount: followers,
  postCount: posts.length,
  posts: posts.map((p) => ({
    shortcode: p.shortcode,
    type: p.type ?? 'reel',
    url: `https://www.instagram.com/reel/${p.shortcode}/`,
    thumbnail: null,
    caption: null,
    uploadedAt: '2026-02-05T00:00:00Z', // within 24h of opts.collectedAt so the 떡상 rule can fire
    viewCount: null,
    likeCount: null,
    commentCount: p.comments,
  })),
});

const fakeSource = (fixtures: Record<string, ScrapedProfile | Error>): ProfileSource => ({
  collectProfile: async (username: string) => {
    const f = fixtures[username];
    if (!f) throw new Error(`no fixture for ${username}`);
    if (f instanceof Error) throw f;
    return f;
  },
});

const opts = { date: '2026-02-05', collectedAt: '2026-02-05T11:00:00Z' };

let db: Database;
beforeEach(() => {
  db = openDatabase(':memory:');
});

describe('collectAccount', () => {
  test('creates the account if missing, updates meta, upserts posts, records a snapshot', async () => {
    const src = fakeSource({
      'tem.duck': profile('tem.duck', 207378, [
        { shortcode: 'A', comments: 1500 },
        { shortcode: 'B', comments: 10 },
      ]),
    });

    const res = await collectAccount(db, src, 'tem.duck', opts);

    expect(res.status).toBe('ok');
    expect(res.postsCollected).toBe(2);
    expect(res.viralCount).toBe(1); // A: 1500 comments ≥ 1000, within 24h → 떡상

    const acc = db.accounts.getByUsername('tem.duck')!;
    expect(acc.followerCount).toBe(207378);
    expect(acc.lastCollectedAt).toBe('2026-02-05T11:00:00Z');
    expect(db.posts.withGrowth(acc.id).find((p) => p.shortcode === 'A')!.commentCount).toBe(1500);
  });

  test('a later collection accumulates the time series and computes growth', async () => {
    await collectAccount(db, fakeSource({ a: profile('a', 100, [{ shortcode: 'X', comments: 30 }]) }), 'a', {
      date: '2026-02-01',
      collectedAt: '2026-02-01T11:00:00Z',
    });
    await collectAccount(db, fakeSource({ a: profile('a', 110, [{ shortcode: 'X', comments: 100 }]) }), 'a', {
      date: '2026-02-08',
      collectedAt: '2026-02-08T11:00:00Z',
    });

    const acc = db.accounts.getByUsername('a')!;
    const post = db.posts.listByAccount(acc.id)[0]!;
    const series = db.dailyStats.listByPost(post.id);
    expect(series).toHaveLength(2);
    // 02-01 → 02-08 is a 7-day gap, so the stored *daily* delta is null (not a real
    // 1-day change), while the windowed 7-day growth is still computed.
    expect(series[1]!.commentDelta).toBeNull();
    expect(series[1]!.followerDelta).toBeNull();
    expect(db.posts.withGrowth(acc.id)[0]!.growth7d).toBe(70);
  });

  test('returns an error result when the source fails, without throwing', async () => {
    const res = await collectAccount(db, fakeSource({ boom: new Error('network down') }), 'boom', opts);
    expect(res.status).toBe('error');
    expect(res.error).toContain('network down');
    expect(res.postsCollected).toBe(0);
  });
});

describe('collectMany', () => {
  test('one account failing does not stop the others', async () => {
    const src = fakeSource({
      ok1: profile('ok1', 1, [{ shortcode: 'A', comments: 5 }]),
      bad: new Error('blocked'),
      ok2: profile('ok2', 2, [{ shortcode: 'B', comments: 2000 }]),
    });

    const results = await collectMany(db, src, ['ok1', 'bad', 'ok2'], opts);

    expect(results.map((r) => r.status)).toEqual(['ok', 'error', 'ok']);
    expect(db.accounts.getByUsername('ok2')).toBeDefined();
    expect(results.find((r) => r.username === 'ok2')!.viralCount).toBe(1);
  });

  test('aborts the run after consecutive rate-limits instead of feeding the throttle', async () => {
    const names = Array.from({ length: 10 }, (_, i) => `u${i}`);
    const src: ProfileSource = { collectProfile: async () => { throw new IgRateLimitError('throttled'); } };
    const results = await collectMany(db, src, names, opts);
    expect(results).toHaveLength(4); // RATE_LIMIT_ABORT — stops after 4, doesn't hammer all 10
    expect(results.every((r) => r.rateLimited)).toBe(true);
  });

  test('onAfterEach fires once after each collected account (incremental 떡상 alerts, not batched at end)', async () => {
    const src = fakeSource({
      a: profile('a', 1, [{ shortcode: 'A', comments: 5 }]),
      b: profile('b', 2, [{ shortcode: 'B', comments: 6 }]),
    });
    const firedAfter: string[] = [];
    await collectMany(db, src, ['a', 'b'], opts, undefined, 'light', undefined, undefined, (r) => {
      firedAfter.push(r.username);
    });
    expect(firedAfter).toEqual(['a', 'b']); // one call per channel, in order — so alerts push per-channel
  });

  test('aborts after consecutive failures even without a rate-limit signal (soft-block)', async () => {
    // Soft-throttle: IG serves an empty shell -> generic error, NOT IgRateLimitError.
    // Must still abort instead of grinding all 10 (which deepens the account flag).
    const names = Array.from({ length: 10 }, (_, i) => `u${i}`);
    const src: ProfileSource = { collectProfile: async () => { throw new Error('no timeline (soft block)'); } };
    const results = await collectMany(db, src, names, opts);
    expect(results).toHaveLength(FAIL_ABORT);
    expect(results.every((r) => r.status === 'error' && !r.rateLimited)).toBe(true);
  });
});

describe('collectAccount: health + mode', () => {
  test('success records last_success_at, resets failures, marks refreshed', async () => {
    const src = fakeSource({ 'a': profile('a', 10, [{ shortcode: 'X', comments: 5 }]) });
    const id = db.accounts.create({ username: 'a' });
    db.accounts.recordCollectFailure(id, opts.collectedAt, 'old');
    await collectAccount(db, src, 'a', opts);
    const acc = db.accounts.getById(id)!;
    expect(acc.consecutiveFailures).toBe(0);
    expect(acc.lastSuccessAt).toBe(opts.collectedAt);
    expect(acc.lastRefreshAt).toBe(opts.collectedAt);
  });

  test('failure increments consecutiveFailures and records the error', async () => {
    const src = fakeSource({ 'a': new Error('blocked') });
    const id = db.accounts.create({ username: 'a' });
    const res = await collectAccount(db, src, 'a', opts);
    expect(res.status).toBe('error');
    const acc = db.accounts.getById(id)!;
    expect(acc.consecutiveFailures).toBe(1);
    expect(acc.lastError).toContain('blocked');
  });

  test('mode is forwarded to the source', async () => {
    const seen: string[] = [];
    const src = { collectProfile: async (u: string, o?: { mode?: string }) => { seen.push(o?.mode ?? 'deep'); return profile(u, 1, []); } };
    db.accounts.create({ username: 'a' });
    await collectAccount(db, src, 'a', opts, 'light');
    expect(seen).toEqual(['light']);
  });
});

describe('collectMany: stop (AbortSignal — competitor stop_requested equivalent)', () => {
  test('already-aborted signal → collects nothing, returns []', async () => {
    const ac = new AbortController();
    ac.abort();
    const seen: string[] = [];
    const src: ProfileSource = { collectProfile: async (u) => { seen.push(u); return profile(u, 0, []); } };
    const res = await collectMany(db, src, ['a', 'b', 'c'], opts, undefined, 'light', undefined, ac.signal);
    expect(res).toEqual([]);
    expect(seen).toEqual([]); // never even started an account
  });

  test('abort between accounts → stops, keeps only pre-abort results', async () => {
    const ac = new AbortController();
    let n = 0;
    const src: ProfileSource = {
      collectProfile: async (u) => {
        n += 1;
        if (n === 2) ac.abort(); // stop requested while collecting the 2nd account
        return profile(u, 0, [{ shortcode: `${u}1`, comments: 1 }]);
      },
    };
    const res = await collectMany(db, src, ['a', 'b', 'c', 'd'], opts, undefined, 'light', undefined, ac.signal);
    // account #2's result is discarded on abort; only #1 is kept, #3/#4 never run
    expect(res.map((r) => r.username)).toEqual(['a']);
  });
});

describe('collectAccount: aborted status (no failure recorded)', () => {
  test('signal aborted + scrape throws → status "aborted", no consecutiveFailures bump', async () => {
    const id = db.accounts.create({ username: 'x' });
    const ac = new AbortController();
    ac.abort();
    const src: ProfileSource = { collectProfile: async () => { throw new Error('browser closed (stop)'); } };
    const r = await collectAccount(db, src, 'x', opts, 'light', ac.signal);
    expect(r.status).toBe('aborted');
    expect(db.accounts.getById(id)!.consecutiveFailures).toBe(0); // not poisoned
  });
});

describe('dead (not_found) accounts', () => {
  test('IgAccountNotFoundError marks the account not_found and flags the result', async () => {
    db.accounts.create({ username: 'gone' });
    const src = fakeSource({ gone: new IgAccountNotFoundError('@gone 계정을 찾을 수 없어요') });
    const res = await collectAccount(db, src, 'gone', opts);
    expect(res.status).toBe('error');
    expect(res.notFound).toBe(true);
    expect(db.accounts.getByUsername('gone')!.backfillState).toBe('not_found');
  });

  test('not_found accounts are excluded from refresh-due and priority lists', () => {
    const id = db.accounts.create({ username: 'gone', autoCollect: true });
    db.accounts.setPriority(id, true);
    db.accounts.setBackfillState(id, 'not_found');
    expect(db.accounts.listRefreshDue('9999-01-01T00:00:00Z', true)).toHaveLength(0);
    expect(db.accounts.listPriority()).toHaveLength(0);
  });

  test('a dead account mid-streak does not reset the rate-limit streak (still counts as a failure)', async () => {
    const src = fakeSource({
      a: new IgRateLimitError('throttled'),
      b: new IgRateLimitError('throttled'),
      dead: new IgAccountNotFoundError('gone'),
      c: new IgRateLimitError('throttled'),
      d: new IgRateLimitError('throttled'),
      e: profile('e', 1, []),
    });
    const results = await collectMany(db, src, ['a', 'b', 'dead', 'c', 'd', 'e'], opts);
    // rl streak a,b (2) → dead is neutral → c,d hit RATE_LIMIT_ABORT(4) → abort before 'e'
    expect(results).toHaveLength(5);
    expect(results.at(-1)!.username).toBe('d');
  });

  test('collecting a previously-dead username again revives it', async () => {
    const id = db.accounts.create({ username: 'back' });
    db.accounts.setBackfillState(id, 'not_found');
    await collectAccount(db, fakeSource({ back: profile('back', 5, [{ shortcode: 'X', comments: 0 }]) }), 'back', opts);
    expect(db.accounts.getByUsername('back')!.backfillState).toBe('pending'); // revive → deep backfill re-queued
  });
});

describe('metaMissing profiles (web_profile_info failed)', () => {
  test('keeps stored follower/post counts instead of overwriting with placeholder 0', async () => {
    await collectAccount(db, fakeSource({ a: profile('a', 100, [{ shortcode: 'X', comments: 0 }]) }), 'a', opts);
    const degraded: ScrapedProfile = { ...profile('a', 0, [{ shortcode: 'X', comments: 5 }]), postCount: 1, metaMissing: true };
    await collectAccount(db, fakeSource({ a: degraded }), 'a', { date: '2026-02-06', collectedAt: '2026-02-06T11:00:00Z' });
    expect(db.accounts.getByUsername('a')!.followerCount).toBe(100); // the "팔로워 0" bug
  });
});

describe('private accounts', () => {
  test('IgPrivateAccountError marks the account private and flags the result', async () => {
    db.accounts.create({ username: 'sneaky' });
    const res = await collectAccount(db, fakeSource({ sneaky: new IgPrivateAccountError('@sneaky 비공개') }), 'sneaky', opts);
    expect(res.status).toBe('error');
    expect(res.private).toBe(true);
    expect(db.accounts.getByUsername('sneaky')!.backfillState).toBe('private');
  });

  test('private accounts are excluded from refresh-due and priority lists', () => {
    const id = db.accounts.create({ username: 'sneaky', autoCollect: true });
    db.accounts.setPriority(id, true);
    db.accounts.setBackfillState(id, 'private');
    expect(db.accounts.listRefreshDue('9999-01-01T00:00:00Z', true)).toHaveLength(0);
    expect(db.accounts.listPriority()).toHaveLength(0);
  });

  test('a private account mid-streak is streak-neutral like not_found (does not reset rl streak)', async () => {
    const src = fakeSource({
      a: new IgRateLimitError('t'), b: new IgRateLimitError('t'),
      priv: new IgPrivateAccountError('p'),
      c: new IgRateLimitError('t'), d: new IgRateLimitError('t'),
      e: profile('e', 1, []),
    });
    const results = await collectMany(db, src, ['a', 'b', 'priv', 'c', 'd', 'e'], opts);
    // priv is neutral → a,b,c,d reach RATE_LIMIT_ABORT(4) → abort before 'e'
    expect(results).toHaveLength(5);
    expect(results.at(-1)!.username).toBe('d');
  });

  test('a private account that starts serving posts (went public) is revived to pending', async () => {
    const id = db.accounts.create({ username: 'wentpublic' });
    db.accounts.setBackfillState(id, 'private');
    await collectAccount(db, fakeSource({ wentpublic: profile('wentpublic', 9, [{ shortcode: 'X', comments: 0 }]) }), 'wentpublic', opts);
    expect(db.accounts.getByUsername('wentpublic')!.backfillState).toBe('pending');
  });
});
