import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ToolContext } from './core.js';
import { executeTool } from './youtube.js';

const ctx: ToolContext = {
  workspacePath: '/tmp/maestro-youtube-test',
  editAllowed: false,
};

const INNERTUBE_PLAYER_PREFIX = 'https://www.youtube.com/youtubei/v1/player';
const INNERTUBE_SEARCH_PREFIX = 'https://www.youtube.com/youtubei/v1/search';
const WATCH_PAGE_PREFIX = 'https://www.youtube.com/watch?v=';
const RESULTS_PAGE_PREFIX = 'https://www.youtube.com/results?search_query=';

function jsonResponse(obj: unknown, status = 200): Response {
  return new Response(JSON.stringify(obj), {
    status,
    headers: { 'Content-Type': 'application/json' },
  });
}

function stubFetch(impl: (url: string, init?: RequestInit) => Response | Promise<Response>) {
  const mock = vi.fn(async (input: unknown, init?: RequestInit) => impl(String(input), init));
  vi.stubGlobal('fetch', mock);
  return mock;
}

function makePlayerResponse(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    videoDetails: { title: 'Test Video', lengthSeconds: '125' },
    captions: {
      playerCaptionsTracklistRenderer: {
        captionTracks: [
          { languageCode: 'ja', name: { simpleText: '日本語' }, baseUrl: 'https://yt.example/timedtext?lang=ja' },
          { languageCode: 'en', name: { simpleText: 'English' }, baseUrl: 'https://yt.example/timedtext?lang=en' },
        ],
      },
    },
    ...overrides,
  };
}

const XML_FORMAT_1 = [
  '<?xml version="1.0" encoding="utf-8"?><timedtext><body>',
  '<p t="0" d="1500"><s>Hello </s><s>&amp; world</s></p>',
  '<p t="61000" d="2000">二行目 &#x3042;</p>',
  '</body></timedtext>',
].join('');

const XML_FORMAT_2 = '<transcript><text start="1.5" dur="2.0">Fallback line</text></transcript>';

interface SearchVideoFixture {
  id: string;
  title: string;
  channel?: string;
  views?: string;
  published?: string;
  length?: string;
  desc?: string;
}

function makeSearchData(videos: SearchVideoFixture[]): Record<string, unknown> {
  return {
    contents: {
      twoColumnSearchResultsRenderer: {
        primaryContents: {
          sectionListRenderer: {
            contents: [
              {
                itemSectionRenderer: {
                  contents: videos.map((v) => ({
                    videoRenderer: {
                      videoId: v.id,
                      title: { runs: [{ text: v.title }] },
                      ownerText: { runs: [{ text: v.channel ?? 'Chan' }] },
                      ...(v.views ? { viewCountText: { simpleText: v.views } } : {}),
                      ...(v.published ? { publishedTimeText: { simpleText: v.published } } : {}),
                      ...(v.length ? { lengthText: { simpleText: v.length } } : {}),
                      ...(v.desc
                        ? { detailedMetadataSnippets: [{ snippetText: { runs: [{ text: v.desc }] } }] }
                        : {}),
                    },
                  })),
                },
              },
            ],
          },
        },
      },
    },
  };
}

afterEach(() => {
  vi.unstubAllGlobals();
  vi.restoreAllMocks();
});

describe('youtube executeTool dispatch', () => {
  it('returns null for unknown tool names', async () => {
    const result = await executeTool('NotATool', {}, ctx);
    expect(result).toBeNull();
  });
});

