import { describe, it, expect } from 'vitest'
import {
  buildArgs,
  parseExtraArgs,
  parseTrimSections,
  selectExtraArgs,
  formatCommandLine,
  sanitizeDirSegment,
  collectionOutputTemplate,
  CROP_SQUARE_PPA,
  ARIA2C_ARGS,
  aria2cArgs,
  DEST_MARKER,
  type AccessOptions
} from '../src/main/core/buildArgs'
import {
  DEFAULT_DOWNLOAD_OPTIONS,
  type DownloadOptions,
  type StartDownloadOptions
} from '@shared/ipc'

// --- Fixtures ---------------------------------------------------------------

const BIN = 'C:/fake/bin'
const OUT = 'C:/out/%(title)s.%(ext)s'
const URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
const NO_ACCESS: AccessOptions = { proxy: '', rateLimit: '', restrictFilenames: false }

function opts(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOptions {
  return { id: 't', url: URL, kind: 'video', quality: '1080p', ...overrides }
}
function audio(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOptions {
  return opts({ kind: 'audio', quality: 'Best', ...overrides })
}
function dlo(overrides: Partial<DownloadOptions> = {}): DownloadOptions {
  return { ...DEFAULT_DOWNLOAD_OPTIONS, ...overrides }
}
function build(
  o: StartDownloadOptions,
  d: DownloadOptions,
  access: AccessOptions = NO_ACCESS,
  extraArgs: string[] = []
): string[] {
  return buildArgs({ opts: o, outputTemplate: OUT, options: d, binDir: BIN, access, extraArgs })
}

/** True when `seq` appears as a contiguous run inside `argv`. */
function hasSeq(argv: string[], ...seq: string[]): boolean {
  if (seq.length === 0) return true
  for (let i = 0; i + seq.length <= argv.length; i++) {
    if (seq.every((s, j) => argv[i + j] === s)) return true
  }
  return false
}

// --- Invariants shared by every recipe --------------------------------------

describe('buildArgs scaffolding', () => {
  it('pins ffmpeg location to the supplied binDir', () => {
    expect(hasSeq(build(opts(), dlo()), '--ffmpeg-location', BIN)).toBe(true)
  })

  it('terminates options with `--` immediately before the URL', () => {
    const argv = build(opts(), dlo())
    expect(argv[argv.length - 2]).toBe('--')
    expect(argv[argv.length - 1]).toBe(URL)
  })

  it('prints the before_dl destination (dest|) so a cancel can locate partials (R4)', () => {
    // download.ts's cancel cleanup depends on this print to know the output stem;
    // removing it would silently disable orphan-.part deletion.
    expect(hasSeq(build(opts(), dlo()), '--print', `before_dl:${DEST_MARKER}%(filename)s`)).toBe(
      true
    )
  })
})

// --- Network: proxy / rate limit / aria2c -----------------------------------

describe('network options', () => {
  it('emits nothing when proxy/rateLimit are empty and aria2c is unset', () => {
    const argv = build(opts(), dlo(), NO_ACCESS)
    expect(argv).not.toContain('--proxy')
    expect(argv).not.toContain('--limit-rate')
    expect(argv).not.toContain('--downloader')
  })

  it('emits --proxy with the trimmed value', () => {
    const argv = build(opts(), dlo(), { ...NO_ACCESS, proxy: '  socks5://127.0.0.1:1080  ' })
    expect(hasSeq(argv, '--proxy', 'socks5://127.0.0.1:1080')).toBe(true)
  })

  it('emits --limit-rate with the trimmed value', () => {
    const argv = build(opts(), dlo(), { ...NO_ACCESS, rateLimit: ' 2M ' })
    expect(hasSeq(argv, '--limit-rate', '2M')).toBe(true)
  })

  it('emits --downloader <path> --downloader-args when an aria2c path is given', () => {
    const argv = build(opts(), dlo(), { ...NO_ACCESS, aria2cPath: 'C:/fake/bin/aria2c.exe' })
    expect(
      hasSeq(argv, '--downloader', 'C:/fake/bin/aria2c.exe', '--downloader-args', ARIA2C_ARGS)
    ).toBe(true)
  })

  it('omits --downloader when aria2cPath is not set', () => {
    const argv = build(opts(), dlo(), NO_ACCESS)
    expect(argv).not.toContain('--downloader-args')
  })

  it('threads a custom aria2c connection count into --downloader-args (L64)', () => {
    const argv = build(opts(), dlo(), {
      ...NO_ACCESS,
      aria2cPath: 'C:/fake/bin/aria2c.exe',
      aria2cConnections: 4
    })
    expect(hasSeq(argv, '--downloader-args', 'aria2c:-x 4 -s 4 -k 1M')).toBe(true)
  })

  it('aria2cArgs clamps to aria2c’s 1–16 ceiling and defaults to 16 (L64)', () => {
    expect(aria2cArgs()).toBe('aria2c:-x 16 -s 16 -k 1M')
    expect(aria2cArgs(8)).toBe('aria2c:-x 8 -s 8 -k 1M')
    expect(aria2cArgs(0)).toBe('aria2c:-x 1 -s 1 -k 1M') // below floor → 1
    expect(aria2cArgs(99)).toBe('aria2c:-x 16 -s 16 -k 1M') // above ceiling → 16
    expect(aria2cArgs(7.6)).toBe('aria2c:-x 8 -s 8 -k 1M') // rounds
    expect(aria2cArgs(NaN)).toBe('aria2c:-x 16 -s 16 -k 1M') // non-finite → default
    expect(ARIA2C_ARGS).toBe(aria2cArgs()) // the exported default stays in sync
  })
})

// --- Request throttle: sleep-requests / sleep-interval ----------------------

describe('throttle options', () => {
  it('emits no sleep flags when all throttle values are 0/unset', () => {
    const argv = build(opts(), dlo(), NO_ACCESS)
    expect(argv).not.toContain('--sleep-requests')
    expect(argv).not.toContain('--sleep-interval')
    expect(argv).not.toContain('--max-sleep-interval')
  })

  it('emits --sleep-requests when sleepRequests > 0', () => {
    const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepRequests: 3 })
    expect(hasSeq(argv, '--sleep-requests', '3')).toBe(true)
  })

  it('emits --sleep-interval when sleepInterval > 0', () => {
    const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 5 })
    expect(hasSeq(argv, '--sleep-interval', '5')).toBe(true)
    expect(argv).not.toContain('--max-sleep-interval')
  })

  it('emits --max-sleep-interval only when it exceeds a non-zero sleepInterval', () => {
    const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 5, maxSleepInterval: 10 })
    expect(hasSeq(argv, '--sleep-interval', '5')).toBe(true)
    expect(hasSeq(argv, '--max-sleep-interval', '10')).toBe(true)
  })

  it('drops --max-sleep-interval when sleepInterval is 0 (yt-dlp needs the min)', () => {
    const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 0, maxSleepInterval: 10 })
    expect(argv).not.toContain('--sleep-interval')
    expect(argv).not.toContain('--max-sleep-interval')
  })

  it('drops --max-sleep-interval when it is not above the min', () => {
    const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 8, maxSleepInterval: 5 })
    expect(hasSeq(argv, '--sleep-interval', '8')).toBe(true)
    expect(argv).not.toContain('--max-sleep-interval')
  })
})

