import { describe, expect, it } from 'vitest';
import {
  buildContactSheetPlan,
  buildConvertPlan,
  buildFadePlan,
  buildLoudnessAnalysisPlan,
  buildLoudnessApplyPlan,
  buildMetadataEditPlan,
  buildPeakAnalysisPlan,
  buildPeakNormalizationFilter,
  buildPeakNormalizationPlan,
  buildSceneChangeAnalysisPlan,
  buildSceneThumbnailSeriesPlan,
  buildSubtitleBurnPlan,
  buildSubtitleExtractionPlan,
  buildSubtitleMuxPlan,
  buildStaticWaveformPlan,
  buildSingleThumbnailPlan,
  buildThumbnailSeriesPlan,
  buildTransformPlan,
  classifySubtitleCodec,
  createChaptersAtIntervals,
  createChaptersFromMarkers,
  escapeFFMetadata,
  escapeFilterValue,
  metadataArguments,
  parseChapterJson,
  parseLoudnessMeasurement,
  parsePeakMeasurement,
  parseSceneChangeFrames,
  parseMetadataJson,
  resolveThumbnailTimes,
  serializeChapterJson,
  serializeFFMetadata,
  validateChapters,
} from '../../src/commands';
import { findBuiltInPreset } from '../../src/presets';

const SOURCE = {
  id: 'media',
  sourceIndex: 0,
  fileName: 'source.mp4',
  extension: 'mp4',
} as const;

describe('metadata and chapter commands', () => {
  it('serializes deterministic metadata args without a command string', () => {
    expect(
      metadataArguments({
        format: { title: 'A title', artist: 'An artist' },
        streams: { 1: { language: 'deu', title: null } },
      })
    ).toEqual([
      '-metadata',
      'artist=An artist',
      '-metadata',
      'title=A title',
      '-metadata:s:1',
      'language=deu',
      '-metadata:s:1',
      'title=',
    ]);
    expect(
      parseMetadataJson(
        '{"format":{"title":"Demo"},"streams":{"0":{"language":"eng"}},"dispositions":{"0":["forced","default"]}}'
      )
    ).toEqual({
      format: { title: 'Demo' },
      streams: { 0: { language: 'eng' } },
      dispositions: { 0: ['default', 'forced'] },
    });
    expect(
      metadataArguments({
        dispositions: { 0: [], 1: ['forced', 'default'] },
      })
    ).toEqual(['-disposition:0', '0', '-disposition:1', 'default+forced']);
    expect(() =>
      parseMetadataJson('{"dispositions":{"0":["default","default"]}}')
    ).toThrow(/duplicates/iu);
    expect(() => parseMetadataJson('{"args":["-i","remote"]}')).toThrow(
      /unsupported/iu
    );
  });

  it('escapes every FFMETADATA delimiter and round-trips chapter JSON', () => {
    expect(escapeFFMetadata('a=b;c#d\\e\nf')).toBe('a\\=b\\;c\\#d\\\\e\\\nf');
    const chapters = [
      { id: 'intro', title: 'Intro #1', startSeconds: 0, endSeconds: 2 },
      { id: 'main', title: 'Main', startSeconds: 2, endSeconds: 5 },
    ];
    expect(serializeFFMetadata(chapters)).toContain('title=Intro \\#1');
    const json = serializeChapterJson(chapters);
    expect(parseChapterJson(json).chapters).toEqual(chapters);
    expect(
      validateChapters([chapters[0]!, { ...chapters[1]!, startSeconds: 1 }])[0]
    ).toMatchObject({ severity: 'error' });
    expect(createChaptersFromMarkers([2, 5], 8)).toHaveLength(3);
    expect(
      createChaptersAtIntervals(10, 4).map((chapter) => chapter.startSeconds)
    ).toEqual([0, 4, 8]);
  });

  it('preserves validated per-chapter time bases and converts seconds to ticks', () => {
    const metadata = serializeFFMetadata([
      {
        id: 'milliseconds',
        title: 'Milliseconds',
        startSeconds: 0,
        endSeconds: 0.5,
        timeBase: '1/1000',
      },
      {
        id: 'samples',
        title: 'Samples',
        startSeconds: 0.5,
        endSeconds: 1,
        timeBase: '1/48000',
      },
    ]);
    expect(metadata).toContain('TIMEBASE=1/1000\nSTART=0\nEND=500');
    expect(metadata).toContain('TIMEBASE=1/48000\nSTART=24000\nEND=48000');
    expect(() =>
      serializeFFMetadata([
        {
          id: 'invalid',
          title: 'Invalid',
          startSeconds: 0,
          endSeconds: 1,
          timeBase: '0/1000',
        },
      ])
    ).toThrow(/time base/iu);
    expect(() =>
      serializeFFMetadata([
        {
          id: 'coarse',
          title: 'Coarse',
          startSeconds: 0,
          endSeconds: 0.1,
          timeBase: '1/1',
        },
      ])
    ).toThrow(/too coarse/iu);
  });

  it('builds a stream-copy metadata edit with explicit removal diagnostics', () => {
    const plan = buildMetadataEditPlan({
      jobId: 'meta',
      source: SOURCE,
      edits: { format: { title: 'Edited' } },
      targetExtension: 'mp4',
      targetMuxer: 'mp4',
      sourceMetadataPolicy: 'remove',
      chapterPolicy: 'remove',
    });
    expect(plan.args).toContain('title=Edited');
    expect(plan.args).toContain('copy');
    expect(plan.diagnostics).toContainEqual(
      expect.objectContaining({ code: 'metadata-removal-scope' })
    );
  });
});

