import { describe, expect, it } from 'vitest';
import {
  buildConvertPlan,
  buildFastConcatPlan,
  buildNormalizedConcatPlan,
  buildPreviewProxyPlan,
  buildRemuxPlan,
  buildSplitPlan,
  buildStreamRemovalPlan,
  buildTimelineExportPlan,
  buildTrimPlan,
  calculateResize,
  createConcatList,
  createPlannedInput,
  createVideoFiltergraph,
  displayArguments,
  sourceVirtualPath,
  validateConcatCompatibility,
  validateCrop,
  validateSplitMarkers,
} from '../../src/commands';
import { findBuiltInPreset } from '../../src/presets';

const SOURCE = {
  id: 'source',
  sourceIndex: 0,
  fileName: "../../My holiday's clip.mp4",
  extension: 'mp4',
} as const;

describe('structured command plans', () => {
  it('generates safe deterministic virtual paths without source path disclosure', () => {
    expect(sourceVirtualPath('../job id', SOURCE)).toBe(
      '/input/job-job-id/source-0.mp4'
    );
    expect(createPlannedInput('abc', SOURCE)).toEqual({
      id: 'source',
      sourceIndex: 0,
      kind: 'media',
      path: '/input/job-abc/source-0.mp4',
    });
  });

  it('renders shell-like diagnostics without changing structured arguments', () => {
    const args = [
      '-i',
      "/input/a file's.mp4",
      '-metadata',
      'title=hello world',
    ];
    expect(displayArguments(args)).toBe(
      "-i '/input/a file'\\''s.mp4' -metadata 'title=hello world'"
    );
    expect(args).toEqual([
      '-i',
      "/input/a file's.mp4",
      '-metadata',
      'title=hello world',
    ]);
  });

  it('escapes generated concat paths and rejects line injection', () => {
    expect(createConcatList(['/input/source-0.mp4', "/input/it's.mp4"])).toBe(
      "file '/input/source-0.mp4'\nfile '/input/it'\\''s.mp4'\n"
    );
    expect(() =>
      createConcatList(["/input/a.mp4\nfile '/etc/passwd'"])
    ).toThrow(/line breaks/u);
  });

  it('composes conversion filters into one -vf argument', () => {
    const preset = findBuiltInPreset('mp4-h264-balanced');
    expect(preset).toBeDefined();
    const plan = buildConvertPlan({
      jobId: 'convert-1',
      source: SOURCE,
      preset: preset!,
      videoFiltergraph: 'crop=640:360:0:0,fade=t=in:st=0:d=1',
      audioFiltergraph: 'afade=t=in:st=0:d=1',
      expectedDurationSeconds: 10,
    });
    expect(plan.args.filter((argument) => argument === '-vf')).toHaveLength(1);
    expect(plan.args).toContain('crop=640:360:0:0,fade=t=in:st=0:d=1');
    expect(plan.args).toContain('afade=t=in:st=0:d=1');
    expect(plan.args.at(-1)).toMatch(/\.mp4$/u);
    expect(Object.isFrozen(plan.args)).toBe(true);
  });

  it('distinguishes fast and accurate trim argument placement', () => {
    const fast = buildTrimPlan({
      jobId: 'trim-fast',
      source: SOURCE,
      mode: 'fast',
      startSeconds: 1.25,
      endSeconds: 3.5,
      sourceDurationSeconds: 10,
      targetExtension: 'mp4',
    });
    expect(fast.args.slice(0, 7)).toEqual([
      '-ss',
      '1.25',
      '-to',
      '3.5',
      '-i',
      '/input/job-trim-fast/source-0.mp4',
      '-map',
    ]);
    expect(fast.args).toContain('copy');
    expect(fast.diagnostics[0]?.severity).toBe('warning');

    const preset = findBuiltInPreset('mp4-h264-balanced');
    const accurate = buildTrimPlan({
      jobId: 'trim-accurate',
      source: SOURCE,
      mode: 'accurate',
      startSeconds: 1.25,
      endSeconds: 3.5,
      preset: preset!,
    });
    expect(accurate.args.slice(0, 6)).toEqual([
      '-ss',
      '1.25',
      '-to',
      '3.5',
      '-i',
      '/input/job-trim-accurate/source-0.mp4',
    ]);
    expect(accurate.args).toContain('libx264');
  });

  it('validates and expands every supported split definition', () => {
    expect(validateSplitMarkers([8, 2, 5], 10)).toEqual([2, 5, 8]);
    expect(() => validateSplitMarkers([2, 2.01], 10)).toThrow(/apart/u);
    const plan = buildSplitPlan({
      jobId: 'split',
      source: SOURCE,
      durationSeconds: 10,
      definition: { type: 'part-count', parts: 3 },
      mode: 'fast',
      targetExtension: 'mp4',
    });
    expect(plan.outputs.map((output) => output.fileName)).toEqual([
      'My-holiday-s-clip-split-001.mp4',
      'My-holiday-s-clip-split-002.mp4',
      'My-holiday-s-clip-split-003.mp4',
    ]);
    expect(plan.outputs.map((output) => output.timeRange)).toEqual([
      { startSeconds: 0, endSeconds: 10 / 3 },
      { startSeconds: 10 / 3, endSeconds: 20 / 3 },
      { startSeconds: 20 / 3, endSeconds: 10 },
    ]);
    expect(
      plan.args.filter((argument) => argument === '-progress')
    ).toHaveLength(1);
    expect(plan.args.filter((argument) => argument === 'copy')).toHaveLength(3);
  });

  it('blocks incompatible remuxes before returning a plan', () => {
    expect(() =>
      buildRemuxPlan({
        jobId: 'remux',
        source: SOURCE,
        targetContainer: 'mp4',
        fileExtension: 'mp4',
        description: {
          streams: [{ index: 0, kind: 'video', codec: 'vp9' }],
        },
      })
    ).toThrow(/cannot be remuxed/u);
  });

  it('maps selected attachment and data streams during compatible remux', () => {
    const plan = buildRemuxPlan({
      jobId: 'remux-auxiliary',
      source: SOURCE,
      targetContainer: 'matroska',
      fileExtension: 'mkv',
      description: {
        streams: [
          { index: 2, kind: 'attachment', codec: 'ttf' },
          { index: 3, kind: 'data', codec: 'bin_data' },
        ],
      },
      streamSelection: {
        attachments: [2],
        data: [3],
      },
    });

    expect(plan.args).toEqual(
      expect.arrayContaining(['-map', '0:2', '-map', '0:3', '-c', 'copy'])
    );
  });

  it('maps only explicitly retained streams for stream removal', () => {
    const plan = buildStreamRemovalPlan({
      jobId: 'streams',
      source: SOURCE,
      selection: { video: [0], audio: [1], subtitles: [] },
      targetExtension: 'mkv',
      targetMuxer: 'matroska',
    });
    expect(plan.args).toEqual([
      '-i',
      '/input/job-streams/source-0.mp4',
      '-map',
      '0:0',
      '-map',
      '0:1',
      '-c',
      'copy',
      '-map_metadata',
      '0',
      '-map_chapters',
      '0',
      '/work/job-streams/My-holiday-s-clip-streams.mkv',
    ]);
  });
});