// --- Cookies: browser store vs. sign-in window's exported file --------------

describe('cookies', () => {
  it('emits nothing when no cookie source is configured', () => {
    const argv = build(opts(), dlo(), NO_ACCESS)
    expect(argv).not.toContain('--cookies-from-browser')
    expect(argv).not.toContain('--cookies')
  })

  it('emits --cookies-from-browser <name> when cookiesFromBrowser is set', () => {
    const argv = build(opts(), dlo(), { ...NO_ACCESS, cookiesFromBrowser: 'firefox' })
    expect(hasSeq(argv, '--cookies-from-browser', 'firefox')).toBe(true)
    expect(argv).not.toContain('--cookies')
  })

  it('emits --cookies <path> when only cookiesFile is set', () => {
    const argv = build(opts(), dlo(), { ...NO_ACCESS, cookiesFile: 'C:/userdata/cookies.txt' })
    expect(hasSeq(argv, '--cookies', 'C:/userdata/cookies.txt')).toBe(true)
    expect(argv).not.toContain('--cookies-from-browser')
  })

  it('prefers cookiesFromBrowser over cookiesFile when both are set', () => {
    const argv = build(opts(), dlo(), {
      ...NO_ACCESS,
      cookiesFromBrowser: 'chrome',
      cookiesFile: 'C:/userdata/cookies.txt'
    })
    expect(hasSeq(argv, '--cookies-from-browser', 'chrome')).toBe(true)
    expect(argv).not.toContain('--cookies')
  })
})