describe('loudness and subtitle commands', () => {
  const LOG = `
[Parsed_loudnorm_0] {
  "input_i" : "-19.70",
  "input_tp" : "-2.10",
  "input_lra" : "4.20",
  "input_thresh" : "-30.00",
  "target_offset" : "-0.30"
}`;

  it('parses complete loudnorm JSON and rejects invalid measurements', () => {
    expect(parseLoudnessMeasurement(LOG)).toEqual({
      inputIntegratedLufs: -19.7,
      inputTruePeakDbtp: -2.1,
      inputLoudnessRangeLu: 4.2,
      inputThresholdLufs: -30,
      targetOffsetLu: -0.3,
    });
    expect(() =>
      parseLoudnessMeasurement(
        '{"input_i":"-inf","input_tp":"0","input_lra":"0","input_thresh":"0"}'
      )
    ).toThrow(/finite/u);
    expect(buildPeakNormalizationFilter(-1, { inputPeakDbfs: -6 })).toMatch(
      /^volume=1\.778/u
    );
  });

  it('builds distinct first and second loudness passes', () => {
    const targets = {
      integratedLufs: -16,
      truePeakDbtp: -1,
      loudnessRangeLu: 11,
    };
    const measurement = parseLoudnessMeasurement(LOG);
    const analysis = buildLoudnessAnalysisPlan({
      jobId: 'loud',
      source: SOURCE,
      targets,
      audioStreamIndex: 1,
    });
    expect(analysis.outputs).toEqual([]);
    expect(analysis.args).toContain('-f');
    expect(analysis.args).toContain('null');
    expect(analysis.args).toContain('0:1');
    expect(
      analysis.args.find((value) => value.startsWith('loudnorm='))
    ).toContain('print_format=json');
    const apply = buildLoudnessApplyPlan({
      jobId: 'loud',
      source: SOURCE,
      targets,
      measurement,
      audioStreamIndex: 1,
      preset: findBuiltInPreset('audio-m4a-aac')!,
    });
    expect(apply.args.find((value) => value.startsWith('loudnorm='))).toContain(
      'measured_I=-19.7'
    );
    const peakAnalysis = buildPeakAnalysisPlan({
      jobId: 'peak',
      source: SOURCE,
      audioStreamIndex: 1,
      preset: findBuiltInPreset('audio-m4a-aac')!,
    });
    expect(
      peakAnalysis.args.find((value) => value.includes('volumedetect'))
    ).toContain('aformat=channel_layouts=stereo');
    const peakMeasurement = parsePeakMeasurement('max_volume: -6.0 dB');
    const peak = buildPeakNormalizationPlan({
      jobId: 'peak',
      source: SOURCE,
      audioStreamIndex: 1,
      targetPeakDb: -1,
      measurement: peakMeasurement,
      preset: findBuiltInPreset('audio-m4a-aac')!,
    });
    expect(peak.args.find((value) => value.includes('volume='))).toContain(
      'volume=1.778'
    );
  });

  it('classifies text and bitmap subtitles and blocks bitmap-to-text extraction', () => {
    expect(classifySubtitleCodec('subrip')).toBe('text');
    expect(classifySubtitleCodec('hdmv_pgs_subtitle')).toBe('bitmap');
    expect(() =>
      buildSubtitleExtractionPlan({
        jobId: 'sub',
        source: SOURCE,
        stream: { index: 2, codec: 'hdmv_pgs_subtitle' },
        outputFormat: 'srt',
      })
    ).toThrow(/bitmap/iu);
  });

  it('converts text subtitles for target containers and safely builds burn-in', () => {
    const subtitle = {
      id: 'subtitle',
      sourceIndex: 1,
      fileName: "my:sub's.srt",
      extension: 'srt',
    } as const;
    const mux = buildSubtitleMuxPlan({
      jobId: 'mux',
      source: SOURCE,
      subtitle,
      subtitleCodec: 'subrip',
      sourceStreams: [
        { index: 0, kind: 'video', codec: 'h264' },
        { index: 1, kind: 'audio', codec: 'aac' },
      ],
      targetContainer: 'mp4',
      targetExtension: 'mp4',
      language: 'eng',
      default: true,
    });
    expect(mux.args).toContain('mov_text');
    const burn = buildSubtitleBurnPlan({
      jobId: 'burn',
      source: SOURCE,
      subtitle,
      preset: findBuiltInPreset('mp4-h264-balanced')!,
    });
    expect(burn.inputs).toHaveLength(2);
    expect(burn.args.find((value) => value.startsWith('subtitles='))).toContain(
      '/input/job-burn/source-1.srt'
    );
    expect(escapeFilterValue("/path/a:b,c's.ass")).toBe(
      "/path/a\\:b\\,c\\'s.ass"
    );
  });
});

