import { chromium } from 'playwright';
import type { Browser, BrowserContext, Response, Page } from 'playwright';
import type { IgAccount } from '@insta-monitor/core';
import { saveSession, loadSession, profileDirFor, clearStaleSingletonLocks } from './session.js';
import { parseWebProfileInfo, parseTimelineConnection, parseReelsClipsConnection } from './parse.js';
import { selectReelsForViewFetch } from './viewfetch.js';
import { dedupePostsByShortcode, filterSince, hasPostBefore, sortByUploadedAtDesc } from './pagination.js';
import { classifyTimelineFailure, detectSoftBlock, isNotFoundPage, isPrivatePage } from './classify.js';
import { stealthLaunchOptions, resolveChannel, hostChromeAvailable, STEALTH_INIT_SCRIPT } from './stealth.js';
import { HUMAN_PACING, pickScroll } from './pacing.js';
import { DISCOVERY_OVERLAY_SCRIPT } from './discovery.js';
import { IgRateLimitError, IgPrivateAccountError } from './errors.js';
import { toAvatarDataUrl } from './avatar.js';
import type { CollectorOptions, ScrapedPost, ScrapedProfile } from './types.js';

const TIMELINE_KEY = 'xdt_api__v1__feed__user_timeline_graphql_connection';
// Reels-tab GraphQL connection (PolarisProfileReelsTabContentQuery) — carries the
// per-reel play_count/조회수 that the profile timeline omits. Captured passively.
const REELS_CLIPS_KEY = 'xdt_api__v1__clips__user__connection_v2';

/**
 * Playwright orchestration for Instagram collection.
 *
 * IMPORTANT — this layer is NOT unit-tested: it requires a real browser and a
 * live (logged-in) Instagram session. Pure logic lives in `parse.ts` / `session.ts`
 * (which ARE tested). Before running, install the browser once:
 *   pnpm -C packages/collector exec playwright install chromium
 *
 * Follow-ups (tracked in PRD):
 *  - Deep pagination: collectProfile() captures only the first page from
 *    web_profile_info (~12 posts). Full history needs scroll + clips/feed API capture.
 *  - Anti-detection: wrap chromium with playwright-extra + stealth (PRD D-5).
 */
const IG_BASE = 'https://www.instagram.com';
const IG_WEB_APP_ID = '936619743392459'; // public Instagram web app id required by web_profile_info
const webProfileInfoUrl = (username: string) =>
  `${IG_BASE}/api/v1/users/web_profile_info/?username=${encodeURIComponent(username)}`;
// Match the UA to the actual OS — a Mac UA on a Windows box is a fingerprint
// mismatch that helps IG flag the session.
function defaultUserAgent(): string {
  const v = '131.0.0.0';
  const base = 'AppleWebKit/537.36 (KHTML, like Gecko)';
  if (process.platform === 'win32') return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) ${base} Chrome/${v} Safari/537.36`;
  if (process.platform === 'darwin') return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ${base} Chrome/${v} Safari/537.36`;
  return `Mozilla/5.0 (X11; Linux x86_64) ${base} Chrome/${v} Safari/537.36`;
}

const randInt = (min: number, max: number): number => Math.floor(min + Math.random() * (max - min + 1));
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));

/** Map a non-OK web_profile_info status to an error. 429/401/403 = IP/endpoint
 *  rate-limit (typed, so the runner backs off + the UI shows it honestly). */
function webProfileError(status: number, username: string): Error {
  if (status === 429 || status === 401 || status === 403) {
    return new IgRateLimitError(`@${username} IG 요청이 일시 제한됨 (web_profile_info HTTP ${status}) — 잠시 후 자동 재시도, 재로그인 불필요.`);
  }
  return new Error(`web_profile_info HTTP ${status} for ${username}`);
}

export class InstagramCollector {
  private browser: Browser | null = null;
  private context: BrowserContext | null = null;

  constructor(private readonly opts: CollectorOptions) {}