// --- Restrict filenames / download archive -----------------------------------

describe('restrictFilenames / downloadArchivePath', () => {
  it('emits --restrict-filenames when set', () => {
    expect(build(opts(), dlo(), { ...NO_ACCESS, restrictFilenames: true })).toContain(
      '--restrict-filenames'
    )
  })

  it('omits --restrict-filenames by default', () => {
    expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--restrict-filenames')
  })

  it('emits --download-archive <path> when a path is given', () => {
    const argv = build(opts(), dlo(), {
      ...NO_ACCESS,
      downloadArchivePath: 'C:/userdata/archive.txt'
    })
    expect(hasSeq(argv, '--download-archive', 'C:/userdata/archive.txt')).toBe(true)
  })

  it('omits --download-archive when no path is given', () => {
    expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--download-archive')
  })
})

// --- Audio extraction format ------------------------------------------------

describe('audio: -x --audio-format <fmt>', () => {
  it.each(['mp3', 'opus', 'flac'] as const)('emits -x --audio-format %s', (fmt) => {
    const argv = build(audio(), dlo({ audioFormat: fmt }))
    expect(hasSeq(argv, '-x', '--audio-format', fmt)).toBe(true)
  })

  it('maps the quality label to an --audio-quality value', () => {
    expect(hasSeq(build(audio({ quality: 'Best' }), dlo()), '--audio-quality', '0')).toBe(true)
    expect(hasSeq(build(audio({ quality: '320 kbps' }), dlo()), '--audio-quality', '320K')).toBe(
      true
    )
    expect(hasSeq(build(audio({ quality: '128 kbps' }), dlo()), '--audio-quality', '128K')).toBe(
      true
    )
  })
})

// --- Subtitles (video only) -------------------------------------------------

describe('embedSubtitles', () => {
  it('emits write/embed/sub-langs/convert without auto-subs by default', () => {
    const argv = build(opts(), dlo({ embedSubtitles: true }))
    expect(
      hasSeq(argv, '--write-subs', '--embed-subs', '--sub-langs', 'en', '--convert-subs', 'srt')
    ).toBe(true)
    expect(argv).not.toContain('--write-auto-subs')
  })

  it('inserts --write-auto-subs before --convert-subs when autoSubtitles is on', () => {
    const argv = build(opts(), dlo({ embedSubtitles: true, autoSubtitles: true }))
    expect(
      hasSeq(
        argv,
        '--write-subs',
        '--embed-subs',
        '--sub-langs',
        'en',
        '--write-auto-subs',
        '--convert-subs',
        'srt'
      )
    ).toBe(true)
  })

  it('honours a custom subtitleLanguages selector', () => {
    const argv = build(opts(), dlo({ embedSubtitles: true, subtitleLanguages: 'en.*,es' }))
    expect(hasSeq(argv, '--sub-langs', 'en.*,es')).toBe(true)
  })

  it('never writes subtitles for an audio download', () => {
    const argv = build(audio(), dlo({ embedSubtitles: true, autoSubtitles: true }))
    expect(argv).not.toContain('--write-subs')
    expect(argv).not.toContain('--embed-subs')
  })
})

// --- SponsorBlock -----------------------------------------------------------

describe('sponsorBlock', () => {
  it('remove → --sponsorblock-remove <cats> --force-keyframes-at-cuts', () => {
    const argv = build(
      opts(),
      dlo({ sponsorBlock: true, sponsorBlockMode: 'remove', sponsorBlockCategories: ['sponsor'] })
    )
    expect(hasSeq(argv, '--sponsorblock-remove', 'sponsor', '--force-keyframes-at-cuts')).toBe(true)
    expect(argv).not.toContain('--sponsorblock-mark')
  })

  it('mark → --sponsorblock-mark <cats>, no keyframe forcing', () => {
    const argv = build(
      opts(),
      dlo({ sponsorBlock: true, sponsorBlockMode: 'mark', sponsorBlockCategories: ['sponsor'] })
    )
    expect(hasSeq(argv, '--sponsorblock-mark', 'sponsor')).toBe(true)
    expect(argv).not.toContain('--sponsorblock-remove')
    expect(argv).not.toContain('--force-keyframes-at-cuts')
  })

  it('joins multiple categories with commas', () => {
    const argv = build(
      opts(),
      dlo({ sponsorBlock: true, sponsorBlockCategories: ['sponsor', 'intro', 'outro'] })
    )
    expect(hasSeq(argv, '--sponsorblock-remove', 'sponsor,intro,outro')).toBe(true)
  })

  it('emits nothing when sponsorBlock is off or category list is empty', () => {
    expect(build(opts(), dlo({ sponsorBlock: false })).join(' ')).not.toContain('sponsorblock')
    expect(
      build(opts(), dlo({ sponsorBlock: true, sponsorBlockCategories: [] })).join(' ')
    ).not.toContain('sponsorblock')
  })
})

