const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const logger = require('./logger').child('CD_RIP');
const { spawnTrackedProcess } = require('./processRunner');
const { parseCdParanoiaProgress } = require('../utils/progressParsers');
const { ensureDir, transliterateForFilename } = require('../utils/files');
const { errorToMeta } = require('../utils/errorMeta');

const execFileAsync = promisify(execFile);

const SUPPORTED_FORMATS = new Set(['wav', 'flac', 'mp3', 'opus', 'ogg']);
const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artist} - {title}';

/**
 * Parse cdparanoia -Q output to extract track information.
 * Supports both bracket styles shown by different builds:
 *   track  1:   0 (00:00.00)   24218 (05:22.43)
 *   track  1:   0 [00:00.00]   24218 [05:22.43]
 */
function parseToc(tocOutput) {
  const lines = String(tocOutput || '').split(/\r?\n/);
  const tracks = [];
  const tocTimePattern = /[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/g;
  const tocTablePattern =
    /^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/i;

  for (const line of lines) {
    const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
    if (trackMatch) {
      const position = Number(trackMatch[1]);
      const payloadWithoutTimes = String(trackMatch[2] || '')
        .replace(tocTimePattern, ' ');
      const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
      if (sectorValues.length < 2) {
        continue;
      }

      const startSector = Number(sectorValues[0]);
      const lengthSector = Number(sectorValues[1]);
      if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
        continue;
      }
      if (position <= 0 || startSector < 0 || lengthSector <= 0) {
        continue;
      }

      // duration in seconds: sectors / 75
      const durationSec = Math.round(lengthSector / 75);
      tracks.push({
        position,
        startSector,
        lengthSector,
        durationSec,
        durationMs: durationSec * 1000
      });
      continue;
    }

    // Alternative cdparanoia -Q table style:
    //   1.   16503 [03:40.03]        0 [00:00.00]    no   no  2
    //   ^    length sectors           ^ start sector
    const tableMatch = line.match(tocTablePattern);
    if (!tableMatch) {
      continue;
    }

    const position = Number(tableMatch[1]);
    const lengthSector = Number(tableMatch[2]);
    const startSector = Number(tableMatch[3]);
    if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
      continue;
    }
    if (position <= 0 || startSector < 0 || lengthSector <= 0) {
      continue;
    }

    const durationSec = Math.round(lengthSector / 75);
    tracks.push({
      position,
      startSector,
      lengthSector,
      durationSec,
      durationMs: durationSec * 1000
    });
  }

  return tracks;
}

async function readToc(devicePath, cmd) {
  const cdparanoia = String(cmd || 'cdparanoia').trim() || 'cdparanoia';
  logger.info('toc:read', { devicePath, cmd: cdparanoia });
  try {
    // Depending on distro/build, TOC can appear on stderr and/or stdout.
    const { stdout, stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], {
      timeout: 15000
    });
    const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
    logger.info('toc:done', { devicePath, trackCount: tracks.length });
    return tracks;
  } catch (error) {
    // cdparanoia -Q may exit non-zero even when TOC is readable.
    const stderr = String(error?.stderr || '');
    const stdout = String(error?.stdout || '');
    const tracks = parseToc(`${stderr}\n${stdout}`);
    if (tracks.length > 0) {
      logger.info('toc:done-from-error-streams', { devicePath, trackCount: tracks.length });
      return tracks;
    }
    logger.warn('toc:failed', { devicePath, error: errorToMeta(error) });
    return [];
  }
}

function buildOutputFilename(track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
  const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate);
  const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
  return `${relativeBasePath}.${ext}`;
}

