import test from 'node:test';
import assert from 'node:assert/strict';
import { CliArgs } from '../../cli/args';
import {
  CliCommandServiceDeps,
  createCliCommandDepsRuntime,
  handleCliCommand,
} from './cli-command';

function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
  return {
    background: false,
    managedPlayback: false,
    start: false,
    launchMpv: false,
    launchMpvTargets: [],
    youtubePlay: undefined,
    youtubeMode: undefined,
    stop: false,
    toggle: false,
    toggleVisibleOverlay: false,
    yomitan: false,
    settings: false,
    syncWindow: false,
    setup: false,
    show: false,
    hide: false,
    showVisibleOverlay: false,
    hideVisibleOverlay: false,
    copySubtitle: false,
    copySubtitleMultiple: false,
    mineSentence: false,
    mineSentenceMultiple: false,
    updateLastCardFromClipboard: false,
    toggleSecondarySub: false,
    triggerFieldGrouping: false,
    triggerSubsync: false,
    markAudioCard: false,
    toggleStatsOverlay: false,
    markWatched: false,
    toggleSubtitleSidebar: false,
    refreshKnownWords: false,
    openRuntimeOptions: false,
    openSessionHelp: false,
    openControllerSelect: false,
    openControllerDebug: false,
    openJimaku: false,
    openTsukihime: false,
    openYoutubePicker: false,
    openPlaylistBrowser: false,
    togglePrimarySubtitleBar: false,
    replayCurrentSubtitle: false,
    playNextSubtitle: false,
    playbackFeedback: undefined,
    cycleRuntimeOptionId: undefined,
    cycleRuntimeOptionDirection: undefined,
    anilistStatus: false,
    anilistLogout: false,
    anilistSetup: false,
    anilistRetryQueue: false,
    dictionary: false,
    dictionaryCandidates: false,
    dictionarySelect: false,
    dictionaryAnilistId: undefined,
    stats: false,
    jellyfin: false,
    jellyfinLogin: false,
    jellyfinLogout: false,
    jellyfinLibraries: false,
    jellyfinItems: false,
    jellyfinSubtitles: false,
    jellyfinSubtitleUrlsOnly: false,
    jellyfinPlay: false,
    jellyfinRemoteAnnounce: false,
    jellyfinPreviewAuth: false,
    texthooker: false,
    texthookerOpenBrowser: false,
    help: false,
    update: false,
    updateLauncherPath: undefined,
    autoStartOverlay: false,
    generateConfig: false,
    backupOverwrite: false,
    debug: false,
    ...overrides,
  };
}