// --- Preferred video codec (sort tiebreaker) --------------------------------

describe('preferredVideoCodec → -S res,fps,vcodec:<token>', () => {
  it('maps av1 to the av01 codec token', () => {
    expect(
      hasSeq(build(opts(), dlo({ preferredVideoCodec: 'av1' })), '-S', 'res,fps,vcodec:av01')
    ).toBe(true)
  })

  it('passes h264 / vp9 through verbatim', () => {
    expect(
      hasSeq(build(opts(), dlo({ preferredVideoCodec: 'h264' })), '-S', 'res,fps,vcodec:h264')
    ).toBe(true)
    expect(
      hasSeq(build(opts(), dlo({ preferredVideoCodec: 'vp9' })), '-S', 'res,fps,vcodec:vp9')
    ).toBe(true)
  })

  it('omits -S entirely when codec preference is "any"', () => {
    expect(build(opts(), dlo({ preferredVideoCodec: 'any' }))).not.toContain('-S')
  })
})

// --- Cropped square cover art (the highest-risk ffmpeg quoting) --------------

describe('cropThumbnail → --ppa crop', () => {
  it('emits --embed-thumbnail then the square-crop --ppa string', () => {
    const argv = build(audio(), dlo({ embedThumbnail: true, cropThumbnail: true }))
    expect(hasSeq(argv, '--embed-thumbnail', '--ppa', CROP_SQUARE_PPA)).toBe(true)
  })

  it('targets the EmbedThumbnail post-processor and uses ffmpeg output args (L39)', () => {
    // Structural test: verify the key invariants without locking in the exact string,
    // so minor whitespace or quoting style tweaks don't break CI.
    expect(CROP_SQUARE_PPA).toMatch(/^EmbedThumbnail\+ffmpeg_o:/)
    expect(CROP_SQUARE_PPA).toContain('-c:v mjpeg')
    expect(CROP_SQUARE_PPA).toMatch(/-vf\s+crop=/)
    // Commas inside gt() MUST be inside quotes so ffmpeg doesn't split on them.
    expect(CROP_SQUARE_PPA).toMatch(/crop="[^"]*,/)
  })

  it('does not add --ppa when cropThumbnail is off', () => {
    const argv = build(audio(), dlo({ embedThumbnail: true, cropThumbnail: false }))
    expect(argv).not.toContain('--ppa')
    expect(argv).toContain('--embed-thumbnail')
  })

  it('does not add --ppa when the thumbnail itself is not embedded', () => {
    const argv = build(audio(), dlo({ embedThumbnail: false, cropThumbnail: true }))
    expect(argv).not.toContain('--ppa')
    expect(argv).not.toContain('--embed-thumbnail')
  })

  it('suppresses --embed-thumbnail for webm (not supported by the container, L38)', () => {
    const argv = build(opts(), dlo({ embedThumbnail: true, videoContainer: 'webm' }))
    expect(argv).not.toContain('--embed-thumbnail')
  })

  it('keeps --embed-thumbnail for mp4 container (L38)', () => {
    const argv = build(opts(), dlo({ embedThumbnail: true, videoContainer: 'mp4' }))
    expect(argv).toContain('--embed-thumbnail')
  })
})

// --- Custom commands (Phase C): extra args + display formatting ------------

describe('parseExtraArgs', () => {
  it('splits on whitespace', () => {
    expect(parseExtraArgs('--write-thumbnail --no-mtime')).toEqual([
      '--write-thumbnail',
      '--no-mtime'
    ])
  })

  it('keeps a double-quoted span with spaces as one token', () => {
    expect(parseExtraArgs('--proxy "http://example.com:8080"')).toEqual([
      '--proxy',
      'http://example.com:8080'
    ])
  })

  it('keeps a single-quoted span with spaces as one token', () => {
    expect(parseExtraArgs("--output-na 'My Title - %(id)s.%(ext)s'")).toEqual([
      '--output-na',
      'My Title - %(id)s.%(ext)s'
    ])
  })

  it('returns an empty array for blank input', () => {
    expect(parseExtraArgs('')).toEqual([])
    expect(parseExtraArgs('   ')).toEqual([])
  })

  it('collapses repeated whitespace between tokens', () => {
    expect(parseExtraArgs('--verbose    --no-progress')).toEqual(['--verbose', '--no-progress'])
  })
})

describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
  const templates = [
    { id: 'thumb', args: '--write-thumbnail' },
    { id: 'danger', args: '--exec "calc.exe"' }
  ]

  it('returns [] when custom commands are disabled, even with a per-download override', () => {
    // The core fix: a renderer-supplied extraArgs must NOT run while the gate is off.
    expect(
      selectExtraArgs({
        enableCustomCommands: false,
        perDownloadExtraArgs: '--exec "calc.exe"',
        defaultTemplateId: 'danger',
        templates
      })
    ).toEqual([])
  })

  it('returns [] when disabled even if a default template id is set', () => {
    expect(
      selectExtraArgs({
        enableCustomCommands: false,
        perDownloadExtraArgs: undefined,
        defaultTemplateId: 'thumb',
        templates
      })
    ).toEqual([])
  })

  it('parses a per-download override when enabled', () => {
    expect(
      selectExtraArgs({
        enableCustomCommands: true,
        perDownloadExtraArgs: '--write-thumbnail --no-mtime',
        defaultTemplateId: null,
        templates
      })
    ).toEqual(['--write-thumbnail', '--no-mtime'])
  })

  it('an explicit empty override yields [] even with a default template set', () => {
    expect(
      selectExtraArgs({
        enableCustomCommands: true,
        perDownloadExtraArgs: '',
        defaultTemplateId: 'thumb',
        templates
      })
    ).toEqual([])
  })

  it('falls back to the default template when no per-download override is given', () => {
    expect(
      selectExtraArgs({
        enableCustomCommands: true,
        perDownloadExtraArgs: undefined,
        defaultTemplateId: 'thumb',
        templates
      })
    ).toEqual(['--write-thumbnail'])
  })

  it('returns [] when the default template id matches nothing', () => {
    expect(
      selectExtraArgs({
        enableCustomCommands: true,
        perDownloadExtraArgs: undefined,
        defaultTemplateId: 'missing',
        templates
      })
    ).toEqual([])
  })
})

describe('formatCommandLine', () => {
  it('joins the exe and args with spaces when nothing needs quoting', () => {
    expect(formatCommandLine('yt-dlp.exe', ['-f', 'best', '--', 'https://x.test/v'])).toBe(
      'yt-dlp.exe -f best -- https://x.test/v'
    )
  })

  it('quotes an arg containing a space', () => {
    expect(formatCommandLine('yt-dlp.exe', ['--ppa', 'EmbedThumbnail+ffmpeg_o:-c:v mjpeg'])).toBe(
      'yt-dlp.exe --ppa "EmbedThumbnail+ffmpeg_o:-c:v mjpeg"'
    )
  })

  it('escapes embedded double quotes', () => {
    expect(formatCommandLine('yt-dlp.exe', ['say "hi"'])).toBe('yt-dlp.exe "say \\"hi\\""')
  })

  it('renders an empty-string arg as ""', () => {
    expect(formatCommandLine('yt-dlp.exe', [''])).toBe('yt-dlp.exe ""')
  })
})

describe('buildArgs extraArgs', () => {
  it('appends extraArgs after every other option, still before -- url', () => {
    const argv = build(opts(), dlo(), NO_ACCESS, ['--write-thumbnail', '--no-mtime'])
    expect(hasSeq(argv, '--write-thumbnail', '--no-mtime', '--', URL)).toBe(true)
  })

  it('omits nothing extra when extraArgs is empty', () => {
    const withEmpty = build(opts(), dlo(), NO_ACCESS, [])
    const withoutParam = build(opts(), dlo())
    expect(withEmpty).toEqual(withoutParam)
  })
})

// --- Media-server sidecar files (Phase K) -----------------------------------