function sanitizePathSegment(value, fallback = 'unknown') {
  const raw = transliterateForFilename(String(value == null ? '' : value))
    .replace(/[\\/:*?"<>|]/g, '-')
    .replace(/[♥❤♡❥❣❦❧]/gu, ' ')
    .replace(/\p{C}+/gu, ' ')
    .replace(/\s+/g, ' ')
    .trim();
  if (!raw || raw === '.' || raw === '..') {
    return fallback;
  }
  return raw.slice(0, 180);
}

function normalizeTemplateTokenKey(rawKey) {
  const key = String(rawKey || '').trim().toLowerCase();
  if (!key) {
    return '';
  }
  if (key === 'tracknr' || key === 'tracknumberpadded' || key === 'tracknopadded') {
    return 'trackNr';
  }
  if (key === 'tracknumber' || key === 'trackno' || key === 'tracknum' || key === 'track') {
    return 'trackNo';
  }
  if (key === 'trackartist' || key === 'track_artist') {
    return 'trackArtist';
  }
  if (key === 'albumartist') {
    return 'albumArtist';
  }
  if (key === 'interpret') {
    return 'artist';
  }
  return key;
}

function cleanupRenderedTemplate(value) {
  return String(value || '')
    .replace(/\(\s*\)/g, '')
    .replace(/\[\s*]/g, '')
    .replace(/\s{2,}/g, ' ')
    .trim();
}

function renderOutputTemplate(template, values) {
  const source = String(template || DEFAULT_CD_OUTPUT_TEMPLATE).trim() || DEFAULT_CD_OUTPUT_TEMPLATE;
  const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
    const normalizedKey = normalizeTemplateTokenKey(keyA || keyB);
    const rawValue = values[normalizedKey];
    if (rawValue === undefined || rawValue === null) {
      return '';
    }
    return String(rawValue);
  });
  return cleanupRenderedTemplate(rendered);
}

function buildTemplateValues(track, meta, format = null) {
  const trackNo = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : 1;
  const trackTitle = sanitizePathSegment(track?.title || `Track ${trackNo}`, `Track ${trackNo}`);
  const albumArtist = sanitizePathSegment(meta?.artist || 'Unknown Artist', 'Unknown Artist');
  const trackArtist = sanitizePathSegment(track?.artist || meta?.artist || 'Unknown Artist', 'Unknown Artist');
  const album = sanitizePathSegment(meta?.title || meta?.album || 'Unknown Album', 'Unknown Album');
  const year = meta?.year == null ? '' : sanitizePathSegment(String(meta.year), '');
  return {
    artist: albumArtist,
    albumArtist,
    trackArtist,
    album,
    year,
    title: trackTitle,
    trackNr: String(trackNo).padStart(2, '0'),
    trackNo: String(trackNo),
    format: format ? String(format).trim().toLowerCase() : ''
  };
}

function buildTrackRelativeBasePath(track, meta, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE, format = null) {
  const values = buildTemplateValues(track, meta, format);
  const rendered = renderOutputTemplate(outputTemplate, values)
    .replace(/\\/g, '/')
    .replace(/\/+/g, '/')
    .replace(/^\/+|\/+$/g, '');

  const parts = rendered
    .split('/')
    .map((part) => sanitizePathSegment(part, 'unknown'))
    .filter(Boolean);

  if (parts.length === 0) {
    return `${String(track?.position || 1).padStart(2, '0')} Track ${String(track?.position || 1).padStart(2, '0')}`;
  }

  return path.join(...parts);
}

function buildOutputDir(meta, baseDir, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
  const sampleTrack = {
    position: 1,
    title: 'Track 1'
  };
  const relativeBasePath = buildTrackRelativeBasePath(sampleTrack, meta, outputTemplate);
  const relativeDir = path.dirname(relativeBasePath);
  if (!relativeDir || relativeDir === '.' || relativeDir === path.sep) {
    return baseDir;
  }
  return path.join(baseDir, relativeDir);
}

function splitPathSegments(value) {
  return String(value || '')
    .replace(/\\/g, '/')
    .split('/')
    .map((segment) => segment.trim())
    .filter(Boolean);
}