function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
  const calls: string[] = [];
  let mpvSocketPath = '/tmp/subminer.sock';
  let texthookerPort = 5174;
  const osd: string[] = [];
  let texthookerWebsocketUrl: string | undefined;

  const deps: CliCommandServiceDeps = {
    getMpvSocketPath: () => mpvSocketPath,
    setMpvSocketPath: (socketPath) => {
      mpvSocketPath = socketPath;
      calls.push(`setMpvSocketPath:${socketPath}`);
    },
    setMpvClientSocketPath: (socketPath) => {
      calls.push(`setMpvClientSocketPath:${socketPath}`);
    },
    hasMpvClient: () => true,
    connectMpvClient: () => {
      calls.push('connectMpvClient');
    },
    isTexthookerRunning: () => false,
    setTexthookerPort: (port) => {
      texthookerPort = port;
      calls.push(`setTexthookerPort:${port}`);
    },
    getTexthookerPort: () => texthookerPort,
    getTexthookerWebsocketUrl: () => texthookerWebsocketUrl,
    shouldOpenTexthookerBrowser: () => true,
    ensureTexthookerRunning: (port, websocketUrl) => {
      calls.push(`ensureTexthookerRunning:${port}:${websocketUrl ?? ''}`);
    },
    openTexthookerInBrowser: (url) => {
      calls.push(`openTexthookerInBrowser:${url}`);
    },
    stopApp: () => {
      calls.push('stopApp');
    },
    isOverlayRuntimeInitialized: () => false,
    initializeOverlayRuntime: () => {
      calls.push('initializeOverlayRuntime');
    },
    toggleVisibleOverlay: () => {
      calls.push('toggleVisibleOverlay');
    },
    togglePrimarySubtitleBar: () => {
      calls.push('togglePrimarySubtitleBar');
    },
    openYomitanSettingsDelayed: (delayMs) => {
      calls.push(`openYomitanSettingsDelayed:${delayMs}`);
    },
    openConfigSettingsWindow: () => {
      calls.push('openConfigSettingsWindow');
    },
    openSyncUiWindow: () => {
      calls.push('openSyncUiWindow');
    },
    openFirstRunSetup: (force?: boolean) => {
      calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
    },
    setVisibleOverlayVisible: (visible) => {
      calls.push(`setVisibleOverlayVisible:${visible}`);
    },
    copyCurrentSubtitle: () => {
      calls.push('copyCurrentSubtitle');
    },
    startPendingMultiCopy: (timeoutMs) => {
      calls.push(`startPendingMultiCopy:${timeoutMs}`);
    },
    mineSentenceCard: async () => {
      calls.push('mineSentenceCard');
    },
    startPendingMineSentenceMultiple: (timeoutMs) => {
      calls.push(`startPendingMineSentenceMultiple:${timeoutMs}`);
    },
    updateLastCardFromClipboard: async () => {
      calls.push('updateLastCardFromClipboard');
    },
    refreshKnownWords: async () => {
      calls.push('refreshKnownWords');
    },
    cycleSecondarySubMode: () => {
      calls.push('cycleSecondarySubMode');
    },
    triggerFieldGrouping: async () => {
      calls.push('triggerFieldGrouping');
    },
    triggerSubsyncFromConfig: async () => {
      calls.push('triggerSubsyncFromConfig');
    },
    markLastCardAsAudioCard: async () => {
      calls.push('markLastCardAsAudioCard');
    },
    openRuntimeOptionsPalette: () => {
      calls.push('openRuntimeOptionsPalette');
    },
    dispatchSessionAction: async () => {
      calls.push('dispatchSessionAction');
    },
    getAnilistStatus: () => ({
      tokenStatus: 'resolved',
      tokenSource: 'stored',
      tokenMessage: null,
      tokenResolvedAt: 1,
      tokenErrorAt: null,
      queuePending: 2,
      queueReady: 1,
      queueDeadLetter: 0,
      queueLastAttemptAt: 2,
      queueLastError: null,
    }),
    clearAnilistToken: () => {
      calls.push('clearAnilistToken');
    },
    openAnilistSetup: () => {
      calls.push('openAnilistSetup');
    },
    openJellyfinSetup: () => {
      calls.push('openJellyfinSetup');
    },
    getAnilistQueueStatus: () => ({
      pending: 2,
      ready: 1,
      deadLetter: 0,
      lastAttemptAt: null,
      lastError: null,
    }),
    retryAnilistQueue: async () => {
      calls.push('retryAnilistQueue');
      return { ok: true, message: 'AniList retry processed.' };
    },
    generateCharacterDictionary: async () => ({
      zipPath: '/tmp/anilist-1.zip',
      fromCache: false,
      mediaId: 1,
      mediaTitle: 'Test',
      entryCount: 10,
    }),
    getCharacterDictionarySelection: async () => ({
      seriesKey: 'test',
      guessTitle: 'Test',
      current: { id: 1, title: 'Test', episodes: 12 },
      override: null,
      candidates: [{ id: 1, title: 'Test', episodes: 12 }],
    }),
    setCharacterDictionarySelection: async () => ({
      ok: true,
      seriesKey: 'test',
      selected: { id: 1, title: 'Test', episodes: 12 },
      staleMediaIds: [],
    }),
    runStatsCommand: async () => {
      calls.push('runStatsCommand');
    },
    runJellyfinCommand: async () => {
      calls.push('runJellyfinCommand');
    },
    runYoutubePlaybackFlow: async (request) => {
      calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
    },
    runUpdateCommand: async (args) => {
      calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
    },
    runEnsureLinuxRuntimePluginAssetsCommand: async () => {
      calls.push('runEnsureLinuxRuntimePluginAssetsCommand');
    },
    printHelp: () => {
      calls.push('printHelp');
    },
    hasMainWindow: () => true,
    getMultiCopyTimeoutMs: () => 2500,
    showMpvOsd: (text) => {
      osd.push(text);
    },
    showPlaybackFeedback: (text) => {
      calls.push(`feedback:${text}`);
    },
    log: (message) => {
      calls.push(`log:${message}`);
    },
    logDebug: (message) => {
      calls.push(`debug:${message}`);
    },
    warn: (message) => {
      calls.push(`warn:${message}`);
    },
    error: (message) => {
      calls.push(`error:${message}`);
    },
    ...overrides,
  };

  return { deps, calls, osd };
}