  async start(): Promise<void> {
    // Anti-detection (PRD D-5): R2 stealth flags + R4 real-Chrome preference + R3
    // PERSISTENT profile. See stealth.ts for the fingerprint rationale. R3 = a stable
    // user-data dir (launchPersistentContext) so the warmed device identity (cookies,
    // IndexedDB, history) survives across runs — IG sees the same trusted device
    // instead of a fresh login each run (which triggers checkpoints).
    const stealth = stealthLaunchOptions();
    const wanted = resolveChannel(this.opts.browserChannel ?? 'auto', hostChromeAvailable());
    const userDataDir = profileDirFor(this.opts.sessionPath);
    // A force-killed prior run leaves a stale SingletonLock in this profile dir; real Chrome
    // then refuses to launch and silently falls back to bundled Chromium (weaker fingerprint).
    // The cross-process collect lock guarantees no other run is using the profile, so clear it.
    clearStaleSingletonLocks(this.opts.sessionPath);
    // Context options fold into the persistent launch (there is no separate browser).
    // --headless=new (not the old headless shell) is less bot-detectable.
    const launch = {
      headless: this.opts.headless ?? false,
      args: stealth.args,
      ignoreDefaultArgs: stealth.ignoreDefaultArgs,
      slowMo: HUMAN_PACING.slowMoMs, // 300ms before every action — human-like at the CDP level (competitor parity)
      locale: 'ko-KR',
      userAgent: defaultUserAgent(),
      viewport: { width: 1280, height: 800 },
    };
    let usedChannel = wanted;
    try {
      this.context = await chromium.launchPersistentContext(userDataDir, { channel: wanted, ...launch });
    } catch (err) {
      // Host Chrome missing/unlaunchable → fall back to the bundled Chromium the app
      // always ships, so a channel choice never breaks collection.
      if (wanted === 'chrome') {
        console.warn(`[browser] channel 'chrome' failed (${err instanceof Error ? err.message : String(err)}); falling back to bundled chromium`);
        usedChannel = 'chromium';
        this.context = await chromium.launchPersistentContext(userDataDir, { channel: 'chromium', ...launch });
      } else {
        throw err;
      }
    }
    this.browser = this.context.browser(); // may be null for a persistent context; stop() closes the context
    // Diagnostic: log WHICH engine actually launched — real Google Chrome (channel:'chrome',
    // strong anti-detection fingerprint) vs bundled Chromium (weaker, more bot-detectable).
    // Without this, a successful run gives no signal about the engine, so a user silently
    // stuck on bundled Chromium looks the same in the log as one on real Chrome. Best-effort.
    try {
      const ver = this.browser?.version();
      console.log(
        `[browser] launched: ${usedChannel === 'chrome' ? 'real Google Chrome' : 'bundled Chromium'}` +
          ` (channel=${usedChannel}${ver ? `, v${ver}` : ''}, headless=${launch.headless})`,
      );
    } catch {
      /* version() is best-effort — never break start() over a diagnostic line */
    }
    // Belt-and-suspenders: strip navigator.webdriver at document start in every page.
    await this.context.addInitScript(STEALTH_INIT_SCRIPT);
    // Migration bridge: a brand-new persistent profile has no cookies. If we still have
    // a saved storageState (from before R3, or a session refreshed elsewhere), seed its
    // cookies so existing users stay logged in without a forced re-login. Going forward
    // the profile persists its own identity and this is a no-op.
    await this.seedProfileFromStorageState();
  }

  /** Seed the fresh persistent profile from a saved storageState when the profile has
   *  no auth cookie yet — keeps existing users logged in across the R3 upgrade. */
  private async seedProfileFromStorageState(): Promise<void> {
    try {
      if (await this.hasAuthCookie()) return; // profile already warm
      const state = loadSession(this.opts.sessionPath);
      const cookies = state?.cookies;
      if (Array.isArray(cookies) && cookies.length > 0) {
        await this.ctx().addCookies(cookies as Parameters<BrowserContext['addCookies']>[0]);
        console.log('[session] seeded persistent profile from saved storageState (R3 migration)');
      }
    } catch (e) {
      console.warn(`[session] profile seed skipped: ${e instanceof Error ? e.message : String(e)}`);
    }
  }

  /**
   * Open the IG login page and wait (up to 5 min) for the user to finish logging in.
   * Persists the resulting session so subsequent runs reuse it.
   */
  async login(): Promise<void> {
    const page = await this.ctx().newPage();
    try {
      await page.goto(`${IG_BASE}/accounts/login/`, { waitUntil: 'domcontentloaded' });
      // Success = the `sessionid` auth cookie appears. The URL merely leaving
      // /accounts/login is NOT enough: IG serves "/" to logged-out users and
      // redirects challenges/onetap elsewhere, so a URL check persists garbage
      // (a session with no sessionid). Wait for the real auth signal instead.
      const ok = await this.waitForAuthCookie(5 * 60_000);
      if (!ok) throw new Error('로그인이 완료되지 않았어요 (인스타그램 세션 쿠키가 발급되지 않음). 다시 시도해 주세요.');
      await this.persist();
    } finally {
      await page.close();
    }
  }