function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) {
  const outputSegments = splitPathSegments(outputBaseDir);
  const relativeSegments = splitPathSegments(relativeDir);
  if (relativeSegments.length === 0 || outputSegments.length < relativeSegments.length) {
    return false;
  }
  const offset = outputSegments.length - relativeSegments.length;
  for (let i = 0; i < relativeSegments.length; i++) {
    const outputPart = outputSegments[offset + i];
    const relativePart = relativeSegments[i];
    if (outputPart === relativePart) {
      continue;
    }
    // Numbered conflict folders end with "_X" (e.g. ".../Album (2007)_2").
    // Treat this as containing the same relative directory to avoid nesting:
    //   Album (2007)_2/Album (2007)/...
    const isLastRelativeSegment = i === relativeSegments.length - 1;
    const matchesNumberedSuffix = isLastRelativeSegment
      && outputPart.startsWith(`${relativePart}_`)
      && /^\d+$/.test(outputPart.slice(relativePart.length + 1));
    if (!matchesNumberedSuffix) {
      return false;
    }
  }
  return true;
}

function stripLeadingRelativeDir(relativeFilePath, relativeDir) {
  const fileSegments = splitPathSegments(relativeFilePath);
  const dirSegments = splitPathSegments(relativeDir);
  if (dirSegments.length === 0 || fileSegments.length <= dirSegments.length) {
    return relativeFilePath;
  }
  for (let i = 0; i < dirSegments.length; i++) {
    if (fileSegments[i] !== dirSegments[i]) {
      return relativeFilePath;
    }
  }
  return path.join(...fileSegments.slice(dirSegments.length));
}

function buildOutputFilePath(outputBaseDir, track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
  const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate, format);
  const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
  const relativeDir = path.dirname(relativeBasePath);
  let relativeFilePath = `${relativeBasePath}.${ext}`;
  if (relativeDir && relativeDir !== '.' && relativeDir !== path.sep) {
    if (outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir)) {
      relativeFilePath = stripLeadingRelativeDir(relativeFilePath, relativeDir);
    }
  }
  const outFile = path.join(outputBaseDir, relativeFilePath);
  return {
    outFile,
    relativeFilePath,
    outFilename: path.basename(relativeFilePath)
  };
}

function buildCancelledError() {
  const error = new Error('Job wurde vom Benutzer abgebrochen.');
  error.statusCode = 409;
  return error;
}

function assertNotCancelled(isCancelled) {
  if (typeof isCancelled === 'function' && isCancelled()) {
    throw buildCancelledError();
  }
}

function normalizeExitCode(error) {
  const code = Number(error?.code);
  if (Number.isFinite(code)) {
    return Math.trunc(code);
  }
  return 1;
}

function quoteShellArg(value) {
  const text = String(value == null ? '' : value);
  if (!text) {
    return "''";
  }
  if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(text)) {
    return text;
  }
  return `'${text.replace(/'/g, "'\\''")}'`;
}

function formatCommandLine(cmd, args = []) {
  const normalizedArgs = Array.isArray(args) ? args : [];
  return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
}

function copyFilePreservingRaw(sourcePath, targetPath) {
  const rawSource = String(sourcePath || '').trim();
  const rawTarget = String(targetPath || '').trim();
  if (!rawSource || !rawTarget) {
    return;
  }
  const source = path.resolve(rawSource);
  const target = path.resolve(rawTarget);
  if (source === target) {
    return;
  }
  fs.copyFileSync(source, target);
}

async function runProcessTracked({
  cmd,
  args,
  cwd,
  onStdoutLine,
  onStderrLine,
  context,
  onProcessHandle,
  isCancelled
}) {
  assertNotCancelled(isCancelled);
  const handle = spawnTrackedProcess({
    cmd,
    args,
    cwd,
    onStdoutLine,
    onStderrLine,
    context
  });
  if (typeof onProcessHandle === 'function') {
    onProcessHandle(handle);
  }
  if (typeof isCancelled === 'function' && isCancelled()) {
    handle.cancel();
  }
  try {
    return await handle.promise;
  } catch (error) {
    if (typeof isCancelled === 'function' && isCancelled()) {
      throw buildCancelledError();
    }
    throw error;
  }
}