describe('GetYouTubeTranscript', () => {
  it('rejects input that contains no extractable video id', async () => {
    const mock = stubFetch(() => {
      throw new Error('should not fetch');
    });
    const result = await executeTool('GetYouTubeTranscript', { url: 'not a youtube link' }, ctx);
    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('YouTube 動画 ID を抽出できません');
    expect(mock).not.toHaveBeenCalled();
  });

  it('fetches transcript via InnerTube and formats timestamped segments', async () => {
    const mock = stubFetch((url) => {
      if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
      if (url.startsWith('https://yt.example/timedtext')) return new Response(XML_FORMAT_1);
      throw new Error(`unexpected fetch: ${url}`);
    });

    const result = await executeTool(
      'GetYouTubeTranscript',
      { url: 'https://www.youtube.com/watch?v=abcdefghijk' },
      ctx,
    );

    expect(result?.isError).toBe(false);
    expect(result?.output).toContain('# Test Video');
    expect(result?.output).toContain('URL: https://www.youtube.com/watch?v=abcdefghijk');
    expect(result?.output).toContain('動画時間: 2:05');
    expect(result?.output).toContain('言語: ja');
    expect(result?.output).toContain('利用可能な言語: ja(日本語), en(English)');
    expect(result?.output).toContain('字幕セグメント数: 2');
    // entity decoding + <s> tag concatenation + timestamp formatting
    expect(result?.output).toContain('[0:00] Hello & world');
    expect(result?.output).toContain('[1:01] 二行目 あ');

    // InnerTube call carries the extracted video id
    const body = JSON.parse(String((mock.mock.calls[0][1] as RequestInit).body)) as Record<string, unknown>;
    expect(body.videoId).toBe('abcdefghijk');
  });

  it('accepts a bare 11-character video id', async () => {
    const mock = stubFetch((url) => {
      if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
      return new Response(XML_FORMAT_1);
    });
    const result = await executeTool('GetYouTubeTranscript', { url: 'dQw4w9WgXcQ' }, ctx);
    expect(result?.isError).toBe(false);
    const body = JSON.parse(String((mock.mock.calls[0][1] as RequestInit).body)) as Record<string, unknown>;
    expect(body.videoId).toBe('dQw4w9WgXcQ');
  });

  it('accepts youtu.be short URLs', async () => {
    const mock = stubFetch((url) => {
      if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
      return new Response(XML_FORMAT_1);
    });
    const result = await executeTool('GetYouTubeTranscript', { url: 'https://youtu.be/abc_def-123' }, ctx);
    expect(result?.isError).toBe(false);
    const body = JSON.parse(String((mock.mock.calls[0][1] as RequestInit).body)) as Record<string, unknown>;
    expect(body.videoId).toBe('abc_def-123');
  });

  it('selects the requested language track when available', async () => {
    const mock = stubFetch((url) => {
      if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
      if (url === 'https://yt.example/timedtext?lang=en') return new Response(XML_FORMAT_1);
      throw new Error(`unexpected fetch: ${url}`);
    });
    const result = await executeTool(
      'GetYouTubeTranscript',
      { url: 'abcdefghijk', lang: 'en' },
      ctx,
    );
    expect(result?.isError).toBe(false);
    expect(result?.output).toContain('言語: en');
    expect(mock).toHaveBeenCalledWith('https://yt.example/timedtext?lang=en', expect.anything());
  });

  it('errors with the available language list when the requested language is missing', async () => {
    stubFetch((url) => {
      if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
      throw new Error(`unexpected fetch: ${url}`);
    });
    const result = await executeTool(
      'GetYouTubeTranscript',
      { url: 'abcdefghijk', lang: 'fr' },
      ctx,
    );
    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('言語 "fr" の字幕は利用できません');
    expect(result?.output).toContain('ja(日本語)');
    expect(result?.output).toContain('en(English)');
  });

  it('returns an error when the InnerTube API responds non-OK', async () => {
    stubFetch(() => new Response('', { status: 403, statusText: 'Forbidden' }));
    const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('字幕の取得に失敗しました');
    expect(result?.output).toContain('403');
  });

  it('returns an error when the transcript XML cannot be parsed', async () => {
    stubFetch((url) => {
      if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
      return new Response('<garbage/>');
    });
    const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('この動画の字幕を解析できませんでした');
  });

  it('falls back to the watch page when InnerTube returns no caption tracks', async () => {
    const pagePlayerResponse = {
      captions: {
        playerCaptionsTracklistRenderer: {
          captionTracks: [
            { languageCode: 'en', baseUrl: 'https://yt.example/fallback-track' },
          ],
        },
      },
    };
    const html = `<html><script>var ytInitialPlayerResponse = ${JSON.stringify(pagePlayerResponse)};</script></html>`;
    stubFetch((url) => {
      if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) {
        return jsonResponse({ videoDetails: { title: 'Test Video', lengthSeconds: '125' } });
      }
      if (url.startsWith(WATCH_PAGE_PREFIX)) return new Response(html);
      if (url === 'https://yt.example/fallback-track') return new Response(XML_FORMAT_2);
      throw new Error(`unexpected fetch: ${url}`);
    });

    const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
    expect(result?.isError).toBe(false);
    expect(result?.output).toContain('# Test Video');
    expect(result?.output).toContain('言語: en');
    expect(result?.output).toContain('字幕セグメント数: 1');
    // format-2 XML: start="1.5" → 0:01
    expect(result?.output).toContain('[0:01] Fallback line');
  });

  it('reports missing captions when the watch page has no ytInitialPlayerResponse', async () => {
    stubFetch((url) => {
      if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse({ videoDetails: { title: 'T' } });
      if (url.startsWith(WATCH_PAGE_PREFIX)) return new Response('<html><body>no data</body></html>');
      throw new Error(`unexpected fetch: ${url}`);
    });
    const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('字幕情報が見つかりませんでした');
  });

  it('reports CAPTCHA when YouTube serves a recaptcha page', async () => {
    stubFetch((url) => {
      if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse({});
      if (url.startsWith(WATCH_PAGE_PREFIX)) return new Response('<div class="g-recaptcha"></div>');
      throw new Error(`unexpected fetch: ${url}`);
    });
    const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('CAPTCHA');
  });

  it('reports no captions when the watch page player data has an empty track list', async () => {
    const html = `<html><script>var ytInitialPlayerResponse = ${JSON.stringify({ captions: { playerCaptionsTracklistRenderer: { captionTracks: [] } } })};</script></html>`;
    stubFetch((url) => {
      if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse({});
      if (url.startsWith(WATCH_PAGE_PREFIX)) return new Response(html);
      throw new Error(`unexpected fetch: ${url}`);
    });
    const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('この動画には字幕がありません');
  });
});