test('handleCliCommand starts youtube playback flow on initial launch', () => {
  const { deps, calls } = createDeps({
    runYoutubePlaybackFlow: async (request) => {
      calls.push(`youtube:${request.url}:${request.mode}`);
    },
  });

  handleCliCommand(
    makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'generate' }),
    'initial',
    deps,
  );

  assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:generate']);
});

test('handleCliCommand defaults youtube mode to download when omitted', () => {
  const { deps, calls } = createDeps({
    runYoutubePlaybackFlow: async (request) => {
      calls.push(`youtube:${request.url}:${request.mode}`);
    },
  });

  handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);

  assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download']);
});

test('handleCliCommand reuses initialized overlay runtime for second-instance youtube playback', () => {
  const { deps, calls } = createDeps({
    isOverlayRuntimeInitialized: () => true,
    runYoutubePlaybackFlow: async (request) => {
      calls.push(`youtube:${request.url}:${request.mode}:${request.source}`);
    },
  });

  handleCliCommand(
    makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
    'second-instance',
    deps,
  );

  assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download:second-instance']);
});

test('handleCliCommand reports youtube playback flow failures to logs and OSD', async () => {
  const { deps, calls, osd } = createDeps({
    runYoutubePlaybackFlow: async () => {
      throw new Error('yt failed');
    },
  });

  handleCliCommand(
    makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
    'initial',
    deps,
  );
  await new Promise((resolve) => setImmediate(resolve));

  assert.ok(calls.some((value) => value.startsWith('error:runYoutubePlaybackFlow failed:')));
  assert.ok(osd.includes('YouTube playback failed: yt failed'));
});

test('handleCliCommand reconnects MPV for second-instance --start when overlay runtime is already initialized', () => {
  const { deps, calls } = createDeps({
    isOverlayRuntimeInitialized: () => true,
  });
  const args = makeArgs({ start: true });

  handleCliCommand(args, 'second-instance', deps);

  assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock'));
  assert.equal(
    calls.some((value) => value.includes('connectMpvClient')),
    true,
  );
  assert.equal(
    calls.some((value) => value.includes('initializeOverlayRuntime')),
    false,
  );
});

test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
  const { deps, calls } = createDeps();
  const args = makeArgs({ start: true });

  handleCliCommand(args, 'second-instance', deps);

  assert.equal(
    calls.some((value) => value === 'log:Ignoring --start because SubMiner is already running.'),
    false,
  );
  assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock'));
  assert.equal(
    calls.some((value) => value.includes('connectMpvClient')),
    true,
  );
});

test('handleCliCommand ensures background stats server for initial --start --background', () => {
  const ensured: number[] = [];
  const { deps } = createDeps({
    ensureBackgroundStatsServer: () => {
      ensured.push(1);
    },
  });

  handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps);

  assert.equal(ensured.length, 1);
});

test('handleCliCommand ensures background stats server for second-instance --start --background', () => {
  const ensured: number[] = [];
  const { deps } = createDeps({
    isOverlayRuntimeInitialized: () => true,
    ensureBackgroundStatsServer: () => {
      ensured.push(1);
    },
  });

  handleCliCommand(makeArgs({ start: true, background: true }), 'second-instance', deps);

  assert.equal(ensured.length, 1);
});

test('handleCliCommand does not ensure background stats server for foreground --start', () => {
  const ensured: number[] = [];
  const { deps } = createDeps({
    ensureBackgroundStatsServer: () => {
      ensured.push(1);
    },
  });

  handleCliCommand(makeArgs({ start: true }), 'initial', deps);

  assert.equal(ensured.length, 0);
});

test('handleCliCommand forces setup open for second-instance setup command', () => {
  const { deps, calls } = createDeps();

  handleCliCommand(makeArgs({ setup: true }), 'second-instance', deps);

  assert.ok(calls.includes('openFirstRunSetup:force'));
  assert.ok(calls.includes('debug:Opened first-run setup flow.'));
});

test('handleCliCommand opens first-run setup window for --setup', () => {
  const { deps, calls } = createDeps();

  handleCliCommand(makeArgs({ setup: true }), 'initial', deps);

  assert.ok(calls.includes('openFirstRunSetup:force'));
  assert.ok(calls.includes('debug:Opened first-run setup flow.'));
  assert.equal(calls.includes('log:Opened first-run setup flow.'), false);
  assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
});

