import { isViral } from '@insta-monitor/core';
import type { Database } from '@insta-monitor/db';
import type { ScrapedProfile } from '@insta-monitor/collector';
import { IgRateLimitError, IgAccountNotFoundError, IgPrivateAccountError } from '@insta-monitor/collector';

/** Anything that can produce a ScrapedProfile for a username (InstagramCollector satisfies this). */
export interface ProfileSource {
  collectProfile(username: string, opts?: { mode?: 'deep' | 'light'; signal?: AbortSignal }): Promise<ScrapedProfile>;
}

export interface CollectOptions {
  /** Snapshot calendar date 'YYYY-MM-DD'. */
  date: string;
  /** ISO timestamp stored as the account's last_collected_at. */
  collectedAt: string;
}

export interface CollectResult {
  username: string;
  /** 'aborted' = a user Stop interrupted this account mid-scrape; NOT a failure (no
   *  cooldown / consecutiveFailures / rate-limit tally). Partial data already persisted. */
  status: 'ok' | 'error' | 'aborted';
  postsCollected: number;
  viralCount: number;
  error?: string;
  /** True when the failure was an IgRateLimitError (deep path 302→home) — caller should cool down. */
  rateLimited?: boolean;
  /** True when the account no longer exists (deleted/renamed). Permanent: the account is
   *  marked not_found and excluded from future runs, so it's neutral to abort streaks. */
  notFound?: boolean;
  /** True when the account is private and unfollowed (exists but serves no posts). Like
   *  notFound it's a channel property, not a throttle — excluded + weekly re-probe. */
  private?: boolean;
}

/** A result for a channel that is inactive by its own nature (deleted or private) rather than
 *  by IG's mood — must never RESET an in-progress rate-limit streak (that delays a needed abort). */
export const isInactiveChannelResult = (r: CollectResult): boolean => !!(r.notFound || r.private);

/**
 * Collect one account: fetch its profile, update account meta, upsert posts,
 * and record today's snapshot for each post. Never throws — a collection
 * failure is returned as `status: 'error'` so batches stay resilient (NFR-3).
 */
export async function collectAccount(
  db: Database,
  source: ProfileSource,
  username: string,
  opts: CollectOptions,
  mode: 'deep' | 'light' = 'deep',
  signal?: AbortSignal,
): Promise<CollectResult> {
  try {
    const profile = await source.collectProfile(username, { mode, signal });

    let account = db.accounts.getByUsername(username);
    if (!account) {
      db.accounts.create({ username });
      account = db.accounts.getByUsername(username)!;
    }
    // A previously-inactive username collecting posts again = it came back (reactivated /
    // went public). Revive to 'pending' (not 'done'): if the deep backfill never ran before it
    // went inactive, it still needs one; a redundant re-backfill for an old account is harmless.
    if (account.backfillState === 'not_found' || account.backfillState === 'private') db.accounts.setBackfillState(account.id, 'pending');

    // When web_profile_info failed, the profile carries placeholder counts (0) —
    // keep the stored values instead of overwriting real ones (the "팔로워 0" bug).
    const followerCount = profile.metaMissing ? account.followerCount : profile.followerCount;
    db.accounts.updateMeta(account.id, {
      followerCount,
      postCount: profile.metaMissing ? account.postCount : profile.postCount,
      lastCollectedAt: opts.collectedAt,
    });

    const cfg = db.settings.getViralConfig();
    let viralCount = 0;

    for (const post of profile.posts) {
      const postId = db.posts.upsert({
        accountId: account.id,
        shortcode: post.shortcode,
        type: post.type,
        url: post.url,
        uploadedAt: post.uploadedAt,
        thumbnail: post.thumbnail,
        caption: post.caption,
        viewCount: post.viewCount,
      });
      db.dailyStats.record({
        postId,
        date: opts.date,
        commentCount: post.commentCount,
        likeCount: post.likeCount,
        viewCount: post.viewCount,
        followerCount,
      });
      if (isViral(post.uploadedAt, post.viewCount, post.commentCount, opts.collectedAt, cfg)) viralCount += 1;
    }

    db.accounts.markRefreshed(account.id, opts.collectedAt);
    db.accounts.recordCollectSuccess(account.id, opts.collectedAt);
    return { username, status: 'ok', postsCollected: profile.posts.length, viralCount };
  } catch (err) {
    // A user Stop closed the browser mid-scrape → the induced error is NOT a real
    // failure: don't record it (would poison the account's health / backfill give-up).
    if (signal?.aborted) return { username, status: 'aborted', postsCollected: 0, viralCount: 0 };
    const notFound = err instanceof IgAccountNotFoundError;
    const priv = err instanceof IgPrivateAccountError;
    const account = db.accounts.getByUsername(username);
    if (account) {
      db.accounts.markRefreshed(account.id, opts.collectedAt);
      db.accounts.recordCollectFailure(account.id, opts.collectedAt, err instanceof Error ? err.message : String(err));
      // Inactive-by-nature accounts: mark so every collection path stops retrying them each run
      // (they used to burn a full navigation + pacing gap on EVERY run). Weekly re-probe revives them.
      if (notFound) db.accounts.setBackfillState(account.id, 'not_found');
      else if (priv) db.accounts.setBackfillState(account.id, 'private');
    }
    return {
      username, status: 'error', postsCollected: 0, viralCount: 0,
      error: err instanceof Error ? err.message : String(err),
      rateLimited: err instanceof IgRateLimitError,
      notFound,
      private: priv,
    };
  }
}