  /**
   * Reels discovery (competitor borrow): open instagram.com/reels/ in this (headed)
   * browser with an overlay that lists authors the user scrolls past who aren't yet
   * monitored, each with a 추가 button. `handlers.exists` tells the overlay whether a
   * username is already tracked; `handlers.add` adds it. Resolves when the user closes
   * the page/window. Construct the collector with headless:false for this.
   */
  async discover(handlers: {
    exists: (username: string) => boolean | Promise<boolean>;
    add: (username: string) => void | Promise<void>;
  }): Promise<void> {
    const page = await this.ctx().newPage();
    await page.exposeBinding('__imCheckExists', async (_src, username: string) => handlers.exists(username));
    await page.exposeBinding('__imAddAccount', async (_src, username: string) => {
      await handlers.add(username);
      return true;
    });
    await page.addInitScript(DISCOVERY_OVERLAY_SCRIPT);
    await page.goto(`${IG_BASE}/reels/`, { waitUntil: 'domcontentloaded' });
    // Keep open until the user closes the tab or the window.
    await new Promise<void>((resolve) => {
      let done = false;
      const finish = (): void => { if (!done) { done = true; resolve(); } };
      page.on('close', finish);
      this.context?.on('close', finish);
    });
  }

  private async hasAuthCookie(): Promise<boolean> {
    const cookies = await this.ctx().cookies();
    return cookies.some((c) => c.name === 'sessionid' && !!c.value);
  }