/**
 * Returns sorted plain WAV filenames (not .cdda.wav) in rawWavDir.
 * Used as fallback when track01.cdda.wav files are absent (e.g. orphan imports with artist-title WAV files).
 */
function getSortedPlainWavFiles(rawWavDir) {
  try {
    return fs.readdirSync(rawWavDir)
      .filter((f) => /\.wav$/i.test(f) && !/\.cdda\.wav$/i.test(f))
      .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
  } catch (_) {
    return [];
  }
}

/**
 * Resolves the WAV file path for a given track position.
 * Primary: track01.cdda.wav (cdparanoia format)
 * Fallback: Nth sorted plain WAV file (for orphan imports with artist-title filenames)
 */
function resolveTrackWavFile(rawWavDir, position, sortedPlainWavFiles) {
  const cddaPath = path.join(rawWavDir, `track${String(position).padStart(2, '0')}.cdda.wav`);
  if (fs.existsSync(cddaPath)) return cddaPath;
  if (Array.isArray(sortedPlainWavFiles) && sortedPlainWavFiles.length >= position && position >= 1) {
    const fallback = path.join(rawWavDir, sortedPlainWavFiles[position - 1]);
    if (fs.existsSync(fallback)) return fallback;
  }
  return cddaPath;
}

/**
 * Rip and encode a CD.
 *
 * @param {object} options
 * @param {string}   options.jobId          - Job ID for logging
 * @param {string}   options.devicePath     - e.g. /dev/sr0
 * @param {string}   options.cdparanoiaCmd  - path/cmd for cdparanoia
 * @param {string}   options.rawWavDir      - temp dir for WAV files
 * @param {string}   options.outputDir      - final output dir
 * @param {string}   options.format         - wav|flac|mp3|opus|ogg
 * @param {object}   options.formatOptions  - encoder-specific options
 * @param {number[]} options.selectedTracks - track positions to rip (empty = all)
 * @param {object[]} options.tracks         - TOC track list [{position, durationMs, title}]
 * @param {object}   options.meta           - album metadata {title, artist, year}
 * @param {string}   options.outputTemplate - template for relative output path without extension
 * @param {boolean}  options.skipEncode     - true => rip only (no encode)
 * @param {Function} options.onProgress     - ({phase, trackIndex, trackTotal, percent, track}) => void
 * @param {Function} options.onLog          - (level, msg) => void
 * @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
 * @param {Function} options.isCancelled    - returns true when user requested cancellation
 * @param {object}   options.context        - passed to spawnTrackedProcess
 */