describe('sidecar files', () => {
  it('emits --write-info-json / --write-thumbnail / --write-description when enabled', () => {
    const argv = build(
      opts(),
      dlo({ writeInfoJson: true, writeThumbnailFile: true, writeDescription: true })
    )
    expect(argv).toContain('--write-info-json')
    expect(argv).toContain('--write-thumbnail')
    expect(argv).toContain('--write-description')
  })

  it('emits none of them by default', () => {
    const argv = build(opts(), dlo())
    expect(argv).not.toContain('--write-info-json')
    expect(argv).not.toContain('--write-description')
    // (--write-thumbnail is sidecar-only here; embedThumbnail uses --embed-thumbnail)
    expect(argv).not.toContain('--write-thumbnail')
  })
})

describe('YouTube extractor-args', () => {
  it('combines player_client and po_token into one youtube: group', () => {
    const argv = build(opts(), dlo(), {
      ...NO_ACCESS,
      youtubePlayerClient: 'web_safari',
      youtubePoToken: 'web.gvs+ABC'
    })
    expect(
      hasSeq(argv, '--extractor-args', 'youtube:player_client=web_safari;po_token=web.gvs+ABC')
    ).toBe(true)
  })

  it('emits only the fields that are set', () => {
    const argv = build(opts(), dlo(), { ...NO_ACCESS, youtubePlayerClient: 'tv' })
    expect(hasSeq(argv, '--extractor-args', 'youtube:player_client=tv')).toBe(true)
  })

  it('emits nothing when both are empty', () => {
    expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--extractor-args')
  })
})

describe('format sorting (-S)', () => {
  it('emits a raw -S string when formatSort is set, overriding the codec tiebreaker', () => {
    const argv = build(
      opts(),
      dlo({ formatSort: 'res:1080,vcodec:av01', preferredVideoCodec: 'h264' })
    )
    expect(hasSeq(argv, '-S', 'res:1080,vcodec:av01')).toBe(true)
    expect(hasSeq(argv, '-S', 'res,fps,vcodec:h264')).toBe(false)
  })

  it('falls back to the codec tiebreaker when formatSort is empty', () => {
    const argv = build(opts(), dlo({ formatSort: '', preferredVideoCodec: 'vp9' }))
    expect(hasSeq(argv, '-S', 'res,fps,vcodec:vp9')).toBe(true)
  })
})

describe('selectExtraArgs url-pattern matching', () => {
  const base = {
    enableCustomCommands: true,
    perDownloadExtraArgs: undefined,
    defaultTemplateId: null as string | null
  }
  const templates = [
    { id: 'a', args: '--audio-quality 0', urlPattern: 'soundcloud\\.com' },
    { id: 'b', args: '--write-thumbnail' }
  ]

  it('auto-applies a template whose urlPattern matches the URL', () => {
    const out = selectExtraArgs({ ...base, templates, url: 'https://soundcloud.com/x/y' })
    expect(out).toEqual(['--audio-quality', '0'])
  })

  it('does not apply a urlPattern template to a non-matching URL', () => {
    const out = selectExtraArgs({ ...base, templates, url: 'https://youtube.com/watch?v=x' })
    expect(out).toEqual([])
  })

  it('urlPattern match takes precedence over the global default template', () => {
    const out = selectExtraArgs({
      ...base,
      defaultTemplateId: 'b',
      templates,
      url: 'https://soundcloud.com/x'
    })
    expect(out).toEqual(['--audio-quality', '0'])
  })

  it('a bad regex never matches (and is skipped safely)', () => {
    const out = selectExtraArgs({
      ...base,
      templates: [{ id: 'c', args: '--x', urlPattern: '(' }],
      url: 'https://soundcloud.com/x'
    })
    expect(out).toEqual([])
  })
})

describe('chapters', () => {
  it('emits --split-chapters when splitChapters is on', () => {
    expect(build(opts(), dlo({ splitChapters: true }))).toContain('--split-chapters')
  })

  it('does not emit --split-chapters by default', () => {
    expect(build(opts(), dlo())).not.toContain('--split-chapters')
  })

  it('emits --split-chapters independently of --embed-chapters', () => {
    const argv = build(opts(), dlo({ splitChapters: true, embedChapters: false }))
    expect(argv).toContain('--split-chapters')
    expect(argv).not.toContain('--embed-chapters')
  })
})