test('handleCliCommand runs update command without overlay startup', async () => {
  const { deps, calls } = createDeps();

  handleCliCommand(
    makeArgs({ update: true, updateLauncherPath: '/home/kyle/.local/bin/subminer' }),
    'initial',
    deps,
  );
  await new Promise((resolve) => setImmediate(resolve));

  assert.deepEqual(calls, ['runUpdateCommand:/home/kyle/.local/bin/subminer']);
});

test('handleCliCommand stops app after headless initial update completes', async () => {
  const { deps, calls } = createDeps({
    hasMainWindow: () => false,
  });

  handleCliCommand(
    makeArgs({ update: true, updateLauncherPath: '/home/kyle/.local/bin/subminer' }),
    'initial',
    deps,
  );
  await new Promise((resolve) => setImmediate(resolve));

  assert.deepEqual(calls, ['runUpdateCommand:/home/kyle/.local/bin/subminer', 'stopApp']);
});

test('handleCliCommand dispatches stats command without overlay startup', async () => {
  const { deps, calls } = createDeps({
    runStatsCommand: async () => {
      calls.push('runStatsCommand');
    },
  });

  handleCliCommand(makeArgs({ stats: true }), 'initial', deps);
  await Promise.resolve();

  assert.ok(calls.includes('runStatsCommand'));
  assert.equal(calls.includes('initializeOverlayRuntime'), false);
  assert.equal(calls.includes('connectMpvClient'), false);
});

test('handleCliCommand applies cli log level for second-instance commands', () => {
  const { deps, calls } = createDeps({
    setLogLevel: (level) => {
      calls.push(`setLogLevel:${level}`);
    },
  });

  handleCliCommand(makeArgs({ start: true, logLevel: 'debug' }), 'second-instance', deps);

  assert.ok(calls.includes('setLogLevel:debug'));
});

test('handleCliCommand runs texthooker flow with browser open', () => {
  const { deps, calls } = createDeps();
  const args = makeArgs({ texthooker: true });

  handleCliCommand(args, 'initial', deps);

  assert.ok(calls.includes('ensureTexthookerRunning:5174:'));
  assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174'));
});

test('handleCliCommand opens texthooker browser when requested even if config disables auto-open', () => {
  const { deps, calls } = createDeps({
    shouldOpenTexthookerBrowser: () => false,
  });
  const args = {
    ...makeArgs({ texthooker: true }),
    texthookerOpenBrowser: true,
  } as CliArgs;

  handleCliCommand(args, 'initial', deps);

  assert.ok(calls.includes('ensureTexthookerRunning:5174:'));
  assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174'));
});

test('handleCliCommand forwards resolved websocket url to texthooker startup', () => {
  const { deps, calls } = createDeps({
    getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
  });

  handleCliCommand(makeArgs({ texthooker: true }), 'initial', deps);

  assert.ok(calls.includes('ensureTexthookerRunning:5174:ws://127.0.0.1:6678'));
});

test('handleCliCommand reports async mine errors to OSD', async () => {
  const { deps, calls, osd } = createDeps({
    mineSentenceCard: async () => {
      throw new Error('boom');
    },
  });

  handleCliCommand(makeArgs({ mineSentence: true }), 'initial', deps);
  await new Promise((resolve) => setImmediate(resolve));

  assert.ok(calls.some((value) => value.startsWith('error:mineSentenceCard failed:')));
  assert.ok(osd.some((value) => value.includes('Mine sentence failed: boom')));
});

test('handleCliCommand routes playback feedback through configured feedback surface', () => {
  const { deps, calls, osd } = createDeps();

  handleCliCommand(makeArgs({ playbackFeedback: 'You can skip by pressing TAB' }), 'initial', deps);

  assert.deepEqual(calls, ['initializeOverlayRuntime', 'feedback:You can skip by pressing TAB']);
  assert.deepEqual(osd, []);
});

test('handleCliCommand applies socket path and connects on start', () => {
  const { deps, calls } = createDeps();

  handleCliCommand(makeArgs({ start: true, socketPath: '/tmp/custom.sock' }), 'initial', deps);

  assert.ok(calls.includes('initializeOverlayRuntime'));
  assert.ok(calls.includes('setMpvSocketPath:/tmp/custom.sock'));
  assert.ok(calls.includes('setMpvClientSocketPath:/tmp/custom.sock'));
  assert.ok(calls.includes('connectMpvClient'));
});