describe('waveform plans', () => {
  it('builds a bounded static derivative for an absolute audio stream', () => {
    const plan = buildStaticWaveformPlan({
      jobId: 'wave',
      source: SOURCE,
      audioStreamIndex: 1,
      width: 640,
      height: 160,
    });
    expect(plan.args.join(' ')).toContain(
      '[0:1]aformat=channel_layouts=mono,showwavespic=s=640x160'
    );
    expect(plan.outputs[0]).toMatchObject({
      role: 'analysis',
      mediaType: 'image/png',
    });
  });
});

describe('thumbnail and contact sheet plans', () => {
  it('supports explicit stretched single-frame dimensions and quality', () => {
    const plan = buildSingleThumbnailPlan({
      jobId: 'single-thumb',
      source: SOURCE,
      timeSeconds: 1.25,
      size: { width: 640, height: 360, maintainAspect: false },
      format: 'jpeg',
      quality: 85,
    });
    expect(plan.args).toContain('scale=640:360');
    expect(plan.args).not.toContain(
      'scale=640:360:force_original_aspect_ratio=decrease'
    );
    expect(plan.args).toEqual(
      expect.arrayContaining(['-q:v', '6', '-frames:v', '1'])
    );
  });

  it('generates ordered unique times from interval, count, and explicit selections', () => {
    expect(
      resolveThumbnailTimes({ durationSeconds: 10, intervalSeconds: 4 })
    ).toEqual([0, 4, 8]);
    expect(resolveThumbnailTimes({ durationSeconds: 10, count: 3 })).toEqual([
      2.5, 5, 7.5,
    ]);
    expect(
      resolveThumbnailTimes({ durationSeconds: 10, timesSeconds: [5, 1, 5] })
    ).toEqual([1, 5]);
  });

  it('builds one invocation for a series and declares every output', () => {
    const plan = buildThumbnailSeriesPlan({
      jobId: 'thumbs',
      source: SOURCE,
      durationSeconds: 10,
      count: 3,
      format: 'png',
      size: { width: 320 },
    });
    expect(plan.outputs).toHaveLength(3);
    expect(plan.args.filter((value) => value === '-i')).toHaveLength(1);
    expect(plan.args).toContain('-filter_complex');
    expect(plan.args.filter((value) => value === '-frames:v')).toHaveLength(3);
    for (const output of plan.outputs) {
      expect(plan.args).toContain(output.path);
    }
    expect(plan.args.some((value) => value.includes('%03d'))).toBe(false);
  });

  it('analyzes a bounded number of scene changes and parses exact timestamps', () => {
    const plan = buildSceneChangeAnalysisPlan({
      jobId: 'scenes',
      source: SOURCE,
      durationSeconds: 10,
      threshold: 0.35,
      maxFrames: 12,
    });
    expect(plan.outputs).toEqual([]);
    expect(plan.args).toEqual(
      expect.arrayContaining([
        "select='gt(scene\\,0.35)',showinfo",
        '-frames:v',
        '12',
        '-f',
        'null',
      ])
    );
    expect(plan.requiredCapabilities.filters).toEqual(['select', 'showinfo']);
    expect(
      parseSceneChangeFrames(`
[Parsed_showinfo_1 @ 0x1] n:   0 pts:   4096 pts_time:0.25 duration:512
n:   1 pts:  16384 pts_time:1 duration:512
[Parsed_showinfo_1 @ 0x1] n:   2 pts:  16384 pts_time:1 duration:512
invalid showinfo n: 3 pts: nope pts_time:2
`)
    ).toEqual([
      { pts: 4096, timeSeconds: 0.25 },
      { pts: 16384, timeSeconds: 1 },
    ]);
  });

  it('builds exact scene-selected thumbnails from analyzed PTS values', () => {
    const plan = buildSceneThumbnailSeriesPlan({
      jobId: 'scene-thumbs',
      source: SOURCE,
      durationSeconds: 10,
      frames: [
        { pts: 4096, timeSeconds: 0.25 },
        { pts: 16384, timeSeconds: 1 },
      ],
      format: 'png',
      size: { width: 320 },
    });
    expect(plan.outputs.map((output) => output.fileName)).toEqual([
      'source-thumbnail-001-250ms.png',
      'source-thumbnail-002-1000ms.png',
    ]);
    expect(plan.args.join(' ')).toContain(
      "select='eq(pts\\,4096)+eq(pts\\,16384)',split=2"
    );
    expect(plan.requiredCapabilities.filters).toEqual([
      'select',
      'split',
      'setpts',
      'scale',
    ]);
  });

  it('degrades contact-sheet labels when drawtext is unavailable', () => {
    const plan = buildContactSheetPlan({
      jobId: 'sheet',
      source: SOURCE,
      durationSeconds: 10,
      frameCount: 6,
      rows: 2,
      columns: 3,
      cellWidth: 240,
      timestampLabels: true,
      format: 'jpeg',
      drawtextAvailable: false,
    });
    expect(plan.diagnostics).toContainEqual(
      expect.objectContaining({
        code: 'labels-unavailable',
        severity: 'warning',
      })
    );
    expect(plan.requiredCapabilities.filters).not.toContain('drawtext');
  });

  it('uses a mounted reviewed font and exact scene frames for labels', () => {
    const plan = buildContactSheetPlan({
      jobId: 'scene-sheet',
      source: SOURCE,
      durationSeconds: 10,
      frameCount: 2,
      rows: 1,
      columns: 2,
      cellWidth: 240,
      timestampLabels: true,
      sourceLabel: true,
      format: 'jpeg',
      drawtextAvailable: true,
      font: {
        id: 'bundled-font',
        sourceIndex: 1,
        fileName: 'DejaVuSans.ttf',
      },
      sceneFrames: [
        { pts: 4096, timeSeconds: 0.25 },
        { pts: 16384, timeSeconds: 1 },
      ],
    });
    expect(plan.inputs[1]).toMatchObject({
      kind: 'font',
      sourceIndex: 1,
    });
    expect(plan.args.join(' ')).toContain(
      "select='eq(pts\\,4096)+eq(pts\\,16384)'"
    );
    expect(plan.args.join(' ')).toContain(
      "drawtext=fontfile='/input/job-scene-sheet/source-1.ttf'"
    );
    expect(plan.args.join(' ')).toContain('nb_frames=2');
    expect(plan.requiredCapabilities.filters).toEqual([
      'select',
      'scale',
      'setpts',
      'tile',
      'drawtext',
    ]);
  });
});