describe('parseTrimSections', () => {
  it('normalises plain time ranges to *START-END specs', () => {
    expect(parseTrimSections('1:30-2:00')).toEqual(['*1:30-2:00'])
    expect(parseTrimSections('90-120')).toEqual(['*90-120'])
    expect(parseTrimSections('0:00:10.5-0:00:20')).toEqual(['*0:00:10.5-0:00:20'])
  })

  it('splits on commas and newlines and trims whitespace', () => {
    expect(parseTrimSections(' 0:30-1:00 , 2:00-2:30 \n 3:00-3:30 ')).toEqual([
      '*0:30-1:00',
      '*2:00-2:30',
      '*3:00-3:30'
    ])
  })

  it('keeps an existing leading * but does not double it', () => {
    expect(parseTrimSections('*1:00-2:00')).toEqual(['*1:00-2:00'])
  })

  it('drops malformed tokens so garbage never reaches the argv', () => {
    expect(parseTrimSections('not-a-range')).toEqual([])
    expect(parseTrimSections('1:30')).toEqual([]) // no end
    expect(parseTrimSections('')).toEqual([])
    expect(parseTrimSections(undefined)).toEqual([])
  })

  it('rejects times with more than two colon groups (L146)', () => {
    // H:MM:SS is the deepest valid form; a fourth component is nonsense and used
    // to slip through to yt-dlp, failing there instead of being rejected here.
    expect(parseTrimSections('1:2:3:4-5:6:7:8')).toEqual([])
    expect(parseTrimSections('0:00:00:01-0:00:00:02')).toEqual([])
    // The deepest valid form still passes.
    expect(parseTrimSections('1:02:03-1:02:04')).toEqual(['*1:02:03-1:02:04'])
  })
})

describe('trim (--download-sections)', () => {
  it('emits a --download-sections spec per range plus --force-keyframes-at-cuts', () => {
    const argv = build(opts({ trim: '0:30-1:00, 2:00-2:30' }), dlo())
    expect(hasSeq(argv, '--download-sections', '*0:30-1:00')).toBe(true)
    expect(hasSeq(argv, '--download-sections', '*2:00-2:30')).toBe(true)
    expect(argv).toContain('--force-keyframes-at-cuts')
  })

  it('emits nothing when there is no trim', () => {
    const argv = build(opts(), dlo())
    expect(argv).not.toContain('--download-sections')
  })

  it('does not double --force-keyframes-at-cuts when SponsorBlock-remove is also on', () => {
    const argv = build(
      opts({ trim: '0:30-1:00' }),
      dlo({ sponsorBlock: true, sponsorBlockMode: 'remove', sponsorBlockCategories: ['sponsor'] })
    )
    expect(argv.filter((a) => a === '--force-keyframes-at-cuts')).toHaveLength(1)
  })
})

// --- Collection folder paths (Phase G, media-manager) -----------------------

describe('sanitizeDirSegment', () => {
  it('keeps an ordinary name (incl. spaces and hyphens) intact', () => {
    expect(sanitizeDirSegment('Linus Tech Tips')).toBe('Linus Tech Tips')
    expect(sanitizeDirSegment('Two-Minute Papers')).toBe('Two-Minute Papers')
  })

  it('replaces Windows-illegal characters with a space and collapses runs', () => {
    expect(sanitizeDirSegment('A/B\\C:D*E?F')).toBe('A B C D E F')
    expect(sanitizeDirSegment('Best of 2024 <Live>')).toBe('Best of 2024 Live')
  })

  it('strips leading/trailing dots and spaces, neutralising `..` traversal', () => {
    expect(sanitizeDirSegment('..')).toBe('Untitled')
    expect(sanitizeDirSegment('../../etc')).toBe('etc')
    expect(sanitizeDirSegment('  trailing.  ')).toBe('trailing')
    expect(sanitizeDirSegment('My Playlist.')).toBe('My Playlist')
  })

  it('prefixes reserved Windows device names', () => {
    expect(sanitizeDirSegment('CON')).toBe('_CON')
    expect(sanitizeDirSegment('com1')).toBe('_com1')
  })

  it('prefixes a reserved device name even when it carries an extension (audit T3)', () => {
    expect(sanitizeDirSegment('CON.txt')).toBe('_CON.txt')
    expect(sanitizeDirSegment('nul.mp4')).toBe('_nul.mp4')
    expect(sanitizeDirSegment('LPT1.foo.bar')).toBe('_LPT1.foo.bar')
    // a non-reserved name that merely starts with similar letters is left alone
    expect(sanitizeDirSegment('console.log')).toBe('console.log')
  })

  it('falls back to Untitled for empty / all-illegal input', () => {
    expect(sanitizeDirSegment('')).toBe('Untitled')
    expect(sanitizeDirSegment('???')).toBe('Untitled')
  })

  it('caps length to keep the overall path bounded', () => {
    expect(sanitizeDirSegment('x'.repeat(200)).length).toBe(80)
  })
})

