'use strict';

const path = require('path');
const { SourcePlugin } = require('./PluginBase');
const omdbService = require('../services/omdbService');
const { spawnTrackedProcess } = require('../services/processRunner');
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
const { ensureDir } = require('../utils/files');

/**
 * Gemeinsame Basisklasse für Blu-ray und DVD Plugins.
 *
 * Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien:
 *   analyze()  → OMDB-Suche + Disc-Metadaten vorbereiten
 *   review()   → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator)
 *   rip()      → makemkvcon backup/mkv
 *   encode()   → HandBrakeCLI
 *
 * Subklassen müssen implementieren:
 *   get id()          → 'bluray' | 'dvd'
 *   get name()        → Anzeigename
 *   get mediaProfile() → 'bluray' | 'dvd'
 *   detect(discInfo)  → boolean
 *
 * ctx.extra-Felder für rip():
 *   rawJobDir       - Absoluter Pfad des RAW-Ausgabeordners
 *   deviceInfo      - { path, index, mediaProfile } vom DiskDetectionService
 *   selectedTitleId - MakeMKV-Titel-ID (optional, nur für mkv-Modus)
 *   ripMode         - 'backup' | 'mkv'  (Default: aus Settings)
 *   backupOutputBase - Basisname für DVD-Backup (nur für backup+DVD)
 *   isCancelled     - () => boolean
 *   onProcessHandle - (handle) => void  [optional]
 *
 * ctx.extra-Felder für encode():
 *   inputPath       - Absoluter Pfad zur gerippten Quelldatei/Verzeichnis
 *   outputPath      - Absoluter Pfad des fertigen Ausgabefiles (inkl. Temp-Präfix)
 *   encodePlan      - { handBrakeTitleId, trackSelection, userPreset } aus confirm-Review
 *   isCancelled     - () => boolean
 *   onProcessHandle - (handle) => void  [optional]
 *
 * ctx.extra-Felder für review():
 *   deviceInfo      - Disc-Device (für Pre-Rip-Scan) ODER
 *   rawJobDir       - RAW-Ordner (für Post-Rip-Scan)
 *   reviewMode      - 'pre_rip' | 'rip'  (Default: 'pre_rip')
 */
class VideoDiscPlugin extends SourcePlugin {
  /**
   * Gibt das Media-Profil zurück, das settingsService-Methoden verwenden.
   * Muss von Subklassen implementiert werden.
   * @returns {'bluray'|'dvd'}
   */
  get mediaProfile() {
    throw new Error(`${this.constructor.name}: mediaProfile nicht implementiert`);
  }