/** Randomized gap (ms) between accounts, to look human and avoid IG rate limits. */
export interface CollectPacing {
  minMs: number;
  maxMs: number;
}
/** Default gap between light-refresh accounts. Halved throughput to match ChannelFinder's
 *  measured ~50 accounts/hour (~72s/account) after a real user's IG account got flagged at
 *  our old ~50/30min pace: an account's scrape is ~20s, so a 40–60s gap → ~70s/account.
 *  (Was 8–20s ≈ 35s/account ≈ 2× the competitor.) Slower = far lower flag risk. */
export const LIGHT_PACING: CollectPacing = { minMs: 40000, maxMs: 60000 };

/** Progress callback invoked after each account: (done, total). */
export type CollectProgress = (done: number, total: number) => void;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
const jitter = (p: CollectPacing): number => Math.floor(p.minMs + Math.random() * (p.maxMs - p.minMs));

/** sleep(ms) that resolves immediately if `signal` aborts mid-wait — so a Stop cuts the
 *  40-60s between-account pacing short instead of waiting it out. */
export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
  if (!signal) return sleep(ms);
  if (signal.aborted) return Promise.resolve();
  return new Promise((resolve) => {
    const onAbort = (): void => { clearTimeout(t); resolve(); };
    const t = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
    signal.addEventListener('abort', onAbort, { once: true });
  });
}

/** Consecutive throttled accounts that abort a run — never keep feeding an active 429/302
 *  (that deepens a flag). Shared by the daily light pass (runDailyLight) and manual/multi
 *  collects (collectMany). */
export const RATE_LIMIT_ABORT = 4;

/** Consecutive FAILURES of any kind (soft-block/format/dead) that abort a run. A soft
 *  throttle (IG serves an empty shell) surfaces as a generic error, NOT IgRateLimitError,
 *  so RATE_LIMIT_ABORT never trips and the run would grind all ~500 accounts for hours —
 *  deepening the flag. This cause-agnostic backstop stops that. Higher than RATE_LIMIT_ABORT
 *  so a few scattered dead/private accounts (streak resets on any success) don't abort a
 *  healthy pass; N-in-a-row means collection is genuinely broken. */
export const FAIL_ABORT = 6;

/**
 * Collect many accounts sequentially (one at a time, to limit IG rate/ban risk — NFR-4).
 * Each account is isolated; one failure does not abort the batch. When `pacing` is
 * given, wait a randomized gap between accounts so a multi-account run isn't a burst.
 */
export async function collectMany(
  db: Database,
  source: ProfileSource,
  usernames: string[],
  opts: CollectOptions,
  pacing?: CollectPacing,
  mode: 'deep' | 'light' = 'light',
  onProgress?: CollectProgress,
  signal?: AbortSignal,
  onAfterEach?: (r: CollectResult) => void | Promise<void>,
): Promise<CollectResult[]> {
  const results: CollectResult[] = [];
  onProgress?.(0, usernames.length); // emit total up front so the UI shows 0/N immediately
  let rlStreak = 0;
  let failStreak = 0;
  for (let i = 0; i < usernames.length; i += 1) {
    if (signal?.aborted) break; // user Stop: exit before starting the next account
    if (i > 0 && pacing) await abortableSleep(jitter(pacing), signal);
    if (signal?.aborted) break; // abort fired during the pacing gap
    const r = await collectAccount(db, source, usernames[i]!, opts, mode, signal);
    if (signal?.aborted) break; // stopped mid-scrape: drop this result (partial data kept in DB)
    results.push(r);
    onProgress?.(results.length, usernames.length);
    // Fire 떡상 alerts NOW, per-channel, while this reel is freshest — not batched at end of a
    // multi-hour pass (which delayed alerts by hours and carried unnotified reels into the next
    // day's run). Awaited so calls serialize; notifyNewlyViral is idempotent so it's safe/cheap.
    if (onAfterEach) await onAfterEach(r);
    // Inactive-by-nature accounts (not_found/private) must not RESET an in-progress rate-limit
    // streak (that delays a needed abort), but they DO count toward the cause-agnostic FAIL_ABORT
    // backstop: a consecutive wave of dead/private shells (IG misbehaving, or a detection false
    // positive) has to stop the run before it marks the whole fleet inactive.
    rlStreak = isInactiveChannelResult(r) ? rlStreak : r.rateLimited ? rlStreak + 1 : 0;
    failStreak = r.status === 'error' ? failStreak + 1 : 0;
    if (rlStreak >= RATE_LIMIT_ABORT || failStreak >= FAIL_ABORT) {
      console.warn(`[collect] aborting run after ${failStreak} consecutive failures (rate-limited streak ${rlStreak}) — collection is failing; backing off instead of deepening the flag`);
      break;
    }
  }
  return results;
}

/** Usernames of accounts flagged for auto-collection. */
export function autoCollectUsernames(db: Database): string[] {
  return db.accounts
    .list()
    .filter((a) => a.autoCollect)
    .map((a) => a.username);
}

/** Collect only the accounts with auto-collect enabled. */
export function collectAuto(
  db: Database,
  source: ProfileSource,
  opts: CollectOptions,
  pacing?: CollectPacing,
  mode: 'deep' | 'light' = 'light',
): Promise<CollectResult[]> {
  return collectMany(db, source, autoCollectUsernames(db), opts, pacing, mode);
}