test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
  const calls: string[] = [];
  const client = {
    setSocketPath: (socketPath: string) => {
      calls.push(`setSocketPath:${socketPath}`);
    },
    connect: () => {
      calls.push('connect');
    },
    reconnect: () => {
      calls.push('reconnect');
    },
  };
  const deps = createCliCommandDepsRuntime({
    mpv: {
      getSocketPath: () => '/tmp/runtime.sock',
      setSocketPath: () => {},
      getClient: () => client,
      showOsd: () => {},
    },
    texthooker: {
      service: { isRunning: () => false, start: () => {} },
      getPort: () => 5174,
      setPort: () => {},
      getWebsocketUrl: () => undefined,
      shouldOpenBrowser: () => false,
      openInBrowser: () => {},
    },
    overlay: {
      isInitialized: () => true,
      initialize: () => {},
      toggleVisible: () => {},
      togglePrimarySubtitleBar: () => {},
      setVisible: () => {},
    },
    mining: {
      copyCurrentSubtitle: () => {},
      startPendingMultiCopy: () => {},
      mineSentenceCard: async () => {},
      startPendingMineSentenceMultiple: () => {},
      updateLastCardFromClipboard: async () => {},
      refreshKnownWords: async () => {},
      triggerFieldGrouping: async () => {},
      triggerSubsyncFromConfig: async () => {},
      markLastCardAsAudioCard: async () => {},
    },
    anilist: {
      getStatus: () => ({
        tokenStatus: 'not_checked',
        tokenSource: 'none',
        tokenMessage: null,
        tokenResolvedAt: null,
        tokenErrorAt: null,
        queuePending: 0,
        queueReady: 0,
        queueDeadLetter: 0,
        queueLastAttemptAt: null,
        queueLastError: null,
      }),
      clearToken: () => {},
      openSetup: () => {},
      getQueueStatus: () => ({
        pending: 0,
        ready: 0,
        deadLetter: 0,
        lastAttemptAt: null,
        lastError: null,
      }),
      retryQueueNow: async () => ({ ok: true, message: 'ok' }),
    },
    dictionary: {
      generate: async () => ({
        zipPath: '/tmp/test.zip',
        fromCache: false,
        mediaId: 1,
        mediaTitle: 'Test',
        entryCount: 0,
      }),
      getSelection: async () => ({
        seriesKey: 'test',
        guessTitle: null,
        current: null,
        override: null,
        candidates: [],
      }),
      setSelection: async () => ({
        ok: true,
        seriesKey: 'test',
        selected: { id: 1, title: 'Test', episodes: null },
        staleMediaIds: [],
      }),
    },
    jellyfin: {
      openSetup: () => {},
      runStatsCommand: async () => {},
      runCommand: async () => {},
    },
    ui: {
      openFirstRunSetup: () => {},
      openYomitanSettings: () => {},
      openConfigSettingsWindow: () => {},
      openSyncUiWindow: () => {},
      cycleSecondarySubMode: () => {},
      openRuntimeOptionsPalette: () => {},
      printHelp: () => {},
    },
    app: {
      stop: () => {},
      hasMainWindow: () => true,
      runUpdateCommand: async () => {},
      runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
      runYoutubePlaybackFlow: async () => {},
    },
    dispatchSessionAction: async () => {},
    getMultiCopyTimeoutMs: () => 2500,
    schedule: () => undefined,
    log: () => {},
    logDebug: () => {},
    warn: () => {},
    error: () => {},
  });

  deps.setMpvClientSocketPath('/tmp/runtime.sock');
  deps.connectMpvClient();

  assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
});

test('handleCliCommand warns when texthooker port override used while running', () => {
  const { deps, calls } = createDeps({
    isTexthookerRunning: () => true,
  });

  handleCliCommand(makeArgs({ texthookerPort: 9999, texthooker: true }), 'initial', deps);

  assert.ok(
    calls.includes(
      'warn:Ignoring --port override because the texthooker server is already running.',
    ),
  );
  assert.equal(
    calls.some((value) => value === 'setTexthookerPort:9999'),
    false,
  );
});

test('handleCliCommand prints help and stops app when no window exists', () => {
  const { deps, calls } = createDeps({
    hasMainWindow: () => false,
  });

  handleCliCommand(makeArgs({ help: true }), 'initial', deps);

  assert.ok(calls.includes('printHelp'));
  assert.ok(calls.includes('stopApp'));
});