  /**
   * Führt eine OMDB-Suche für den erkannten Disc-Titel durch.
   * Gibt { detectedTitle, omdbCandidates } zurück.
   *
   * @param {string} devicePath
   * @param {object} job        - Vorbereiteter Job-Record (noch ohne Metadaten)
   * @param {PluginContext} ctx
   * @returns {Promise<{detectedTitle: string, omdbCandidates: Array}>}
   */
  async analyze(devicePath, job, ctx) {
    ctx.markExecution('analyze', {
      pluginId: this.id,
      pluginName: this.name,
      pluginFile: 'VideoDiscPlugin.js'
    });

    const discInfo = ctx.extra?.discInfo || {};
    const detectedTitle = String(
      discInfo.discLabel || discInfo.label || discInfo.model || job?.detected_title || 'Unknown Disc'
    ).trim();

    ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle });

    const omdbCandidates = await omdbService.search(detectedTitle).catch((error) => {
      ctx.logger.warn(`${this.id}:analyze:omdb-failed`, {
        jobId: job?.id,
        error: error?.message || String(error)
      });
      return [];
    });

    ctx.logger.info(`${this.id}:analyze:done`, {
      jobId: job?.id,
      detectedTitle,
      omdbCandidates: omdbCandidates.length
    });

    return { detectedTitle, omdbCandidates };
  }

  /**
   * Führt einen HandBrake-Scan durch und gibt die rohen Scandaten zurück.
   * Der Orchestrator übernimmt die komplexe Auswertung (buildDiscScanReview).
   *
   * ctx.extra.reviewMode:
   *   'pre_rip' → Scan direkt vom Laufwerk (deviceInfo)
   *   'rip'     → Scan aus dem RAW-Ordner (rawJobDir)
   *
   * @param {object} job
   * @param {PluginContext} ctx
   * @returns {Promise<{scanLines: string[], runInfo: object}|null>}
   */
  async review(job, ctx) {
    ctx.markExecution('review', {
      pluginId: this.id,
      pluginName: this.name,
      pluginFile: 'VideoDiscPlugin.js'
    });

    const reviewMode = String(ctx.extra?.reviewMode || 'pre_rip').trim().toLowerCase();
    const deviceInfo = ctx.extra?.deviceInfo || null;
    const rawJobDir = ctx.extra?.rawJobDir || null;

    ctx.logger.info(`${this.id}:review:start`, {
      jobId: job?.id,
      reviewMode,
      deviceInfo: deviceInfo?.path || null,
      rawJobDir: rawJobDir || null
    });

    const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);

    let scanConfig;
    if (reviewMode === 'rip' && rawJobDir) {
      // Post-Rip scan: scan from the ripped RAW folder
      scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(rawJobDir, {
        mediaProfile: this.mediaProfile,
        settingsMap: settings
      });
    } else {
      // Pre-Rip scan: scan directly from disc device
      scanConfig = await ctx.settings.buildHandBrakeScanConfig(deviceInfo, {
        mediaProfile: this.mediaProfile,
        settingsMap: settings
      });
    }

    ctx.logger.info(`${this.id}:review:scan-command`, {
      jobId: job?.id,
      cmd: scanConfig.cmd,
      args: scanConfig.args
    });

    const scanLines = [];
    const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
    const reviewStage = String(ctx.extra?.reviewStage || 'MEDIAINFO_CHECK').trim().toUpperCase() || 'MEDIAINFO_CHECK';
    const reviewSource = String(ctx.extra?.reviewSource || 'HANDBRAKE_SCAN').trim().toUpperCase() || 'HANDBRAKE_SCAN';
    const reviewSilent = Boolean(ctx.extra?.reviewSilent);
    let runInfo;
    if (runCommandFn) {
      try {
        runInfo = await runCommandFn({
          jobId: job?.id,
          stage: reviewStage,
          source: reviewSource,
          cmd: scanConfig.cmd,
          args: scanConfig.args,
          collectLines: scanLines,
          // HandBrake scan details (duration/chapters/audio/subtitles) are often
          // emitted on stderr. Collect both streams for reliable disc heuristics.
          collectStderrLines: true,
          silent: reviewSilent
        });
      } catch (cmdError) {
        // runCommand collected stdout lines via callbacks before throwing.
        // Recover the runInfo from the error so the caller can still parse
        // whatever output HandBrakeCLI produced (e.g. exit code 2 with valid JSON).
        runInfo = cmdError?.runInfo || {
          status: 'ERROR',
          exitCode: typeof cmdError?.code === 'number' ? cmdError.code : 1,
          errorMessage: cmdError?.message || String(cmdError)
        };
      }
    } else {
      runInfo = await _runCommandCaptured({
        cmd: scanConfig.cmd,
        args: scanConfig.args,
        onStdoutLine: (line) => scanLines.push(line),
        onStderrLine: (line) => scanLines.push(line),
        context: { jobId: job?.id, source: `${this.id}Plugin.review` }
      });
    }

    ctx.logger.info(`${this.id}:review:scan-done`, {
      jobId: job?.id,
      exitCode: runInfo.exitCode,
      lineCount: scanLines.length
    });

    // Return raw scan data — the Orchestrator (or legacy pipelineService)
    // processes this with buildDiscScanReview() / parseMediainfoJsonOutput().
    return {
      scanLines,
      runInfo,
      reviewMode,
      mediaProfile: this.mediaProfile,
      sourceArg: scanConfig.sourceArg || null
    };
  }

  /**
   * Rippt die Disc mit makemkvcon in den rawJobDir.
   *
   * @param {object} job
   * @param {PluginContext} ctx
   * @returns {Promise<object>} runInfo vom makemkvcon-Prozess
   */
  async rip(job, ctx) {
    ctx.markExecution('rip', {
      pluginId: this.id,
      pluginName: this.name,
      pluginFile: 'VideoDiscPlugin.js'
    });

    const {
      rawJobDir,
      deviceInfo,
      selectedTitleId = null,
      backupOutputBase = null,
      disableMinLengthFilter = false,
      isCancelled,
      onProcessHandle
    } = ctx.extra || {};

    if (!rawJobDir) {
      throw new Error(`${this.constructor.name}.rip(): ctx.extra.rawJobDir fehlt`);
    }
    if (!deviceInfo) {
      throw new Error(`${this.constructor.name}.rip(): ctx.extra.deviceInfo fehlt`);
    }

    ensureDir(rawJobDir);

    const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
    const ripConfig = await ctx.settings.buildMakeMKVRipConfig(rawJobDir, deviceInfo, {
      selectedTitleId,
      mediaProfile: this.mediaProfile,
      settingsMap: settings,
      backupOutputBase,
      disableMinLengthFilter
    });

    const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
      .trim().toLowerCase() === 'backup' ? 'backup' : 'mkv';

    ctx.logger.info(`${this.id}:rip:start`, {
      jobId: job?.id,
      cmd: ripConfig.cmd,
      args: ripConfig.args,
      ripMode,
      rawJobDir,
      selectedTitleId,
      disableMinLengthFilter
    });

    ctx.emitProgress(0, ripMode === 'backup'
      ? `${this.name}: Backup läuft …`
      : `${this.name}: Ripping läuft …`
    );

    // Pipeline-integrierter Pfad: ctx.extra.runCommand delegiert an this.runCommand()
    // des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log,
    // Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests.
    const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
    let runInfo;
    if (runCommandFn) {
      runInfo = await runCommandFn({
        jobId: job?.id,
        stage: 'RIPPING',
        source: 'MAKEMKV_RIP',
        cmd: ripConfig.cmd,
        args: ripConfig.args,
        parser: parseMakeMkvProgress
      });
    } else {
      runInfo = await _spawnAndWait({
        cmd: ripConfig.cmd,
        args: ripConfig.args,
        cwd: rawJobDir,
        jobId: job?.id,
        progressParser: parseMakeMkvProgress,
        onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
        isCancelled,
        onProcessHandle,
        context: { jobId: job?.id, source: `${this.id}Plugin.rip`, stage: 'RIPPING' }
      });
    }

    ctx.logger.info(`${this.id}:rip:done`, {
      jobId: job?.id,
      rawJobDir,
      exitCode: runInfo?.exitCode ?? runInfo?.code ?? null
    });

    ctx.extra.ripRunInfo = runInfo;
    return runInfo;
  }

  /**
   * Encodiert die gerippten Dateien mit HandBrakeCLI.
   *
   * @param {object} job
   * @param {PluginContext} ctx
   * @returns {Promise<object>} runInfo vom HandBrake-Prozess
   */
  async encode(job, ctx) {
    ctx.markExecution('encode', {
      pluginId: this.id,
      pluginName: this.name,
      pluginFile: 'VideoDiscPlugin.js'
    });

    const {
      inputPath,
      outputPath,
      encodePlan = {},
      isCancelled,
      onProcessHandle
    } = ctx.extra || {};

    if (!inputPath) {
      throw new Error(`${this.constructor.name}.encode(): ctx.extra.inputPath fehlt`);
    }
    if (!outputPath) {
      throw new Error(`${this.constructor.name}.encode(): ctx.extra.outputPath fehlt`);
    }

    ensureDir(path.dirname(outputPath));

    const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
    const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
      trackSelection: encodePlan.trackSelection || null,
      titleId: encodePlan.handBrakeTitleId || null,
      mediaProfile: this.mediaProfile,
      settingsMap: settings,
      userPreset: encodePlan.userPreset || null
    });

    ctx.logger.info(`${this.id}:encode:start`, {
      jobId: job?.id,
      cmd: handBrakeConfig.cmd,
      args: handBrakeConfig.args,
      titleId: encodePlan.handBrakeTitleId || null
    });

    ctx.emitProgress(0, `${this.name}: Encoding läuft …`);

    const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
    const encodeSource = String(ctx.extra?.encodeSource || 'HANDBRAKE').trim().toUpperCase() || 'HANDBRAKE';
    const encodeStage = String(ctx.extra?.encodeStage || 'ENCODING').trim().toUpperCase() || 'ENCODING';
    const encodeParser = typeof ctx.extra?.progressParser === 'function'
      ? ctx.extra.progressParser
      : parseHandBrakeProgress;

    let runInfo;
    if (runCommandFn) {
      runInfo = await runCommandFn({
        jobId: job?.id,
        stage: encodeStage,
        source: encodeSource,
        cmd: handBrakeConfig.cmd,
        args: handBrakeConfig.args,
        parser: encodeParser
      });
    } else {
      runInfo = await _spawnAndWait({
        cmd: handBrakeConfig.cmd,
        args: handBrakeConfig.args,
        jobId: job?.id,
        progressParser: parseHandBrakeProgress,
        onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `${this.name}: Encoding ${pct}%`),
        isCancelled,
        onProcessHandle,
        context: { jobId: job?.id, source: `${this.id}Plugin.encode`, stage: 'ENCODING' }
      });
    }

    ctx.logger.info(`${this.id}:encode:done`, {
      jobId: job?.id,
      outputPath,
      exitCode: runInfo.exitCode
    });

    ctx.extra.encodeRunInfo = runInfo;
    return runInfo;
  }

  /**
   * Plugin-spezifische Settings (gemeinsam für BluRay + DVD, via this.mediaProfile).
   */
  getSettingsSchema() {
    const profile = this.mediaProfile;
    const label = profile === 'bluray' ? 'Blu-ray' : 'DVD';
    const orderBase = profile === 'bluray' ? 200 : 220;

    return [
      {
        key: `handbrake_preset_${profile}`,
        category: 'Tools',
        label: `HandBrake Preset (${label})`,
        type: 'string',
        required: 0,
        description: `Preset Name für -Z (${label}). Leer = kein Preset, nur CLI-Parameter werden verwendet.`,
        default_value: null,
        options_json: '[]',
        validation_json: '{}',
        order_index: orderBase
      },
      {
        key: `output_extension_${profile}`,
        category: 'Tools',
        label: `Ausgabe-Format (${label})`,
        type: 'select',
        required: 1,
        description: `Dateiendung des encodierten ${label}-Outputs.`,
        default_value: 'mkv',
        options_json: '["mkv","mp4"]',
        validation_json: '{}',
        order_index: orderBase + 1
      },
      {
        key: `output_template_${profile}`,
        category: 'Pfade',
        label: `Output Template (${label})`,
        type: 'string',
        required: 1,
        description: `Template für relative ${label}-Ausgabepfade ohne Dateiendung. Platzhalter: {title}, {year}. Unterordner über "/".`,
        default_value: '{title} ({year})/{title} ({year})',
        options_json: '[]',
        validation_json: '{"minLength":1}',
        order_index: 700 + (profile === 'bluray' ? 0 : 5)
      }
    ];
  }
}

// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────

function _assertNotCancelled(isCancelled) {
  if (typeof isCancelled === 'function' && isCancelled()) {
    const error = new Error('Job wurde vom Benutzer abgebrochen.');
    error.statusCode = 409;
    throw error;
  }
}

async function _runCommandCaptured({ cmd, args, onStdoutLine, onStderrLine, context }) {
  const lines = [];
  const handle = spawnTrackedProcess({
    cmd,
    args,
    onStdoutLine: (line) => {
      lines.push(line);
      if (typeof onStdoutLine === 'function') {
        onStdoutLine(line);
      }
    },
    onStderrLine: (line) => {
      lines.push(line);
      if (typeof onStderrLine === 'function') {
        onStderrLine(line);
      }
    },
    context: context || {}
  });

  try {
    await handle.promise;
    return { exitCode: 0, lines };
  } catch (error) {
    return {
      exitCode: typeof error?.code === 'number' ? error.code : 1,
      lines,
      error: error?.message || String(error)
    };
  }
}

async function _spawnAndWait({ cmd, args, cwd, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
  _assertNotCancelled(isCancelled);

  const handle = spawnTrackedProcess({
    cmd,
    args,
    cwd,
    onStdoutLine: (line) => {
      if (progressParser && typeof onProgress === 'function') {
        const progress = progressParser(line);
        if (progress?.percent != null) {
          onProgress(progress.percent, progress.statusText || null);
        }
      }
    },
    onStderrLine: (line) => {
      if (progressParser && typeof onProgress === 'function') {
        const progress = progressParser(line);
        if (progress?.percent != null) {
          onProgress(progress.percent, progress.statusText || null);
        }
      }
    },
    context: context || { jobId }
  });

  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()) {
      const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
      cancelError.statusCode = 409;
      throw cancelError;
    }
    throw error;
  }
}

module.exports = { VideoDiscPlugin };