async function ripAndEncode(options) {
  const {
    jobId,
    devicePath,
    cdparanoiaCmd = 'cdparanoia',
    rawWavDir,
    outputDir,
    format = 'flac',
    formatOptions = {},
    selectedTracks = [],
    tracks = [],
    meta = {},
    outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE,
    onProgress,
    onLog,
    onProcessHandle,
    isCancelled,
    context,
    skipRip = false,
    skipEncode = false
  } = options;

  if (!skipEncode && !SUPPORTED_FORMATS.has(format)) {
    throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
  }

  const tracksToRip = selectedTracks.length > 0
    ? tracks.filter((t) => selectedTracks.includes(t.position))
    : tracks;

  if (tracksToRip.length === 0) {
    throw new Error('Keine Tracks zum Rippen ausgewählt.');
  }

  await ensureDir(rawWavDir);
  if (!skipEncode) {
    await ensureDir(outputDir);
  }

  logger.info('rip:start', {
    jobId,
    devicePath,
    format,
    trackCount: tracksToRip.length
  });

  const log = (level, msg) => {
    logger[level] && logger[level](msg, { jobId });
    onLog && onLog(level, msg);
  };
  const ripPercentSpan = skipEncode ? 100 : 50;
  const encodePercentStart = ripPercentSpan;
  const encodePercentSpan = Math.max(0, 100 - encodePercentStart);

  // ── Phase 1: Rip each selected track to WAV ──────────────────────────────
  const _sortedPlainWavFiles = skipRip ? getSortedPlainWavFiles(rawWavDir) : null;

  if (skipRip) {
    // Encode-only: WAV-Dateien müssen bereits vorhanden sein
    for (const track of tracksToRip) {
      const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
      if (!fs.existsSync(wavFile)) {
        throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`);
      }
    }
  } else {
  for (let i = 0; i < tracksToRip.length; i++) {
    assertNotCancelled(isCancelled);
    const track = tracksToRip[i];
    const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
    const ripArgs = ['-d', devicePath, String(track.position), wavFile];

    onProgress && onProgress({
      phase: 'rip',
      trackEvent: 'start',
      trackIndex: i + 1,
      trackTotal: tracksToRip.length,
      trackPosition: track.position,
      trackPercent: 0,
      percent: (i / tracksToRip.length) * ripPercentSpan
    });

    log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
    log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`);

    try {
      await runProcessTracked({
        cmd: cdparanoiaCmd,
        args: ripArgs,
        cwd: rawWavDir,
        onStderrLine(line) {
          const parsed = parseCdParanoiaProgress(line);
          if (parsed && parsed.percent !== null) {
            const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * ripPercentSpan;
            onProgress && onProgress({
              phase: 'rip',
              trackEvent: 'progress',
              trackIndex: i + 1,
              trackTotal: tracksToRip.length,
              trackPosition: track.position,
              trackPercent: parsed.percent,
              percent: overallPercent
            });
          }
        },
        context,
        onProcessHandle,
        isCancelled
      });
    } catch (error) {
      if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
        throw error;
      }
      throw new Error(
        `cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
      );
    }

    onProgress && onProgress({
      phase: 'rip',
      trackEvent: 'complete',
      trackIndex: i + 1,
      trackTotal: tracksToRip.length,
      trackPosition: track.position,
      trackPercent: 100,
      percent: ((i + 1) / tracksToRip.length) * ripPercentSpan
    });

    log('info', `Track ${track.position} gerippt.`);
  }
  } // end if (!skipRip)

  if (skipEncode) {
    return {
      outputDir: null,
      format: 'raw',
      trackCount: tracksToRip.length,
      encodeResults: []
    };
  }

  // ── Phase 2: Encode WAVs to target format ─────────────────────────────────
  const encodeResults = [];

  if (format === 'wav') {
    // Keep RAW WAVs in place and copy them to the final output structure.
    for (let i = 0; i < tracksToRip.length; i++) {
      assertNotCancelled(isCancelled);
      const track = tracksToRip[i];
      const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
      const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
      onProgress && onProgress({
        phase: 'encode',
        trackEvent: 'start',
        trackIndex: i + 1,
        trackTotal: tracksToRip.length,
        trackPosition: track.position,
        trackPercent: 0,
        percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
      });
      ensureDir(path.dirname(outFile));
      log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
      copyFilePreservingRaw(wavFile, outFile);
      onProgress && onProgress({
        phase: 'encode',
        trackEvent: 'complete',
        trackIndex: i + 1,
        trackTotal: tracksToRip.length,
        trackPosition: track.position,
        trackPercent: 100,
        percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
      });
      log('info', `WAV für Track ${track.position} gespeichert.`);
      encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true });
    }
    return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
  }

  for (let i = 0; i < tracksToRip.length; i++) {
    assertNotCancelled(isCancelled);
    const track = tracksToRip[i];
    const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);

    if (!fs.existsSync(wavFile)) {
      throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
    }

    const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate);
    ensureDir(path.dirname(outFile));

    onProgress && onProgress({
      phase: 'encode',
      trackEvent: 'start',
      trackIndex: i + 1,
      trackTotal: tracksToRip.length,
      trackPosition: track.position,
      trackPercent: 0,
      percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
    });

    log('info', `Encodiere Track ${track.position} → ${outFilename} …`);

    const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile);
    log('info', `Promptkette [Encode ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(encodeArgs.cmd, encodeArgs.args)}`);

    try {
      await runProcessTracked({
        cmd: encodeArgs.cmd,
        args: encodeArgs.args,
        cwd: rawWavDir,
        onStdoutLine() {},
        onStderrLine() {},
        context,
        onProcessHandle,
        isCancelled
      });
    } catch (error) {
      if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
        throw error;
      }
      encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: false });
      throw new Error(
        `${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
      );
    }

    // Safety net: some encoders (e.g. older flac without --no-delete-input-file) may remove
    // the source WAV. Restore it from the encoded output so the RAW folder stays filled.
    if (!fs.existsSync(wavFile) && fs.existsSync(outFile)) {
      try {
        fs.copyFileSync(outFile, wavFile);
        log('info', `Track ${track.position}: WAV-Quelldatei vom Encoder gelöscht – aus Output wiederhergestellt.`);
      } catch (restoreErr) {
        log('warn', `Track ${track.position}: WAV-Wiederherstellung fehlgeschlagen: ${restoreErr.message}`);
      }
    }

    onProgress && onProgress({
      phase: 'encode',
      trackEvent: 'complete',
      trackIndex: i + 1,
      trackTotal: tracksToRip.length,
      trackPosition: track.position,
      trackPercent: 100,
      percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
    });

    log('info', `Track ${track.position} encodiert.`);
    encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: true });
  }

  return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
}