test('handleCliCommand reports async trigger-subsync errors to OSD', async () => {
  const { deps, calls, osd } = createDeps({
    triggerSubsyncFromConfig: async () => {
      throw new Error('subsync boom');
    },
  });

  handleCliCommand(makeArgs({ triggerSubsync: true }), 'initial', deps);
  await new Promise((resolve) => setImmediate(resolve));

  assert.ok(calls.some((value) => value.startsWith('error:triggerSubsyncFromConfig failed:')));
  assert.ok(osd.some((value) => value.includes('Subsync failed: subsync boom')));
});

test('handleCliCommand stops app for --stop command', () => {
  const { deps, calls } = createDeps();
  handleCliCommand(makeArgs({ stop: true }), 'initial', deps);
  assert.ok(calls.includes('log:Stopping SubMiner...'));
  assert.ok(calls.includes('stopApp'));
});

test('handleCliCommand still runs non-start actions on second-instance', () => {
  const { deps, calls } = createDeps();
  handleCliCommand(makeArgs({ start: true, toggleVisibleOverlay: true }), 'second-instance', deps);
  assert.ok(calls.includes('toggleVisibleOverlay'));
  assert.equal(
    calls.some((value) => value === 'connectMpvClient'),
    true,
  );
});

test('handleCliCommand does not connect MPV for pure toggle on second-instance', () => {
  const { deps, calls } = createDeps();
  handleCliCommand(makeArgs({ toggle: true }), 'second-instance', deps);
  assert.ok(calls.includes('toggleVisibleOverlay'));
  assert.equal(
    calls.some((value) => value === 'connectMpvClient'),
    false,
  );
});

test('handleCliCommand does not connect MPV for explicit visible-overlay toggle', () => {
  const { deps, calls } = createDeps();
  handleCliCommand(makeArgs({ toggleVisibleOverlay: true }), 'second-instance', deps);
  assert.ok(calls.includes('toggleVisibleOverlay'));
  assert.equal(
    calls.some((value) => value === 'connectMpvClient'),
    false,
  );
});