describe('composed visual operations', () => {
  it('creates palette-based GIF conversion instead of a raw encoder pass', () => {
    const plan = buildConvertPlan({
      jobId: 'gif',
      source: SOURCE,
      preset: findBuiltInPreset('animated-gif')!,
    });
    expect(plan.args).toContain('-filter_complex');
    expect(plan.args.find((value) => value.includes('palettegen'))).toContain(
      'paletteuse'
    );
    expect(plan.args).not.toContain('-vf');
  });

  it('wraps transforms and A/V fades into executable conversion plans', () => {
    const preset = findBuiltInPreset('mp4-h264-balanced')!;
    const transformed = buildTransformPlan({
      jobId: 'transform',
      source: SOURCE,
      preset,
      settings: {
        crop: {
          x: 0,
          y: 0,
          width: 640,
          height: 360,
          sourceWidth: 1280,
          sourceHeight: 720,
          requireEvenDimensions: true,
        },
        frameRate: 25,
      },
    });
    expect(transformed.args).toContain('crop=640:360:0:0,fps=25');
    const faded = buildFadePlan({
      jobId: 'fade',
      source: SOURCE,
      preset,
      durationSeconds: 10,
      audioFadeInSeconds: 1,
      videoFadeOutSeconds: 2,
      audioCurve: 'qsin',
      videoCurve: 'exp',
    });
    expect(faded.args.find((value) => value.startsWith('afade='))).toContain(
      'curve=qsin'
    );
    expect(faded.args.find((value) => value.startsWith('geq='))).toContain(
      'exp(5*'
    );
    expect(faded.requiredCapabilities.filters).toContain('geq');
    expect(() =>
      buildFadePlan({
        jobId: 'overlap',
        source: SOURCE,
        preset,
        durationSeconds: 2,
        audioFadeInSeconds: 1.5,
        audioFadeOutSeconds: 1,
      })
    ).toThrow(/overlap/u);
    expect(() =>
      buildFadePlan({
        jobId: 'unsupported-curve',
        source: SOURCE,
        preset,
        durationSeconds: 2,
        audioFadeInSeconds: 1,
        audioCurve: 'log' as 'tri',
      })
    ).toThrow(/tri, qsin, or exp/u);
  });
});