describe('collectionOutputTemplate', () => {
  const ctx = { channel: 'DevChannel', playlist: 'Full Course', index: 3 }

  it('files into <out>/<channel>/<playlist>/<NNN> - <filename>', () => {
    const t = collectionOutputTemplate('C:/out', ctx, '%(title)s.%(ext)s')
    // normalise separators so the assertion is OS-independent
    expect(t.replace(/\\/g, '/')).toBe('C:/out/DevChannel/Full Course/003 - %(title)s.%(ext)s')
  })

  it('zero-pads the index to three digits and clamps bad values to 001', () => {
    expect(
      collectionOutputTemplate('C:/o', { ...ctx, index: 42 }, 'f').replace(/\\/g, '/')
    ).toContain('/042 - f')
    expect(
      collectionOutputTemplate('C:/o', { ...ctx, index: 0 }, 'f').replace(/\\/g, '/')
    ).toContain('/001 - f')
    expect(
      collectionOutputTemplate('C:/o', { ...ctx, index: NaN }, 'f').replace(/\\/g, '/')
    ).toContain('/001 - f')
  })

  it('sanitizes the channel and playlist folder names', () => {
    const t = collectionOutputTemplate(
      'C:/out',
      { channel: 'A/B', playlist: '..', index: 1 },
      '%(title)s.%(ext)s'
    )
    expect(t.replace(/\\/g, '/')).toBe('C:/out/A B/Untitled/001 - %(title)s.%(ext)s')
  })

  it('a custom template overrides the built-in layout and is left for yt-dlp to expand', () => {
    const t = collectionOutputTemplate(
      'C:/out',
      ctx,
      '%(title)s.%(ext)s',
      '%(uploader)s/%(playlist)s/%(playlist_index)s - %(title)s.%(ext)s'
    )
    expect(t.replace(/\\/g, '/')).toBe(
      'C:/out/%(uploader)s/%(playlist)s/%(playlist_index)s - %(title)s.%(ext)s'
    )
  })

  it('a blank/whitespace custom template falls back to the built-in layout', () => {
    expect(collectionOutputTemplate('C:/o', ctx, 'f', '').replace(/\\/g, '/')).toBe(
      'C:/o/DevChannel/Full Course/003 - f'
    )
    expect(collectionOutputTemplate('C:/o', ctx, 'f', '   ').replace(/\\/g, '/')).toBe(
      'C:/o/DevChannel/Full Course/003 - f'
    )
  })
})

describe('metadata overrides (--replace-in-metadata)', () => {
  it('emits --embed-metadata and --replace-in-metadata for each set field', () => {
    const argv = build(opts(), dlo({ embedMetadata: false, metadataTitle: 'My Track' }))
    expect(argv).toContain('--embed-metadata')
    expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'My Track')).toBe(true)
  })

  it('emits all three override fields when all are set', () => {
    const argv = build(opts(), dlo({ metadataTitle: 'T', metadataArtist: 'A', metadataAlbum: 'B' }))
    expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'T')).toBe(true)
    expect(hasSeq(argv, '--replace-in-metadata', 'artist', '^.*$', 'A')).toBe(true)
    expect(hasSeq(argv, '--replace-in-metadata', 'album', '^.*$', 'B')).toBe(true)
  })

  it('passes a value containing a colon as a single arg without splitting', () => {
    const argv = build(opts(), dlo({ metadataTitle: 'Foo: Bar' }))
    expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'Foo: Bar')).toBe(true)
  })

  it('escapes backslashes in the replacement string', () => {
    const argv = build(opts(), dlo({ metadataTitle: 'A\\B' }))
    expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'A\\\\B')).toBe(true)
  })

  it('skips blank or whitespace-only values', () => {
    const argv = build(opts(), dlo({ metadataTitle: '', metadataArtist: '   ' }))
    expect(argv).not.toContain('--replace-in-metadata')
  })

  it('forces --embed-metadata even when embedMetadata is false and an override is set', () => {
    const argv = build(opts(), dlo({ embedMetadata: false, metadataAlbum: 'Soundtrack' }))
    expect(argv).toContain('--embed-metadata')
  })

  it('emits no override flags when no fields are set', () => {
    const argv = build(opts(), dlo({ embedMetadata: true }))
    expect(argv).not.toContain('--replace-in-metadata')
  })
})