  private async waitForAuthCookie(timeoutMs: number): Promise<boolean> {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      if (await this.hasAuthCookie()) return true;
      await sleep(500);
    }
    return false;
  }

  /**
   * Identify the logged-in user from the active session: read the `ds_user_id`
   * cookie and resolve it to a username/name/avatar via the IG user-info API.
   * Returns null on any failure (no cookie, API error) so callers degrade
   * gracefully — login still counts as successful without it.
   */
  async whoami(): Promise<IgAccount | null> {
    const cookies = await this.ctx().cookies();
    const id = cookies.find((c) => c.name === 'ds_user_id')?.value;
    if (!id) return null;
    try {
      const res = await this.ctx().request.get(`${IG_BASE}/api/v1/users/${id}/info/`, {
        headers: {
          'x-ig-app-id': IG_WEB_APP_ID,
          'x-requested-with': 'XMLHttpRequest',
          referer: `${IG_BASE}/`,
        },
        timeout: this.opts.navigationTimeoutMs ?? 30_000,
      });
      if (!res.ok()) return null;
      const json = (await res.json()) as {
        user?: { username?: string; full_name?: string; profile_pic_url?: string };
      };
      const u = json.user;
      if (!u?.username) return null;
      const avatarUrl = u.profile_pic_url ? await this.embedAvatar(u.profile_pic_url) : null;
      return { username: u.username, fullName: u.full_name ?? null, avatarUrl };
    } catch {
      return null;
    }
  }

  /**
   * Fetch the IG CDN avatar via the authenticated request context and return it
   * as a `data:` URL. The signed CDN URL expires within hours, so we embed the
   * bytes now (while fresh) rather than persisting a link that will 403 later.
   * Falls back to the raw URL if the fetch fails — best effort, never throws.
   */
  private async embedAvatar(url: string): Promise<string | null> {
    try {
      const res = await this.ctx().request.get(url, {
        headers: { referer: `${IG_BASE}/` },
        timeout: this.opts.navigationTimeoutMs ?? 15_000,
      });
      if (!res.ok()) return url;
      const body = await res.body();
      return toAvatarDataUrl(new Uint8Array(body), res.headers()['content-type'] ?? null) ?? url;
    } catch {
      return url;
    }
  }

  async isLoggedIn(): Promise<boolean> {
    // Load IG so any session cookies are active, then check for the `sessionid`
    // auth cookie. (A URL check false-positives: IG serves "/" to everyone.)
    const page = await this.ctx().newPage();
    try {
      await page.goto(`${IG_BASE}/`, { waitUntil: 'domcontentloaded' });
      return await this.hasAuthCookie();
    } finally {
      await page.close();
    }
  }

  /**
   * Server-side session validity (stronger than the local cookie check in isLoggedIn):
   * a sessionid cookie can persist after IG invalidates it server-side. Returns
   * true on a 200 user resolve, false on missing cookie / 401 / 403, null on an
   * unverifiable error (so callers don't flip the flag on a network blip).
   */
  async validateSession(): Promise<boolean | null> {
    const cookies = await this.ctx().cookies();
    const id = cookies.find((c) => c.name === 'ds_user_id')?.value;
    if (!id) return false;
    try {
      const res = await this.ctx().request.get(`${IG_BASE}/api/v1/users/${id}/info/`, {
        headers: { 'x-ig-app-id': IG_WEB_APP_ID, 'x-requested-with': 'XMLHttpRequest', referer: `${IG_BASE}/` },
        timeout: this.opts.navigationTimeoutMs ?? 30_000,
      });
      if (res.ok()) return true;
      if (res.status() === 401 || res.status() === 403) return false;
      return null;
    } catch {
      return null;
    }
  }

  /**
   * Collect a profile. mode 'deep' (default) scrolls the logged-in timeline back
   * collectDays (once per channel, for history backfill). mode 'light' (the daily
   * path) does a shallow logged-in scrape for just the recent posts. Both run on
   * the logged-in session — web_profile_info no longer serves posts (anon 401 /
   * logged-in empty), so there is no login-free path anymore.
   */
  async collectProfile(username: string, opts?: { mode?: 'deep' | 'light'; signal?: AbortSignal }): Promise<ScrapedProfile> {
    const signal = opts?.signal;
    const profile = (opts?.mode ?? 'deep') === 'light' ? await this.collectLight(username, signal) : await this.collectDeep(username, signal);
    await this.fetchReelsTabViews(username, profile.posts, signal);
    return profile;
  }

  /**
   * Enrich reel view counts (조회수) PASSIVELY: visit /{user}/reels/ and read the
   * play_count the reels-tab GraphQL (PolarisProfileReelsTabContentQuery) already
   * returns — instead of forging a per-reel /api/v1/media/{pk}/info/ request. The
   * forged path was a confirmed rate-limit/ban signal (prod: "filled 0/N"); the
   * competitor teardown showed views come organically from the reels tab.
   *
   * Best-effort: any failure leaves viewCount as-is and never breaks collection.
   * Only visits when there are recent reels worth enriching (skips the extra page
   * load otherwise). Per-run guard disables it if visits keep seeing no clips data.
   */
  private reelsTabVisits = 0;
  private reelsTabHits = 0;
  private reelsTabDisabled = false;

  private async fetchReelsTabViews(username: string, posts: ScrapedPost[], signal?: AbortSignal): Promise<void> {
    if (this.reelsTabDisabled || signal?.aborted) return; // skip the extra page on a Stop
    const windowDays = this.opts.viewWindowDays ?? 3;
    const targets = selectReelsForViewFetch(posts, new Date().toISOString(), windowDays);
    if (targets.length === 0) return; // no recent reels → skip the extra page load
    const wanted = new Set(targets.map((p) => p.shortcode));

    const page = await this.ctx().newPage();
    const timeout = this.opts.navigationTimeoutMs ?? 30_000;
    const views = new Map<string, number>();
    const onResponse = async (res: Response): Promise<void> => {
      if (!res.url().includes('/graphql/query')) return;
      let text: string;
      try {
        text = await res.text();
      } catch {
        return;
      }
      if (!text.includes(REELS_CLIPS_KEY)) return;
      try {
        for (const r of parseReelsClipsConnection(JSON.parse(text))) views.set(r.shortcode, r.viewCount);
      } catch {
        /* ignore malformed bodies */
      }
    };
    page.on('response', onResponse);
    try {
      await page.goto(`${IG_BASE}/${encodeURIComponent(username)}/reels/`, { waitUntil: 'domcontentloaded', timeout });
      await sleep(randInt(HUMAN_PACING.profileSettleMinMs, HUMAN_PACING.profileSettleMaxMs));
      // Wait for the first clips batch, then a few shallow human-paced scrolls to load recent reels.
      const start = Date.now();
      while (views.size === 0 && Date.now() - start < timeout) await page.waitForTimeout(400);
      for (let i = 0; i < 3; i += 1) {
        if (signal?.aborted) break; // user Stop mid reels-tab scrape
        if ([...wanted].every((sc) => views.has(sc))) break; // got every recent reel we care about
        await this.humanScroll(page);
      }
    } catch (e) {
      console.warn(`[views] reels-tab visit failed for ${username}: ${e instanceof Error ? e.message : String(e)}`);
    } finally {
      page.off('response', onResponse);
      await page.close();
    }

    let filled = 0;
    for (const p of posts) {
      const v = views.get(p.shortcode);
      if (v != null) {
        p.viewCount = v;
        filled += 1;
      }
    }
    console.log(`[views] reels-tab filled ${filled}/${targets.length} view counts (passive, ≤${windowDays}d old)`);
    this.reelsTabVisits += 1;
    if (views.size > 0) this.reelsTabHits += 1;
    if (this.reelsTabVisits >= 5 && this.reelsTabHits === 0) {
      this.reelsTabDisabled = true;
      console.warn(`[views] disabling reels-tab view capture for this run — ${this.reelsTabVisits} visits, 0 clips connections seen`);
    }
  }

  // Per-run circuit breaker for web_profile_info. On a flagged account IG 429s it on ~100%
  // of accounts, yet it only yields follower/post counts (posts come from the timeline
  // scrape) — so calling it 500×/day delivers nothing and is a textbook ban signal. After
  // several consecutive failures, skip it for the rest of the run; any success re-arms it,
  // so healthy accounts never trip it.
  private metaFailStreak = 0;
  private metaDisabled = false;

  private async metaOrNull(username: string): Promise<ScrapedProfile | null> {
    if (this.metaDisabled) return null;
    try {
      const meta = await this.fetchProfileMeta(username);
      this.metaFailStreak = 0;
      return meta;
    } catch (e) {
      this.metaFailStreak += 1;
      if (this.metaFailStreak >= 5) {
        this.metaDisabled = true;
        console.warn(`[meta] disabling web_profile_info for this run after ${this.metaFailStreak} consecutive failures — timeline-only`);
      } else {
        console.warn(`[meta] ${username}: web_profile_info failed: ${e instanceof Error ? e.message : String(e)}`);
      }
      return null;
    }
  }

  private async collectLight(username: string, signal?: AbortSignal): Promise<ScrapedProfile> {
    // web_profile_info no longer serves posts (IG anti-scraping), so the daily path runs on
    // the logged-in session: meta gives follower/post counts (best-effort, circuit-broken),
    // posts come from a SHALLOW timeline scrape.
    const meta = await this.metaOrNull(username);
    if (meta && meta.posts.length > 0) return { ...meta, posts: sortByUploadedAtDesc(meta.posts) };
    // Private account we don't follow: meta resolves (name/counts) but serves no posts. Skip
    // the pointless timeline navigation and surface a typed, channel-property error.
    if (meta && meta.isPrivate) throw new IgPrivateAccountError(`@${username} 비공개 계정이라 게시물을 수집할 수 없어요 — 공개로 전환되면 자동으로 다시 수집해요.`);
    const { posts, rawCount } = await this.fetchTimelinePosts(username, { maxScrolls: 2 }, signal);
    // rawCount===0 here means the timeline query MATCHED but served zero posts — a genuine
    // 302/soft-block/not-found/private shell would have thrown inside fetchTimelinePosts. So
    // it's an empty/quiet channel, NOT a rate limit; return success with 0 posts. (The old
    // IgRateLimitError here re-polluted the RATE_LIMIT_ABORT streak on meta-failed fleets.)
    if (rawCount === 0) {
      return meta
        ? { ...meta, posts: [] }
        : { username, displayName: null, followerCount: 0, postCount: 0, posts: [], metaMissing: true };
    }
    return meta
      ? { ...meta, posts: sortByUploadedAtDesc(posts) }
      : { username, displayName: null, followerCount: 0, postCount: posts.length, posts: sortByUploadedAtDesc(posts), metaMissing: true };
  }

  private async collectDeep(username: string, signal?: AbortSignal): Promise<ScrapedProfile> {
    const meta = await this.metaOrNull(username);
    if (meta && meta.posts.length > 0) return meta;
    if (meta && meta.isPrivate) throw new IgPrivateAccountError(`@${username} 비공개 계정이라 게시물을 수집할 수 없어요 — 공개로 전환되면 자동으로 다시 수집해요.`);
    const { posts } = await this.fetchTimelinePosts(username, undefined, signal);
    console.log(`[collect] ${username}: timeline scrape -> ${posts.length} posts`);
    return meta
      ? { ...meta, posts }
      : { username, displayName: null, followerCount: 0, postCount: posts.length, posts, metaMissing: true };
  }

  private async fetchProfileMeta(username: string): Promise<ScrapedProfile> {
    const res = await this.ctx().request.get(webProfileInfoUrl(username), {
      headers: {
        'x-ig-app-id': IG_WEB_APP_ID,
        'x-requested-with': 'XMLHttpRequest',
        referer: `${IG_BASE}/${encodeURIComponent(username)}/`,
      },
      timeout: this.opts.navigationTimeoutMs ?? 30_000,
    });
    if (!res.ok()) throw webProfileError(res.status(), username);
    return parseWebProfileInfo(await res.json());
  }

  /**
   * One human-like scroll, matched to the competitor's scroll_page(): 50% End key
   * vs 50% scrollBy 4000-6000px, then a 5-10s "reading" wait, plus a 10% chance of a
   * 10-20s long pause. Slow, irregular cadence — not raw speed — is the throttle
   * avoidance mechanism.
   */
  private async humanScroll(page: Page): Promise<void> {
    const action = pickScroll(Math.random(), Math.random());
    if (action.mode === 'end') await page.keyboard.press('End');
    else await page.mouse.wheel(0, action.distance);
    await sleep(randInt(HUMAN_PACING.scrollWaitMinMs, HUMAN_PACING.scrollWaitMaxMs));
    if (Math.random() < HUMAN_PACING.longPauseProb) {
      await sleep(randInt(HUMAN_PACING.longPauseMinMs, HUMAN_PACING.longPauseMaxMs));
    }
  }

  /**
   * Load the profile page and scroll, accumulating every GraphQL timeline
   * response, until: posts older than collectDays are reached, no new posts
   * arrive for `noNewDataLimit` scrolls, or maxScrolls is hit.
   */
  private async fetchTimelinePosts(
    username: string,
    override?: { maxScrolls?: number },
    signal?: AbortSignal,
  ): Promise<{ posts: ScrapedPost[]; rawCount: number }> {
    const page = await this.ctx().newPage();
    const timeout = this.opts.navigationTimeoutMs ?? 30_000;
    // Scroll depth scales with the look-back window — the `hasPostBefore` date
    // break below is the real limiter (stops once posts older than collectDays
    // appear); this cap is just a safety net. Shorter windows scroll less (faster,
    // lower ban risk), longer windows are allowed to go deeper.
    const maxScrolls =
      override?.maxScrolls ?? this.opts.maxScrolls ?? (this.opts.collectDays ? Math.min(60, Math.ceil(this.opts.collectDays / 2)) : 22);
    const sinceIso = this.opts.collectDays
      ? new Date(Date.now() - this.opts.collectDays * 86_400_000).toISOString()
      : null;

    const collected: ScrapedPost[] = [];
    let graphqlSeen = 0;
    let timelineMatched = 0;
    let blocked = false;
    let notFound = false;
    let privatePage = false;
    let sawHomeRedirect = false;
    let sawLoginRedirect = false;
    // Diagnostic: one compact descriptor per graphql response (friendly-name:status:size)
    // so a timeline MISS can be explained — which queries fired, did the timeline query
    // even run, was the body an error/empty. Dumped only when timelineMatched===0.
    const graphqlLog: string[] = [];
    const onResponse = async (res: Response): Promise<void> => {
      if (!res.url().includes('/graphql/query')) return;
      graphqlSeen += 1;
      const st = res.status();
      const fname =
        res.request().headers()['x-fb-friendly-name'] ?? res.request().headers()['x-root-field-name'] ?? '?';
      if (st >= 300 && st < 400) {
        const loc = res.headers()['location'] ?? '';
        graphqlLog.push(`${fname}:${st}→${/\/accounts\/login/.test(loc) ? 'login' : 'home'}`);
        if (/\/accounts\/login/.test(loc)) sawLoginRedirect = true;
        else sawHomeRedirect = true; // 302 → instagram.com/ = throttle, not logout
        return;
      }
      let text: string;
      try {
        text = await res.text();
      } catch {
        graphqlLog.push(`${fname}:${st}:body-err`);
        return;
      }
      const isTimeline = text.includes(TIMELINE_KEY);
      graphqlLog.push(`${fname}:${st}:${text.length}b${isTimeline ? ':TL' : ''}`);
      if (!isTimeline) return;
      timelineMatched += 1;
      try {
        collected.push(...parseTimelineConnection(JSON.parse(text)));
      } catch {
        /* ignore malformed bodies */
      }
    };
    page.on('response', onResponse);

    try {
      await page.goto(`${IG_BASE}/${encodeURIComponent(username)}/`, { waitUntil: 'domcontentloaded' });
      console.log(`[timeline] ${username}: landed on ${page.url()}`);
      // Settle like a human glancing at the page before scrolling (competitor PROFILE_WAIT 5-10s).
      await sleep(randInt(HUMAN_PACING.profileSettleMinMs, HUMAN_PACING.profileSettleMaxMs));
      // wait for the first timeline batch
      const start = Date.now();
      while (collected.length === 0 && Date.now() - start < timeout) {
        await page.waitForTimeout(400);
      }
      let stagnant = 0;
      for (let i = 0; i < maxScrolls; i += 1) {
        if (signal?.aborted) break; // user Stop mid-scrape → return the posts gathered so far (competitor pattern)
        const before = dedupePostsByShortcode(collected).length;
        await this.humanScroll(page);
        const current = dedupePostsByShortcode(collected);
        if (sinceIso && hasPostBefore(current, sinceIso)) break;
        if (current.length === before) {
          stagnant += 1;
          if (stagnant >= HUMAN_PACING.noNewDataLimit) break;
        } else {
          stagnant = 0;
        }
      }
      await sleep(randInt(600, 1400));
      // Checkpoint/login-wall detection via Playwright's node-side API (no DOM lib).
      const url = page.url();
      const hasLoginForm = await page
        .locator('input[name="username"], input[name="password"]')
        .count()
        .catch(() => 0);
      const bodyText = await page.locator('body').innerText({ timeout: 2000 }).catch(() => '');
      blocked =
        url.includes('/accounts/login') ||
        url.includes('/challenge') ||
        hasLoginForm > 0 ||
        /please wait a few minutes|try again later/i.test(bodyText);
      notFound = isNotFoundPage(bodyText);
      privatePage = isPrivatePage(bodyText);
      console.log(`[timeline] ${username}: graphql=${graphqlSeen}, timelineMatches=${timelineMatched}, rawPosts=${collected.length}, blocked=${blocked}`);
      // On a timeline MISS, dump the evidence needed to tell throttle/soft-block apart
      // from a genuine IG format change: which graphql queries fired, whether a reCAPTCHA/
      // bot-challenge was served, and a short snippet of what IG actually rendered.
      if (timelineMatched === 0) {
        const recaptchaCount = await page
          .locator('iframe[src*="recaptcha"], iframe[title*="captcha" i], [id*="recaptcha" i]')
          .count()
          .catch(() => 0);
        const soft = detectSoftBlock({ bodyText, recaptchaCount });
        const snippet = bodyText.replace(/\s+/g, ' ').trim().slice(0, 200);
        console.warn(
          `[timeline-diag] ${username}: no timeline. graphql=[${graphqlLog.join(', ') || 'none'}] ` +
            `softblock=${soft.reason} url=${url} bodyLen=${bodyText.length} snippet="${snippet}"`,
        );
      }
    } finally {
      page.off('response', onResponse);
      await page.close();
    }

    // Loud failure (no silent zeros). Classify so the UI/worker can react: a
    // 302→home is a throttle (cooldown, no re-login); a login wall is a session
    // problem (re-login); anything else is a generic format error.
    const failure = classifyTimelineFailure({
      timelineMatched,
      sawHomeRedirect,
      sawLoginRedirect,
      loginWall: blocked,
      notFoundPage: notFound,
      privatePage,
      graphqlSeen,
      username,
    });
    if (failure) throw failure;

    const posts = dedupePostsByShortcode(collected);
    // rawCount = posts actually served by IG, pre window-filter — lets callers tell
    // "channel is stale" (raw>0, windowed 0) apart from "IG served nothing" (raw 0).
    return { posts: sinceIso ? filterSince(posts, sinceIso) : posts, rawCount: posts.length };
  }

  private async persist(): Promise<void> {
    saveSession(this.opts.sessionPath, await this.ctx().storageState());
  }

  private ctx(): BrowserContext {
    if (!this.context) throw new Error('Collector not started — call start() first');
    return this.context;
  }

  async stop(): Promise<void> {
    await this.context?.close();
    await this.browser?.close();
    this.context = null;
    this.browser = null;
  }
}