describe('concat and transform validation', () => {
  const STREAMS = [
    {
      kind: 'video' as const,
      codec: 'h264',
      width: 640,
      height: 360,
      pixelFormat: 'yuv420p',
      timeBase: '1/12800',
    },
    {
      kind: 'audio' as const,
      codec: 'aac',
      sampleRate: 48_000,
      channelLayout: 'stereo',
      timeBase: '1/48000',
    },
  ];
  const SOURCES = [
    {
      ...SOURCE,
      id: 'one',
      sourceIndex: 0,
      durationSeconds: 2,
      streams: STREAMS,
    },
    {
      ...SOURCE,
      id: 'two',
      sourceIndex: 1,
      fileName: 'two.mp4',
      durationSeconds: 3,
      streams: STREAMS,
    },
  ];

  it('checks exact fast-concat stream arrangement and properties', () => {
    expect(validateConcatCompatibility(SOURCES)).toEqual([]);
    expect(
      validateConcatCompatibility([
        SOURCES[0]!,
        {
          ...SOURCES[1]!,
          streams: [{ ...STREAMS[0]!, width: 1280 }, STREAMS[1]!],
        },
      ])[0]
    ).toMatchObject({ code: 'stream-property', severity: 'error' });
    const plan = buildFastConcatPlan({
      jobId: 'concat',
      sources: SOURCES,
      targetExtension: 'mp4',
      targetMuxer: 'mp4',
    });
    expect(plan.temporaryFiles[0]?.content).toBe(
      "file '/input/job-concat/source-0.mp4'\nfile '/input/job-concat/source-1.mp4'\n"
    );
    expect(plan.expectedDurationSeconds).toBe(5);
  });

  it('builds bounded chained audio/video crossfades for normalized concat', () => {
    const preset = findBuiltInPreset('mp4-h264-balanced');
    const sources = [
      { ...SOURCES[0]!, durationSeconds: 4 },
      { ...SOURCES[1]!, durationSeconds: 5 },
      {
        ...SOURCES[1]!,
        id: 'three',
        sourceIndex: 2,
        fileName: 'three.mp4',
        durationSeconds: 3,
      },
    ];
    const plan = buildNormalizedConcatPlan({
      jobId: 'crossfade',
      sources,
      targetExtension: 'mp4',
      targetMuxer: 'mp4',
      preset: preset!,
      width: 640,
      height: 360,
      frameRate: 25,
      sampleRate: 48_000,
      channelLayout: 'stereo',
      missingAudioPolicy: 'reject',
      subtitlePolicy: 'reject',
      crossfadeSeconds: 0.5,
    });
    const graph = plan.args[plan.args.indexOf('-filter_complex') + 1] ?? '';
    expect(graph).toContain(
      '[v0][v1]xfade=transition=fade:duration=0.5:offset=3.5[vxf1]'
    );
    expect(graph).toContain(
      '[vxf1][v2]xfade=transition=fade:duration=0.5:offset=8[vout]'
    );
    expect(graph).toContain('[a0][a1]acrossfade=d=0.5:c1=tri:c2=tri[axf1]');
    expect(graph).not.toContain('concat=n=3');
    expect(plan.expectedDurationSeconds).toBe(11);
    expect(plan.requiredCapabilities.filters).toEqual(
      expect.arrayContaining(['xfade', 'acrossfade'])
    );
    expect(() =>
      buildNormalizedConcatPlan({
        jobId: 'too-long-crossfade',
        sources: SOURCES,
        targetExtension: 'mp4',
        targetMuxer: 'mp4',
        preset: preset!,
        width: 640,
        height: 360,
        frameRate: 25,
        missingAudioPolicy: 'reject',
        subtitlePolicy: 'reject',
        crossfadeSeconds: 2.5,
      })
    ).toThrow(/Clip 1 needs at least/iu);
  });

  it('corrects crop to even bounds and calculates aspect-preserving resize', () => {
    expect(
      validateCrop({
        x: 3,
        y: 5,
        width: 101,
        height: 51,
        sourceWidth: 200,
        sourceHeight: 100,
        requireEvenDimensions: true,
      })
    ).toMatchObject({ x: 2, y: 4, width: 100, height: 50 });
    expect(
      calculateResize({
        sourceWidth: 1920,
        sourceHeight: 1080,
        width: 640,
        height: 640,
        mode: 'contain',
      })
    ).toEqual({ width: 640, height: 360 });
  });

  it('orders typed filter stages independently of insertion order', () => {
    const builder = createVideoFiltergraph()
      .add({ id: 'fade', stage: 'fade', expression: 'fade=t=in:d=1' })
      .add({ id: 'crop', stage: 'crop', expression: 'crop=100:100:0:0' })
      .add({ id: 'fps', stage: 'frame-rate', expression: 'fps=25' });
    expect(builder.build().graph).toBe('crop=100:100:0:0,fps=25,fade=t=in:d=1');
  });

  it('builds a bounded preview proxy with optional audio mapping', () => {
    const plan = buildPreviewProxyPlan({
      jobId: 'preview',
      source: SOURCE,
      sourceDurationSeconds: 120,
      startSeconds: 10,
    });
    expect(plan.expectedDurationSeconds).toBe(30);
    expect(plan.args).toContain('0:a:0?');
    expect(plan.args).toContain("scale=w='max(2,trunc(min(960,iw)/2)*2)':h=-2");
  });

  it('builds a bounded AAC/M4A proxy for an audio-only source', () => {
    const plan = buildPreviewProxyPlan({
      jobId: 'audio-preview',
      source: SOURCE,
      sourceDurationSeconds: 90,
      maxDurationSeconds: 20,
      hasVideo: false,
    });

    expect(plan.expectedDurationSeconds).toBe(20);
    expect(plan.outputs[0]?.fileName).toMatch(/-preview\.m4a$/u);
    expect(plan.outputs[0]?.mediaType).toBe('audio/mp4');
    expect(plan.args).toContain('0:a:0');
    expect(plan.args).toContain('aac');
    expect(plan.args).not.toContain('0:v:0');
    expect(plan.args).not.toContain('libx264');
    expect(plan.args).not.toContain('-vf');
    expect(plan.requiredCapabilities).toMatchObject({
      muxers: ['ipod'],
      encoders: ['aac'],
      filters: [],
    });
  });

  it('builds one normalized timeline filter graph', () => {
    const preset = findBuiltInPreset('mp4-h264-balanced');
    const plan = buildTimelineExportPlan({
      jobId: 'timeline',
      preset: preset!,
      outputWidth: 640,
      outputHeight: 360,
      frameRate: 25,
      missingAudioPolicy: 'insert-silence',
      outputFileName: '../../My final cut.mov',
      clips: [
        {
          id: 'one',
          source: SOURCES[0]!,
          sourceInSeconds: 0.25,
          sourceOutSeconds: 1.75,
          hasVideo: true,
          hasAudio: true,
          audioFadeInSeconds: 0.25,
          audioFadeCurve: 'qsin',
          videoFadeOutSeconds: 0.25,
          videoFadeCurve: 'exp',
        },
        {
          id: 'two',
          source: SOURCES[1]!,
          sourceInSeconds: 0,
          sourceOutSeconds: 2,
          hasVideo: true,
          hasAudio: false,
        },
      ],
    });
    expect(
      plan.args.filter((argument) => argument === '-filter_complex')
    ).toHaveLength(1);
    const graph = plan.args[plan.args.indexOf('-filter_complex') + 1];
    expect(graph).toContain('anullsrc=');
    expect(graph).toContain('curve=qsin');
    expect(graph).toContain("geq=lum_expr='lum(X,Y)*((exp(5*");
    expect(graph).toContain('concat=n=2:v=1:a=1[vout][aout]');
    expect(plan.requiredCapabilities.filters).toContain('geq');
    expect(plan.expectedDurationSeconds).toBe(3.5);
    expect(plan.outputs[0]?.fileName).toBe('My-final-cut.mp4');
    expect(plan.outputs[0]?.path).not.toContain('..');
  });
});