describe('SearchYouTube', () => {
  it('formats InnerTube search results and emits structured blocks', async () => {
    const data = makeSearchData([
      {
        id: 'aaaaaaaaaaa',
        title: 'First Video',
        channel: 'Chan A',
        views: '1万 回視聴',
        published: '1 日前',
        length: '10:00',
        desc: 'An interesting clip',
      },
      { id: 'bbbbbbbbbbb', title: 'Second Video', channel: 'Chan B' },
    ]);
    stubFetch((url) => {
      if (url.startsWith(INNERTUBE_SEARCH_PREFIX)) return jsonResponse(data);
      throw new Error(`unexpected fetch: ${url}`);
    });

    const result = await executeTool('SearchYouTube', { query: 'cats' }, ctx);
    expect(result?.isError).toBe(false);
    expect(result?.output).toContain('YouTube 検索結果: "cats" (2件)');
    expect(result?.output).toContain('1. First Video');
    expect(result?.output).toContain('URL: https://www.youtube.com/watch?v=aaaaaaaaaaa');
    expect(result?.output).toContain('チャンネル: Chan A');
    expect(result?.output).toContain('動画時間: 10:00');
    expect(result?.output).toContain('再生回数: 1万 回視聴');
    expect(result?.output).toContain('投稿日: 1 日前');
    expect(result?.output).toContain('概要: An interesting clip');
    expect(result?.output).toContain('2. Second Video');
    expect(result?.output).toMatch(/\[\[embed:youtube-\d+\]\]/);

    expect(result?.structuredBlocks).toHaveLength(1);
    const block = result!.structuredBlocks![0];
    expect(block.type).toBe('youtube_videos');
    const blockData = block.data as { query: string; videos: Array<Record<string, unknown>> };
    expect(blockData.query).toBe('cats');
    expect(blockData.videos).toHaveLength(2);
    expect(blockData.videos[0]).toMatchObject({
      videoId: 'aaaaaaaaaaa',
      title: 'First Video',
      channelName: 'Chan A',
      thumbnailUrl: 'https://i.ytimg.com/vi/aaaaaaaaaaa/mqdefault.jpg',
      videoUrl: 'https://www.youtube.com/watch?v=aaaaaaaaaaa',
      duration: '10:00',
    });
  });

  it('respects the limit parameter', async () => {
    const data = makeSearchData([
      { id: 'aaaaaaaaaaa', title: 'One' },
      { id: 'bbbbbbbbbbb', title: 'Two' },
      { id: 'ccccccccccc', title: 'Three' },
    ]);
    stubFetch(() => jsonResponse(data));

    const result = await executeTool('SearchYouTube', { query: 'cats', limit: 1 }, ctx);
    expect(result?.isError).toBe(false);
    expect(result?.output).toContain('(1件)');
    expect(result?.output).toContain('1. One');
    expect(result?.output).not.toContain('Two');
    const blockData = result!.structuredBlocks![0].data as { videos: unknown[] };
    expect(blockData.videos).toHaveLength(1);
  });

  it('returns a non-error message when the response has no result sections', async () => {
    stubFetch(() => jsonResponse({ contents: {} }));
    const result = await executeTool('SearchYouTube', { query: 'nothing' }, ctx);
    expect(result?.isError).toBe(false);
    expect(result?.output).toContain('"nothing" の検索結果が見つかりませんでした');
  });

  it('returns a non-error message when sections contain no videoRenderer items', async () => {
    const data = {
      contents: {
        twoColumnSearchResultsRenderer: {
          primaryContents: {
            sectionListRenderer: {
              contents: [{ itemSectionRenderer: { contents: [{ shelfRenderer: {} }] } }],
            },
          },
        },
      },
    };
    stubFetch(() => jsonResponse(data));
    const result = await executeTool('SearchYouTube', { query: 'nothing' }, ctx);
    expect(result?.isError).toBe(false);
    expect(result?.output).toContain('"nothing" の動画検索結果が見つかりませんでした');
    expect(result?.structuredBlocks).toBeUndefined();
  });

  it('falls back to HTML scraping when the InnerTube API fails', async () => {
    const data = makeSearchData([{ id: 'aaaaaaaaaaa', title: 'Scraped Video', channel: 'HTML Chan' }]);
    const html = `<html><script>var ytInitialData = ${JSON.stringify(data)};</script></html>`;
    stubFetch((url) => {
      if (url.startsWith(INNERTUBE_SEARCH_PREFIX)) return new Response('', { status: 500 });
      if (url.startsWith(RESULTS_PAGE_PREFIX)) return new Response(html);
      throw new Error(`unexpected fetch: ${url}`);
    });

    const result = await executeTool('SearchYouTube', { query: 'cats' }, ctx);
    expect(result?.isError).toBe(false);
    expect(result?.output).toContain('1. Scraped Video');
    expect(result?.output).toContain('チャンネル: HTML Chan');
    // HTML fallback path does not emit structured blocks
    expect(result?.structuredBlocks).toBeUndefined();
  });

  it('errors when the fallback HTML has no ytInitialData', async () => {
    stubFetch((url) => {
      if (url.startsWith(INNERTUBE_SEARCH_PREFIX)) return new Response('', { status: 500 });
      return new Response('<html><body>blocked</body></html>');
    });
    const result = await executeTool('SearchYouTube', { query: 'cats' }, ctx);
    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('YouTube 検索結果の解析に失敗しました');
  });

  it('errors when both the API and the fallback page request fail', async () => {
    stubFetch((url) => {
      if (url.startsWith(INNERTUBE_SEARCH_PREFIX)) return new Response('', { status: 500 });
      return new Response('', { status: 429 });
    });
    const result = await executeTool('SearchYouTube', { query: 'cats' }, ctx);
    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('YouTube 検索に失敗しました');
    expect(result?.output).toContain('429');
  });
});