function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
  const artist = track?.artist || meta?.artist || '';
  const album = meta?.title || '';
  const year = meta?.year ? String(meta.year) : '';
  const trackTitle = track.title || `Track ${track.position}`;
  const trackNum = String(track.position);

  if (format === 'flac') {
    const level = Number(opts.flacCompression ?? 5);
    const clampedLevel = Math.max(0, Math.min(8, level));
    return {
      cmd: 'flac',
      args: [
        `--compression-level-${clampedLevel}`,
        '--no-delete-input-file', // flac deletes input WAV by default; keep RAW folder filled
        '--tag', `TITLE=${trackTitle}`,
        '--tag', `ARTIST=${artist}`,
        '--tag', `ALBUM=${album}`,
        '--tag', `DATE=${year}`,
        '--tag', `TRACKNUMBER=${trackNum}`,
        wavFile,
        '-o', outFile
      ]
    };
  }

  if (format === 'mp3') {
    const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
    const args = ['--id3v2-only', '--noreplaygain'];
    if (mode === 'vbr') {
      const quality = Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)));
      args.push('-V', String(quality));
    } else {
      const bitrate = Number(opts.mp3Bitrate ?? 192);
      args.push('-b', String(bitrate));
    }
    args.push(
      '--tt', trackTitle,
      '--ta', artist,
      '--tl', album,
      '--ty', year,
      '--tn', trackNum,
      wavFile,
      outFile
    );
    return { cmd: 'lame', args };
  }

  if (format === 'opus') {
    const bitrate = Math.max(32, Math.min(512, Number(opts.opusBitrate ?? 160)));
    const complexity = Math.max(0, Math.min(10, Number(opts.opusComplexity ?? 10)));
    return {
      cmd: 'opusenc',
      args: [
        '--bitrate', String(bitrate),
        '--comp', String(complexity),
        '--title', trackTitle,
        '--artist', artist,
        '--album', album,
        '--date', year,
        '--tracknumber', trackNum,
        wavFile,
        outFile
      ]
    };
  }

  if (format === 'ogg') {
    const quality = Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)));
    return {
      cmd: 'oggenc',
      args: [
        '-q', String(quality),
        '-t', trackTitle,
        '-a', artist,
        '-l', album,
        '-d', year,
        '-N', trackNum,
        '-o', outFile,
        wavFile
      ]
    };
  }

  throw new Error(`Unbekanntes Format: ${format}`);
}

module.exports = {
  parseToc,
  readToc,
  ripAndEncode,
  buildOutputDir,
  buildOutputFilename,
  DEFAULT_CD_OUTPUT_TEMPLATE,
  SUPPORTED_FORMATS
};