test('handleCliCommand handles visibility and utility command dispatches', () => {
  const cases: Array<{
    args: Partial<CliArgs>;
    expected: string;
  }> = [
    { args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
    { args: { settings: true }, expected: 'openConfigSettingsWindow' },
    {
      args: { showVisibleOverlay: true },
      expected: 'setVisibleOverlayVisible:true',
    },
    {
      args: { hideVisibleOverlay: true },
      expected: 'setVisibleOverlayVisible:false',
    },
    { args: { copySubtitle: true }, expected: 'copyCurrentSubtitle' },
    {
      args: { copySubtitleMultiple: true },
      expected: 'startPendingMultiCopy:2500',
    },
    {
      args: { mineSentenceMultiple: true },
      expected: 'startPendingMineSentenceMultiple:2500',
    },
    { args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
    { args: { togglePrimarySubtitleBar: true }, expected: 'togglePrimarySubtitleBar' },
    { args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
    { args: { markWatched: true }, expected: 'dispatchSessionAction' },
    {
      args: { openRuntimeOptions: true },
      expected: 'openRuntimeOptionsPalette',
    },
    { args: { anilistLogout: true }, expected: 'clearAnilistToken' },
    { args: { anilistSetup: true }, expected: 'openAnilistSetup' },
    { args: { jellyfin: true }, expected: 'openJellyfinSetup' },
  ];

  for (const entry of cases) {
    const { deps, calls } = createDeps();
    handleCliCommand(makeArgs(entry.args), 'initial', deps);
    assert.ok(
      calls.includes(entry.expected),
      `expected call missing for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
    );
  }
});

test('handleCliCommand dispatches cycle-runtime-option session action', async () => {
  let request: unknown = null;
  const { deps } = createDeps({
    dispatchSessionAction: async (nextRequest) => {
      request = nextRequest;
    },
  });

  handleCliCommand(
    makeArgs({
      cycleRuntimeOptionId: 'anki.autoUpdateNewCards',
      cycleRuntimeOptionDirection: -1,
    }),
    'initial',
    deps,
  );
  await new Promise((resolve) => setImmediate(resolve));

  assert.deepEqual(request, {
    actionId: 'cycleRuntimeOption',
    payload: {
      runtimeOptionId: 'anki.autoUpdateNewCards',
      direction: -1,
    },
  });
});

test('handleCliCommand dispatches generic session action payloads', async () => {
  let request: unknown = null;
  const { deps } = createDeps({
    dispatchSessionAction: async (nextRequest) => {
      request = nextRequest;
    },
  });

  handleCliCommand(
    makeArgs({
      sessionAction: {
        actionId: 'openCharacterDictionaryManager',
      },
    }),
    'initial',
    deps,
  );
  await new Promise((resolve) => setImmediate(resolve));

  assert.deepEqual(request, {
    actionId: 'openCharacterDictionaryManager',
  });
});

test('handleCliCommand dispatches mark-watched session action', async () => {
  let request: unknown = null;
  const { deps } = createDeps({
    dispatchSessionAction: async (nextRequest) => {
      request = nextRequest;
    },
  });

  handleCliCommand(makeArgs({ markWatched: true }), 'initial', deps);
  await new Promise((resolve) => setImmediate(resolve));

  assert.deepEqual(request, {
    actionId: 'markWatched',
  });
});

test('handleCliCommand logs AniList status details', () => {
  const { deps, calls } = createDeps();
  handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
  assert.ok(calls.some((value) => value.startsWith('log:AniList token status:')));
  assert.ok(calls.some((value) => value.startsWith('log:AniList queue:')));
});

test('handleCliCommand runs AniList retry command', async () => {
  const { deps, calls } = createDeps();
  handleCliCommand(makeArgs({ anilistRetryQueue: true }), 'initial', deps);
  await new Promise((resolve) => setImmediate(resolve));
  assert.ok(calls.includes('retryAnilistQueue'));
  assert.ok(calls.includes('log:AniList retry processed.'));
});

test('handleCliCommand runs dictionary generation command', async () => {
  const { deps, calls } = createDeps({
    hasMainWindow: () => false,
    generateCharacterDictionary: async () => ({
      zipPath: '/tmp/anilist-9253.zip',
      fromCache: true,
      mediaId: 9253,
      mediaTitle: 'STEINS;GATE',
      entryCount: 314,
    }),
  });
  handleCliCommand(makeArgs({ dictionary: true }), 'initial', deps);
  await new Promise((resolve) => setImmediate(resolve));
  assert.ok(calls.includes('log:Generating character dictionary for current anime...'));
  assert.ok(
    calls.includes('log:Character dictionary cache hit: AniList 9253 (STEINS;GATE), entries=314'),
  );
  assert.ok(calls.includes('log:Dictionary ZIP: /tmp/anilist-9253.zip'));
  assert.ok(calls.includes('stopApp'));
});

test('handleCliCommand forwards --dictionary-target to dictionary runtime', async () => {
  let receivedTarget: string | undefined;
  const { deps } = createDeps({
    generateCharacterDictionary: async (targetPath?: string) => {
      receivedTarget = targetPath;
      return {
        zipPath: '/tmp/anilist-100.zip',
        fromCache: false,
        mediaId: 100,
        mediaTitle: 'Test',
        entryCount: 1,
      };
    },
  });

  handleCliCommand(
    makeArgs({ dictionary: true, dictionaryTarget: '/tmp/example-video.mkv' }),
    'initial',
    deps,
  );
  await new Promise((resolve) => setImmediate(resolve));

  assert.equal(receivedTarget, '/tmp/example-video.mkv');
});

test('handleCliCommand lists character dictionary AniList candidates', async () => {
  const { calls, deps } = createDeps({
    getCharacterDictionarySelection: async (targetPath?: string) => {
      calls.push(`getCharacterDictionarySelection:${targetPath ?? ''}`);
      return {
        seriesKey: 're-zero-starting-life-in-another-world-2016',
        guessTitle: 'Re ZERO, Starting Life in Another World',
        current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: null },
        override: null,
        candidates: [
          { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
          { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
        ],
      };
    },
  });

  handleCliCommand(
    makeArgs({ dictionaryCandidates: true, dictionaryTarget: '/tmp/re-zero.mkv' }),
    'initial',
    deps,
  );
  await new Promise((resolve) => setImmediate(resolve));

  assert.ok(calls.includes('getCharacterDictionarySelection:/tmp/re-zero.mkv'));
  assert.ok(
    calls.includes(
      'log:Character dictionary series key: re-zero-starting-life-in-another-world-2016',
    ),
  );
  assert.ok(
    calls.includes('log:Candidate: 21355 - Re:ZERO -Starting Life in Another World- (25 episodes)'),
  );
});

test('handleCliCommand sets character dictionary manual AniList selection', async () => {
  const { calls, deps } = createDeps({
    setCharacterDictionarySelection: async (request) => {
      calls.push(`setCharacterDictionarySelection:${request.mediaId}:${request.targetPath ?? ''}`);
      return {
        ok: true,
        seriesKey: 're-zero-starting-life-in-another-world-2016',
        selected: {
          id: request.mediaId,
          title: 'Re:ZERO -Starting Life in Another World-',
          episodes: 25,
        },
        staleMediaIds: [10607],
      };
    },
  });

  handleCliCommand(
    makeArgs({
      dictionarySelect: true,
      dictionaryAnilistId: 21355,
      dictionaryTarget: '/tmp/re-zero.mkv',
    }),
    'initial',
    deps,
  );
  await new Promise((resolve) => setImmediate(resolve));

  assert.ok(calls.includes('setCharacterDictionarySelection:21355:/tmp/re-zero.mkv'));
  assert.ok(
    calls.includes(
      'log:Character dictionary override saved: re-zero-starting-life-in-another-world-2016 -> 21355 - Re:ZERO -Starting Life in Another World-',
    ),
  );
});

test('handleCliCommand does not log character dictionary selection success when result is not ok', async () => {
  const { calls, deps } = createDeps({
    setCharacterDictionarySelection: async () => ({
      ok: false,
      seriesKey: 'test',
      selected: { id: 0, title: '', episodes: null },
      staleMediaIds: [],
    }),
  });

  handleCliCommand(
    makeArgs({
      dictionarySelect: true,
      dictionaryAnilistId: 21355,
      dictionaryTarget: '/tmp/re-zero.mkv',
    }),
    'initial',
    deps,
  );
  await new Promise((resolve) => setImmediate(resolve));

  assert.ok(calls.includes('warn:Character dictionary override was not saved.'));
  assert.equal(
    calls.some((call) => call.startsWith('log:Character dictionary override saved:')),
    false,
  );
});

test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
  const nonJellyfinArgs: Array<Partial<CliArgs>> = [
    { start: true },
    { copySubtitle: true },
    { toggleVisibleOverlay: true },
  ];

  for (const args of nonJellyfinArgs) {
    const { deps, calls } = createDeps();
    handleCliCommand(makeArgs(args), 'initial', deps);
    const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length;
    assert.equal(
      runJellyfinCallCount,
      0,
      `Unexpected Jellyfin dispatch for args ${JSON.stringify(args)}`,
    );
  }
});

test('handleCliCommand runs jellyfin command dispatcher', async () => {
  const { deps, calls } = createDeps();
  handleCliCommand(makeArgs({ jellyfinLibraries: true }), 'initial', deps);
  handleCliCommand(makeArgs({ jellyfinSubtitles: true }), 'initial', deps);
  await new Promise((resolve) => setImmediate(resolve));
  const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length;
  assert.equal(runJellyfinCallCount, 2);
});

test('handleCliCommand reports jellyfin command errors to OSD', async () => {
  const { deps, calls, osd } = createDeps({
    runJellyfinCommand: async () => {
      throw new Error('server offline');
    },
  });

  handleCliCommand(makeArgs({ jellyfinLibraries: true }), 'initial', deps);
  await new Promise((resolve) => setImmediate(resolve));

  assert.ok(calls.some((value) => value.startsWith('error:runJellyfinCommand failed:')));
  assert.ok(osd.some((value) => value.includes('Jellyfin command failed: server offline')));
});

test('handleCliCommand runs refresh-known-words command', () => {
  const { deps, calls } = createDeps();

  handleCliCommand(makeArgs({ refreshKnownWords: true }), 'initial', deps);

  assert.ok(calls.includes('refreshKnownWords'));
});

test('handleCliCommand stops app after headless initial refresh-known-words completes', async () => {
  const { deps, calls } = createDeps({
    hasMainWindow: () => false,
  });

  handleCliCommand(makeArgs({ refreshKnownWords: true }), 'initial', deps);
  await new Promise((resolve) => setImmediate(resolve));

  assert.ok(calls.includes('refreshKnownWords'));
  assert.ok(calls.includes('stopApp'));
});

test('handleCliCommand reports async refresh-known-words errors to OSD', async () => {
  const { deps, calls, osd } = createDeps({
    hasMainWindow: () => false,
    refreshKnownWords: async () => {
      throw new Error('refresh boom');
    },
  });

  handleCliCommand(makeArgs({ refreshKnownWords: true }), 'initial', deps);
  await new Promise((resolve) => setImmediate(resolve));

  assert.ok(calls.some((value) => value.startsWith('error:refreshKnownWords failed:')));
  assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom')));
  assert.ok(calls.includes('stopApp'));
});
