/**
 * E2E tests for core browser commands (bilibili, zhihu, v2ex, IMDb).
 * These launch a headless Chromium in CI.
 *
 * NOTE: Some sites may block headless browsers with bot detection.
 * Tests are wrapped with tryBrowserCommand() which allows graceful failure.
 */

import { describe, it, expect } from 'vitest';
import { runCli, parseJsonOutput, type CliResult } from './helpers.js';

const BROWSER_UNAVAILABLE_ENV = { OPENCLI_BROWSER_CONNECT_TIMEOUT: '5' };

async function tryBrowserCommand(args: string[]): Promise<any[] | null> {
  const { stdout, code } = await runCli(args, { timeout: 60_000, env: BROWSER_UNAVAILABLE_ENV });
  if (code !== 0) return null;
  try {
    const data = parseJsonOutput(stdout);
    return Array.isArray(data) ? data : null;
  } catch {
    return null;
  }
}

function expectDataOrSkip(data: any[] | null, label: string) {
  if (data === null || data.length === 0) {
    console.warn(`${label}: skipped — no data returned (likely bot detection or geo-blocking)`);
    return;
  }
  expect(data.length).toBeGreaterThanOrEqual(1);
}

function isImdbChallenge(result: CliResult): boolean {
  const text = `${result.stderr}\n${result.stdout}`;
  return /IMDb blocked this request|Robot Check|Are you a robot|verify that you are human|captcha/i.test(text);
}

function isBrowserBridgeUnavailable(result: CliResult): boolean {
  const text = `${result.stderr}\n${result.stdout}`;
  return /Browser Bridge.*not connected|Extension.*not connected|Browser profile .*not connected/i.test(text);
}

function isBaiduChallengeText(text: string): boolean {
  return /百度安全验证|安全验证|请完成验证|captcha/i.test(text);
}

function isBaiduChallenge(result: CliResult): boolean {
  const text = `${result.stderr}\n${result.stdout}`;
  return isBaiduChallengeText(text);
}

function isTransientBrowserDetach(result: CliResult): boolean {
  const text = `${result.stderr}\n${result.stdout}`;
  return /Detached while handling command|No tab with id|Debugger is not attached to the tab/i.test(text);
}

async function runCliWithTransientRetry(args: string[], timeout: number): Promise<CliResult> {
  let result = await runCli(args, { timeout, env: BROWSER_UNAVAILABLE_ENV });
  if (result.code !== 0 && isTransientBrowserDetach(result)) {
    result = await runCli(args, { timeout, env: BROWSER_UNAVAILABLE_ENV });
  }
  return result;
}

async function runJsonCliOrThrow(args: string[], label: string, timeout: number, opts: { retryTransient?: boolean } = {}): Promise<any[] | null> {
  const result = opts.retryTransient
    ? await runCliWithTransientRetry(args, timeout)
    : await runCli(args, { timeout, env: BROWSER_UNAVAILABLE_ENV });
  if (result.code !== 0) {
    if (isBrowserBridgeUnavailable(result)) {
      console.warn(`${label}: skipped — Browser Bridge extension is unavailable in this environment`);
      return null;
    }
    if (isBaiduChallenge(result)) {
      console.warn(`${label}: skipped — Baidu challenge page detected`);
      return null;
    }
    throw new Error(`${label} failed:\n${result.stderr || result.stdout}`);
  }

  const data = parseJsonOutput(result.stdout);
  if (!Array.isArray(data)) {
    throw new Error(`${label} returned non-array JSON:\n${result.stdout.slice(0, 500)}`);
  }
  return data;
}

function normalizeTiebaTitle(value: string): string {
  return value.replace(/\s+/g, ' ').trim();
}

function hasTiebaMainPost(data: any[] | null): boolean {
  return Array.isArray(data) && data.some((item: any) => Number(item.floor) === 1);
}

function expectNonEmptyDataOrSkipEnv(data: any[] | null, label: string): data is any[] {
  if (data === null) {
    console.warn(`${label}: skipped — environment is unavailable for browser assertions`);
    return false;
  }
  expect(data.length).toBeGreaterThanOrEqual(1);
  return true;
}

function countTiebaReplies(data: any[] | null): number {
  if (!Array.isArray(data)) return 0;
  return data.filter((item: any) => Number(item.floor) > 1).length;
}

function maxTiebaFloor(data: any[] | null): number {
  if (!Array.isArray(data) || !data.length) return 0;
  return Math.max(...data.map((item: any) => Number(item.floor) || 0));
}

function getTiebaReplyFloors(data: any[] | null): number[] {
  if (!Array.isArray(data)) return [];
  return data
    .map((item: any) => Number(item.floor) || 0)
    .filter((floor) => floor > 1);
}

function countTiebaReplyFloorOverlap(left: any[] | null, right: any[] | null): number {
  const rightFloors = new Set(getTiebaReplyFloors(right));
  return getTiebaReplyFloors(left).filter((floor) => rightFloors.has(floor)).length;
}

function pickTiebaReadCandidates(
  posts: any[] | null,
  minReplies: number,
): Array<{ threadId: string; title: string; replies: number }> {
  if (!Array.isArray(posts) || !posts.length) return [];

  return [...posts]
    .filter((item: any) => item?.id)
    .map((item: any) => ({
      threadId: String(item.id || '').trim(),
      title: normalizeTiebaTitle(String(item.title || '')),
      replies: Number(item.replies) || 0,
    }))
    .filter((item) => item.threadId && item.title && item.replies >= minReplies)
    .sort((left, right) => right.replies - left.replies);
}

/**
 * Pick a live thread that actually exposes enough visible replies for the read assertions.
 */
async function getTiebaReadCandidateOrSkip(
  label: string,
  options: { minRepliesOnPage1?: number; requirePage2?: boolean } = {},
): Promise<{ threadId: string; title: string; replies: number } | null> {
  const minRepliesOnPage1 = Math.max(1, Number(options.minRepliesOnPage1 || 1));
  const requirePage2 = options.requirePage2 === true;
  const posts = await runJsonCliOrThrow(['tieba', 'posts', '李毅', '--limit', '10', '-f', 'json'], `${label} setup`, 90_000, {
    retryTransient: true,
  });
  if (posts === null) {
    return null;
  }
  if (!Array.isArray(posts) || !posts.length) {
    console.warn(`${label}: skipped — could not resolve Tieba posts for setup`);
    return null;
  }

  const minReplies = requirePage2 ? Math.max(minRepliesOnPage1, 50) : minRepliesOnPage1;
  const candidates = pickTiebaReadCandidates(posts, minReplies).slice(0, 5);
  if (!candidates.length) {
    console.warn(`${label}: skipped — could not find a Tieba thread with enough replies from posts metadata`);
    return null;
  }

  for (const candidate of candidates) {
    const page1Preview = await runJsonCliOrThrow(
      ['tieba', 'read', candidate.threadId, '--page', '1', '--limit', String(Math.max(minRepliesOnPage1, 2)), '-f', 'json'],
      `${label} preview page 1`,
      90_000,
      { retryTransient: true },
    );
    if (page1Preview === null) {
      return null;
    }
    if (!hasTiebaMainPost(page1Preview) || countTiebaReplies(page1Preview) < minRepliesOnPage1) {
      continue;
    }

    if (requirePage2) {
      const page2Preview = await runJsonCliOrThrow(
        ['tieba', 'read', candidate.threadId, '--page', '2', '--limit', '1', '-f', 'json'],
        `${label} preview page 2`,
        90_000,
        { retryTransient: true },
      );
      if (page2Preview === null) {
        return null;
      }
      if (hasTiebaMainPost(page2Preview) || countTiebaReplies(page2Preview) < 1) {
        continue;
      }
    }

    return candidate;
  }

  console.warn(`${label}: skipped — could not find a Tieba thread with enough visible replies`);
  return null;
}

describe('tieba e2e helper guards', () => {
  it('does not treat generic empty-result errors as a Baidu challenge', () => {
    expect(isBaiduChallengeText('tieba posts returned no data\n→ The page structure may have changed — this adapter may be outdated.')).toBe(false);
  });

  it('still recognizes actual Baidu challenge text', () => {
    expect(isBaiduChallengeText('百度安全验证，请完成验证后继续')).toBe(true);
  });

  it('counts partial overlap between read pages', () => {
    expect(countTiebaReplyFloorOverlap(
      [{ floor: 1 }, { floor: 23 }, { floor: 27 }, { floor: 28 }, { floor: 29 }, { floor: 30 }],
      [{ floor: 27 }, { floor: 28 }, { floor: 31 }],
    )).toBe(2);
  });

  it('picks read fixtures from posts metadata in descending reply order', () => {
    expect(pickTiebaReadCandidates([
      { id: '1', title: '普通帖', replies: 2 },
      { id: '2', title: '大帖', replies: 120 },
      { id: '', title: '无效帖', replies: 999 },
    ], 50)).toEqual([{
      threadId: '2',
      title: '大帖',
      replies: 120,
    }]);

    expect(pickTiebaReadCandidates([{ id: '1', title: '普通帖', replies: 2 }], 50)).toEqual([]);
  });
});

async function expectImdbDataOrChallengeSkip(args: string[], label: string): Promise<any[] | null> {
  const result = await runCli(args, { timeout: 60_000, env: BROWSER_UNAVAILABLE_ENV });
  if (result.code !== 0) {
    if (isImdbChallenge(result)) {
      console.warn(`${label}: skipped — IMDb challenge page detected`);
      return null;
    }
    if (isBrowserBridgeUnavailable(result)) {
      console.warn(`${label}: skipped — Browser Bridge extension is unavailable in this environment`);
      return null;
    }
    throw new Error(`${label} failed:\n${result.stderr || result.stdout}`);
  }

  const data = parseJsonOutput(result.stdout);
  if (!Array.isArray(data)) {
    throw new Error(`${label} returned non-array JSON:\n${result.stdout.slice(0, 500)}`);
  }
  if (data.length === 0) {
    throw new Error(`${label} returned an empty result`);
  }
  return data;
}

describe('browser public-data commands E2E', () => {

  // ── bilibili ──
  it('bilibili hot returns trending videos', async () => {
    const data = await tryBrowserCommand(['bilibili', 'hot', '--limit', '5', '-f', 'json']);
    expectDataOrSkip(data, 'bilibili hot');
    if (data?.length) {
      expect(data[0]).toHaveProperty('title');
    }
  }, 60_000);

  it('bilibili ranking returns ranked videos', async () => {
    const data = await tryBrowserCommand(['bilibili', 'ranking', '--limit', '5', '-f', 'json']);
    expectDataOrSkip(data, 'bilibili ranking');
  }, 60_000);

  it('bilibili search returns results', async () => {
    const data = await tryBrowserCommand(['bilibili', 'search', 'typescript', '--limit', '3', '-f', 'json']);
    expectDataOrSkip(data, 'bilibili search');
  }, 60_000);

  // ── zhihu ──
  it('zhihu hot returns trending questions', async () => {
    const data = await tryBrowserCommand(['zhihu', 'hot', '--limit', '5', '-f', 'json']);
    expectDataOrSkip(data, 'zhihu hot');
    if (data?.length) {
      expect(data[0]).toHaveProperty('title');
    }
  }, 60_000);

  it('zhihu search returns results', async () => {
    const data = await tryBrowserCommand(['zhihu', 'search', 'playwright', '--limit', '3', '-f', 'json']);
    expectDataOrSkip(data, 'zhihu search');
  }, 60_000);

  // ── v2ex ──
  it('v2ex daily returns topics', async () => {
    const data = await tryBrowserCommand(['v2ex', 'daily', '--limit', '3', '-f', 'json']);
    expectDataOrSkip(data, 'v2ex daily');
  }, 60_000);

  // ── tieba ──
  it('tieba hot returns trending topics', async () => {
    const data = await runJsonCliOrThrow(['tieba', 'hot', '--limit', '5', '-f', 'json'], 'tieba hot', 60_000, { retryTransient: true });
    if (expectNonEmptyDataOrSkipEnv(data, 'tieba hot')) {
      expect(data[0]).toHaveProperty('title');
      expect(data[0]).toHaveProperty('discussions');
    }
  }, 60_000);

  it('tieba posts returns forum threads', async () => {
    const data = await runJsonCliOrThrow(['tieba', 'posts', '李毅', '--limit', '20', '-f', 'json'], 'tieba posts', 90_000, { retryTransient: true });
    if (expectNonEmptyDataOrSkipEnv(data, 'tieba posts')) {
      expect(data[0]).toHaveProperty('title');
      expect(String(data[0].id || '')).toMatch(/^\d+$/);
      expect(String(data[0].url || '')).toContain('/p/');
      expect(Number.isFinite(Number(data[0].replies))).toBe(true);
      expect(data.length).toBeLessThanOrEqual(20);
    }
  }, 90_000);

  it('tieba posts page 2 returns a different forum slice', async () => {
    const data1 = await runJsonCliOrThrow(['tieba', 'posts', '李毅', '--page', '1', '--limit', '5', '-f', 'json'], 'tieba posts page 1', 60_000, { retryTransient: true });
    const data2 = await runJsonCliOrThrow(['tieba', 'posts', '李毅', '--page', '2', '--limit', '5', '-f', 'json'], 'tieba posts page 2', 60_000, { retryTransient: true });
    if (expectNonEmptyDataOrSkipEnv(data1, 'tieba posts page 1') && expectNonEmptyDataOrSkipEnv(data2, 'tieba posts page 2')) {
      const ids1 = data1.map((item: any) => String(item.id || '')).filter(Boolean);
      const ids2 = data2.map((item: any) => String(item.id || '')).filter(Boolean);
      const newIds = ids2.filter((id) => !ids1.includes(id));
      expect(newIds.length).toBeGreaterThan(0);
    }
  }, 90_000);

  it('tieba search returns results', async () => {
    const data = await runJsonCliOrThrow(['tieba', 'search', '编程', '--limit', '20', '-f', 'json'], 'tieba search', 90_000, { retryTransient: true });
    if (expectNonEmptyDataOrSkipEnv(data, 'tieba search')) {
      expect(data[0]).toHaveProperty('title');
      expect(String(data[0].id || '')).toMatch(/^\d+$/);
      expect(String(data[0].url || '')).toContain('/p/');
      expect(data.length).toBeLessThanOrEqual(20);
    }
  }, 90_000);

  it('tieba search rejects unsupported pages above 1', async () => {
    const result = await runCli(['tieba', 'search', '编程', '--page', '2', '--limit', '3', '-f', 'json'], {
      timeout: 60_000,
    });
    expect(result.code).toBe(2);
    expect(`${result.stderr}\n${result.stdout}`).toContain('Argument "page" must be one of: 1');
  }, 60_000);

  it('tieba read returns thread content', async () => {
    const fixture = await getTiebaReadCandidateOrSkip('tieba read');
    if (!fixture) {
      return;
    }
    const data = await runJsonCliOrThrow(['tieba', 'read', fixture.threadId, '--limit', '5', '-f', 'json'], 'tieba read', 90_000, { retryTransient: true });
    if (expectNonEmptyDataOrSkipEnv(data, 'tieba read')) {
      expect(data[0]).toHaveProperty('floor');
      expect(data[0]).toHaveProperty('content');
      expect(data.some((item: any) => Number(item.floor) === 1)).toBe(true);
      expect(normalizeTiebaTitle(String(data[0].content || ''))).toContain(fixture.title);
    }
  }, 90_000);

  it('tieba read page 2 omits the main post', async () => {
    const fixture = await getTiebaReadCandidateOrSkip('tieba read page', { requirePage2: true });
    if (!fixture) {
      return;
    }
    const data1 = await runJsonCliOrThrow(['tieba', 'read', fixture.threadId, '--page', '1', '--limit', '5', '-f', 'json'], 'tieba read page 1', 90_000, { retryTransient: true });
    const data2 = await runJsonCliOrThrow(['tieba', 'read', fixture.threadId, '--page', '2', '--limit', '5', '-f', 'json'], 'tieba read page 2', 90_000, { retryTransient: true });
    if (expectNonEmptyDataOrSkipEnv(data1, 'tieba read page 1') && expectNonEmptyDataOrSkipEnv(data2, 'tieba read page 2')) {
      const overlap = countTiebaReplyFloorOverlap(data1, data2);
      expect(normalizeTiebaTitle(String(data1[0].content || ''))).toContain(fixture.title);
      expect(hasTiebaMainPost(data1)).toBe(true);
      expect(hasTiebaMainPost(data2)).toBe(false);
      expect(overlap).toBe(0);
      expect(maxTiebaFloor(data2)).toBeGreaterThan(maxTiebaFloor(data1));
    }
  }, 90_000);

  it('tieba read limit counts replies instead of consuming the main post slot', async () => {
    const fixture = await getTiebaReadCandidateOrSkip('tieba read limit semantics', { minRepliesOnPage1: 2 });
    if (!fixture) {
      return;
    }
    const data = await runJsonCliOrThrow(['tieba', 'read', fixture.threadId, '--page', '1', '--limit', '2', '-f', 'json'], 'tieba read limit semantics', 90_000, { retryTransient: true });
    if (expectNonEmptyDataOrSkipEnv(data, 'tieba read limit semantics')) {
      expect(normalizeTiebaTitle(String(data[0].content || ''))).toContain(fixture.title);
      expect(hasTiebaMainPost(data)).toBe(true);
      expect(countTiebaReplies(data)).toBe(2);
    }
  }, 90_000);

  // ── imdb ──
  it('imdb top returns chart data', async () => {
    const data = await expectImdbDataOrChallengeSkip(['imdb', 'top', '--limit', '3', '-f', 'json'], 'imdb top');
    if (data?.length) {
      expect(data[0]).toHaveProperty('title');
    }
  }, 60_000);

  it('imdb search returns results', async () => {
    const data = await expectImdbDataOrChallengeSkip(['imdb', 'search', 'inception', '--limit', '3', '-f', 'json'], 'imdb search');
    if (data?.length) {
      expect(data[0]).toHaveProperty('id');
      expect(data[0]).toHaveProperty('title');
    }
  }, 60_000);

  it('imdb title returns movie details', async () => {
    const data = await expectImdbDataOrChallengeSkip(['imdb', 'title', 'tt1375666', '-f', 'json'], 'imdb title');
    if (data?.length) {
      expect(data[0]).toHaveProperty('field');
      expect(data[0]).toHaveProperty('value');
    }
  }, 60_000);
});
