/*
 * Copyright (C) 2026 SFG545
 *
 * This file is part of Orchard.
 *
 * Orchard is free software: you can redistribute it and/or modify it under the
 * terms of the GNU Affero General Public License as published by the Free
 * Software Foundation, either version 3 of the License, or (at your option) any
 * later version.
 *
 * Orchard is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
 * PARTICULAR PURPOSE. See the GNU Affero General Public License for more
 * details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Orchard. If not, see <https://www.gnu.org/licenses/>.
 */

import { createRequire } from 'node:module';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { IPC_CHANNELS } from '../../shared/ipcChannels.js';
import {
  AUDIO_ANALYSIS_VERSION,
  isValidLocalAnalysis,
  localAnalysisWithSource,
  safeAudioAnalysisDiagnostics
} from '../../shared/audioAnalysis.js';
import { refineBeatConfidence } from './essentiaBeatConfidence.js';
import { DEFAULT_MODEL_PATH, refineBeatsWithModel } from './beatThisTracker.js';
import { createBeatModelHost } from './beatModelHost.js';
import { DEFAULT_MODEL_PATH as DEFAULT_VOCAL_MODEL_PATH } from './vocalMaskTracker.js';
import { createVocalMaskHost } from './vocalMaskHost.js';

// Owns native-addon loading, analysis request de-duplication, and the persisted
// result cache. `stop()` removes every IPC handler and flushes pending cache data.

const require = createRequire(import.meta.url);
const { AUDIO_ANALYSIS } = IPC_CHANNELS;
const CACHE_VERSION = AUDIO_ANALYSIS_VERSION;
const MAX_CACHE_ITEMS = 600;
// Mirrors ANALYSIS_PRIORITIES.next in the renderer. Duplicated rather than
// imported because that module pulls in browser-only globals.
const ANALYSIS_PRIORITY_NEXT = 1;
// Generous: the pass runs about a second for a typical track, and the budget
// exists to stop a pathological input stalling a transition, not to police
// normal work. A skip is free -- the native confidence is still there.
const ESSENTIA_TIMEOUT_MS = 10_000;

function errorDetails(error) {
  return {
    errorName: String(error?.name || 'Error'),
    errorMessage: String(error?.message || error || 'Unknown error').slice(0, 1000),
    errorCode: String(error?.code || '').slice(0, 100)
  };
}

function stdoutLogger(event, details = {}) {
  let suffix = '';
  try {
    const serialized = JSON.stringify(details);
    if (serialized && serialized !== '{}') suffix = ` ${serialized.slice(0, 4000)}`;
  } catch {
    suffix = ' {"logError":"Details were not serializable"}';
  }
  process.stdout.write(`[audio-analysis] ${event}${suffix}\n`);
}

function cleanTrackId(value) {
  return String(value || '').trim().slice(0, 256);
}

// IPC may deserialize an ArrayBuffer or another typed view. This creates only a
// view; the N-API binding performs the lifetime-safe whole-buffer copy.
function floatSamples(value) {
  if (value instanceof Float32Array) return value;
  if (value instanceof ArrayBuffer) return new Float32Array(value);
  if (ArrayBuffer.isView(value)) {
    if (value.byteLength % Float32Array.BYTES_PER_ELEMENT !== 0) return null;
    return new Float32Array(value.buffer, value.byteOffset, value.byteLength / Float32Array.BYTES_PER_ELEMENT);
  }
  return null;
}

function finiteSeconds(value) {
  if (!Array.isArray(value)) return [];
  return value.map(Number).filter((time) => Number.isFinite(time) && time >= 0);
}

/**
 * Translates a renderer-side window into the addon's flat shape. Both bounds of
 * a window must be real numbers or the window is dropped: a half-specified
 * window would silently constrain one end only.
 */
function regionConstraint(value) {
  if (!value || typeof value !== 'object') return undefined;
  const bounds = {};
  [['startEarliest', 'startLatest'], ['endEarliest', 'endLatest']].forEach(([from, to]) => {
    const earliest = Number(value[from]);
    const latest = Number(value[to]);
    if (Number.isFinite(earliest) && Number.isFinite(latest) && latest >= earliest) {
      bounds[from] = earliest;
      bounds[to] = latest;
    }
  });
  return Object.keys(bounds).length ? bounds : undefined;
}

/**
 * Registers the privileged native-analysis IPC service and persistent LRU cache.
 * @param {object} options
 * @param {string} options.cachePath Atomic JSON cache destination.
 * @param {Electron.IpcMain} options.ipcMain IPC registrar owned by Electron.
 * @param {string} options.nativeModulePath Development or asar-unpacked addon path.
 * @returns {{stop: Function}} Cleanup that removes handlers and flushes cache data.
 */
export function setupAudioAnalysisService({
  cachePath,
  ipcMain,
  nativeModulePath,
  transitionModulePath,
  beatModelPath = DEFAULT_MODEL_PATH,
  vocalModelPath = DEFAULT_VOCAL_MODEL_PATH,
  loadNativeAddon = require,
  logger = stdoutLogger,
  // Injectable so tests can exercise the priority gate without loading several
  // megabytes of WASM and spending a second per track inside the suite.
  refineConfidence = refineBeatConfidence,
  // Injectable for the same reason: the real one runs a 23 MB transformer,
  // in a utility process forked on first use.
  refineBeats = refineBeatsWithModel,
  createModelHost = createBeatModelHost,
  createVocalHost = createVocalMaskHost
}) {
  const cache = new Map();
  const inFlight = new Map();
  let nativeAddon = null;
  let nativeLoadAttempts = 0;
  let transitionAddon = null;
  let transitionLoadAttempts = 0;
  let saveTimer = null;
  let savePromise = Promise.resolve();

  function log(event, details = {}) {
    try {
      logger(event, safeAudioAnalysisDiagnostics(details));
    } catch {
      // Diagnostics must never interrupt playback or analysis.
    }
  }

  function addon() {
    if (nativeAddon) return nativeAddon;
    nativeLoadAttempts += 1;
    try {
      const loaded = loadNativeAddon(nativeModulePath);
      if (loaded?.analysisVersion === CACHE_VERSION && typeof loaded?.analyze === 'function') {
        nativeAddon = loaded;
        log('native-load-ready', { attempt: nativeLoadAttempts, analysisVersion: loaded.analysisVersion });
      } else {
        log('native-load-invalid', {
          attempt: nativeLoadAttempts,
          expectedAnalysisVersion: CACHE_VERSION,
          actualAnalysisVersion: loaded?.analysisVersion ?? null,
          hasAnalyze: typeof loaded?.analyze === 'function'
        });
      }
    } catch (error) {
      nativeAddon = null;
      log('native-load-failed', { attempt: nativeLoadAttempts, ...errorDetails(error) });
    }
    return nativeAddon;
  }

  // The transition engine lives in its own napi-rs addon (native-audio-rust),
  // separate from the analysis addon above: it carries no analysis version
  // because it caches nothing, and unlike the node-gyp addon the same binary
  // loads under any Electron version.
  function transition() {
    if (transitionAddon) return transitionAddon;
    if (!transitionModulePath) return null;
    transitionLoadAttempts += 1;
    try {
      const loaded = loadNativeAddon(transitionModulePath);
      if (typeof loaded?.renderTransition === 'function') {
        transitionAddon = loaded;
        log('transition-load-ready', { attempt: transitionLoadAttempts });
      } else {
        log('transition-load-invalid', {
          attempt: transitionLoadAttempts,
          hasRenderTransition: typeof loaded?.renderTransition === 'function'
        });
      }
    } catch (error) {
      transitionAddon = null;
      log('transition-load-failed', { attempt: transitionLoadAttempts, ...errorDetails(error) });
    }
    return transitionAddon;
  }

  // Load before the renderer asks for analysis, while still allowing a later
  // request to recover if startup briefly raced the unpacked native module.
  addon();
  transition();

  const cacheReady = readFile(cachePath, 'utf8')
    .then((contents) => JSON.parse(contents))
    .then((stored) => {
      if (stored?.version !== CACHE_VERSION || !Array.isArray(stored.items)) return;
      stored.items.slice(-MAX_CACHE_ITEMS).forEach((item) => {
        const trackId = cleanTrackId(item?.trackId);
        if (!trackId || !isValidLocalAnalysis(item?.result)) return;
        cache.set(trackId, {
          lastUsed: Number(item.lastUsed) || 0,
          result: item.result
        });
      });
    })
    .catch(() => {});

  function cached(trackId) {
    const entry = cache.get(trackId);
    if (!entry) return null;
    if (!isValidLocalAnalysis(entry.result)) {
      cache.delete(trackId);
      return null;
    }
    cache.delete(trackId);
    cache.set(trackId, { ...entry, lastUsed: Date.now() });
    return entry.result;
  }

  function persist() {
    const items = Array.from(cache, ([trackId, entry]) => ({ trackId, ...entry }));
    const temporaryPath = `${cachePath}.tmp`;
    savePromise = savePromise
      .catch(() => {})
      .then(async () => {
        await mkdir(path.dirname(cachePath), { recursive: true });
        await writeFile(temporaryPath, JSON.stringify({ version: CACHE_VERSION, items }), 'utf8');
        await rename(temporaryPath, cachePath);
      });
    return savePromise;
  }

  function schedulePersist() {
    clearTimeout(saveTimer);
    saveTimer = setTimeout(() => {
      saveTimer = null;
      void persist();
    }, 750);
  }

  ipcMain.handle(AUDIO_ANALYSIS.AVAILABLE, async () => {
    await cacheReady;
    const available = Boolean(addon());
    log('availability-result', { available, loadAttempts: nativeLoadAttempts });
    return available;
  });

  ipcMain.handle(AUDIO_ANALYSIS.GET, async (_event, value) => {
    await cacheReady;
    const trackId = cleanTrackId(value);
    const result = trackId ? cached(trackId) : null;
    log('cache-result', { trackId, hit: Boolean(result) });
    return result;
  });

  ipcMain.handle(AUDIO_ANALYSIS.DEBUG, async (_event, payload = {}) => {
    const event = String(payload?.event || 'unknown').replace(/[^a-z0-9:_-]/gi, '').slice(0, 100);
    const details = payload?.details && typeof payload.details === 'object'
      ? payload.details
      : {};
    log(`renderer:${event}`, safeAudioAnalysisDiagnostics(details));
    return true;
  });

  // Stereo transition rendering shares the addon but not the analysis cache:
  // a rendered overlap is bound to one exact pairing and playback position, so
  // the renderer owns any reuse and this handler stays stateless.
  // Vocal presence across one overlap slice. Stateless like the transition
  // renderer beside it: a mask is bound to one exact pairing and playback
  // position, so nothing here is worth caching.
  //
  // Every failure resolves to null rather than throwing, because the caller's
  // fallback -- a sweep at its flat depth, which is what shipped before this
  // model existed -- is a perfectly good transition, just a less precise one.
  ipcMain.handle(AUDIO_ANALYSIS.VOCAL_MASK, async (_event, payload = {}) => {
    const native = addon();
    if (typeof native?.vocalSpectrogram !== 'function') return null;
    const channels = (Array.isArray(payload.channels) ? payload.channels : [])
      .map(floatSamples)
      .filter((channel) => channel?.length > 0);
    const sampleRate = Number(payload.sampleRate);
    if (channels.length !== 2 || !Number.isFinite(sampleRate) || sampleRate < 1000) return null;
    // Bounded by the longest overlap the planner will ever ask for; anything
    // larger is a caller bug rather than something to spend inference on.
    if (channels.some((channel) => channel.length > sampleRate * 30)) return null;

    const startedAt = Date.now();
    try {
      const spectrogram = await native.vocalSpectrogram(channels, sampleRate);
      if (!spectrogram?.frames) {
        // Most often a sample-rate mismatch: the model is 44.1 kHz only.
        log('vocal-mask-unavailable', { sampleRate });
        return null;
      }
      const result = await vocalHost().track(spectrogram);
      if (!result?.curve?.length) {
        log('vocal-mask-no-opinion', { elapsedMs: Date.now() - startedAt });
        return null;
      }
      log('vocal-mask-ready', {
        elapsedMs: Date.now() - startedAt,
        frames: spectrogram.frames,
        points: result.curve.length
      });
      return result;
    } catch (error) {
      log('vocal-mask-failed', { elapsedMs: Date.now() - startedAt, ...errorDetails(error) });
      return null;
    }
  });

  ipcMain.handle(AUDIO_ANALYSIS.RENDER_TRANSITION, async (_event, payload = {}) => {
    const engine = transition();
    if (!engine) {
      log('transition-render-unavailable', { loadAttempts: transitionLoadAttempts });
      throw new Error('Native transition rendering is unavailable.');
    }

    function transitionSource(value, label) {
      const channels = Array.isArray(value?.channels)
        ? value.channels.map(floatSamples)
        : [];
      const sampleRate = Number(value?.sampleRate);
      const bpm = Number(value?.bpm);
      if (!channels.length || channels.some((channel) => !channel?.length) ||
          !Number.isFinite(sampleRate) || sampleRate < 1000 || !Number.isFinite(bpm)) {
        throw new Error(`Invalid ${label} PCM for transition rendering.`);
      }
      // Overlap slices are bounded by design; refuse anything whole-track sized.
      const maxSamples = 48000 * 90 * channels.length;
      const total = channels.reduce((sum, channel) => sum + channel.length, 0);
      if (total > maxSamples) throw new Error(`Oversized ${label} PCM for transition rendering.`);
      return {
        channels,
        sampleRate,
        bpm,
        beats: finiteSeconds(value?.beats),
        downbeats: finiteSeconds(value?.downbeats)
      };
    }

    const outgoing = transitionSource(payload.outgoing, 'outgoing');
    const incoming = transitionSource(payload.incoming, 'incoming');
    const options = payload.options && typeof payload.options === 'object' ? payload.options : {};

    const startedAt = Date.now();
    log('transition-render-start', {
      sampleRate: outgoing.sampleRate,
      outgoingBpm: outgoing.bpm,
      incomingBpm: incoming.bpm,
      outgoingDownbeats: outgoing.downbeats.length,
      incomingDownbeats: incoming.downbeats.length
    });

    const result = await engine.renderTransition(outgoing, incoming, {
      outgoing: regionConstraint(options.outgoing),
      incoming: regionConstraint(options.incoming),
      ...(Array.isArray(options.beatLengths) ? { beatLengths: options.beatLengths.map(Number) } : {}),
      // Omitted rather than passed empty when the model had no opinion, so the
      // engine's own "no curve means full depth" default applies.
      ...(Array.isArray(options.duckCurve) && options.duckCurve.length
        ? { duckCurve: options.duckCurve.map(Number) }
        : {}),
      diagnostics: Boolean(options.diagnostics)
    });

    log(result.rendered ? 'transition-render-ready' : 'transition-render-refused', {
      elapsedMs: Date.now() - startedAt,
      rejected: String(result.rejected || ''),
      strategy: String(result.strategy || ''),
      beats: Number(result.beats) || 0,
      stretchRatio: Number(result.outgoingTempoRatio) || 0,
      overlapSamples: result.channels?.[0]?.length || 0
    });
    return {
      rendered: Boolean(result.rendered),
      rejected: String(result.rejected || ''),
      strategy: String(result.strategy || ''),
      summary: String(result.summary || ''),
      beats: Number(result.beats) || 0,
      duration: Number(result.duration) || 0,
      stretchRatio: Number(result.outgoingTempoRatio) || 1,
      incomingStretchRatio: Number(result.incomingTempoRatio) || 1,
      bpm: Number(result.targetBpm) || 0,
      outgoingStart: Number(result.outgoingStart) || 0,
      incomingStart: Number(result.incomingStart) || 0,
      outgoingResume: Number(result.outgoingResume) || 0,
      incomingResume: Number(result.incomingResume) || 0,
      sampleRate: Number(result.sampleRate) || outgoing.sampleRate,
      channels: result.channels || []
    };
  });

  ipcMain.handle(AUDIO_ANALYSIS.STORE, async (_event, payload = {}) => {
    await cacheReady;
    const trackId = cleanTrackId(payload.trackId);
    const source = payload?.result?.analysisSource === 'local-native' ? 'local-native' : 'local-worker';
    const result = localAnalysisWithSource(payload.result, source);
    if (!trackId || !result) {
      log('cache-store-invalid', { trackId, bpm: Number(payload?.result?.bpm) || 0 });
      throw new Error('A complete local audio analysis is required for caching.');
    }
    cache.delete(trackId);
    cache.set(trackId, { lastUsed: Date.now(), result });
    while (cache.size > MAX_CACHE_ITEMS) cache.delete(cache.keys().next().value);
    schedulePersist();
    log('cache-store-ready', { trackId, bpm: result.bpm, analysisSource: result.analysisSource });
    return true;
  });

  // The beat/downbeat model pass. Ordered ahead of Essentia because it answers
  // a strictly larger question: Essentia refines how much to *trust* the grid,
  // while the model can also fix which beat is beat one -- and when it has
  // done both, Essentia's opinion is redundant and its second of WASM time is
  // pure cost. Windows only arrive on transition-priority requests, so the
  // priority gate is enforced by the renderer's decision to send them.
  // The utility process holding the ONNX session, forked on the first request
  // that carries windows and re-forked after any exit.
  let modelHost = null;
  function host() {
    if (!modelHost) modelHost = createModelHost({ modelPath: beatModelPath, log });
    return modelHost;
  }

  // A second, separate utility process for the vocal model. Separate rather
  // than shared because the two run at completely different times -- the beat
  // model during analysis, this one while preparing a transition -- and a
  // single process would hold both models resident for the lifetime of either.
  let vocalMaskHost = null;
  function vocalHost() {
    if (!vocalMaskHost) vocalMaskHost = createVocalHost({ modelPath: vocalModelPath, log });
    return vocalMaskHost;
  }

  async function refineWithModelIfPossible(rawResult, beatWindows, trackId) {
    if (!beatWindows?.length) return null;
    const native = addon();
    if (typeof native?.beatSpectrogram !== 'function') return null;
    const startedAt = Date.now();
    try {
      const merged = await refineBeats(rawResult, beatWindows, {
        beatSpectrogram: (samples, sampleRate) => native.beatSpectrogram(samples, sampleRate),
        // Inference happens out of process; the host already knows the model
        // path, so the per-call one trackBeats would use is ignored.
        track: (spectrogram) => host().track(spectrogram),
        modelPath: beatModelPath,
        log: (event, details) => log(event, { trackId, ...details })
      });
      if (!merged) {
        log('beat-model-no-opinion', { trackId, elapsedMs: Date.now() - startedAt });
        return null;
      }
      log('beat-model-refined', {
        trackId,
        elapsedMs: Date.now() - startedAt,
        beatConfidence: merged.beatConfidence,
        nativeConfidence: Number(rawResult.beatConfidence) || 0,
        beatModelBpm: merged.beatModelBpm || 0,
        agreement: merged.beatModelAgreement,
        movedDownbeats: Boolean(merged.downbeats)
      });
      return { ...rawResult, ...merged };
    } catch (error) {
      log('beat-model-failed', { trackId, elapsedMs: Date.now() - startedAt, ...errorDetails(error) });
      return null;
    }
  }

  // Only the tracks immediately around a transition earn the Essentia pass.
  // Its rhythm extractor costs several times the whole native analysis, so
  // running it at Best Mix scale -- up to fifty tracks -- would turn a job of
  // roughly half a minute into several. Background analysis keeps the native
  // confidence, which is what shipped before this existed.
  async function refineIfWorthIt(rawResult, samples, sampleRate, trackId, priority) {
    const level = Number(priority);
    if (!Number.isFinite(level) || level > ANALYSIS_PRIORITY_NEXT) return rawResult;
    if (!Number.isFinite(Number(rawResult?.bpm)) || Number(rawResult?.bpm) <= 0) return rawResult;

    const refined = await Promise.resolve(
      refineConfidence(samples, sampleRate, { timeoutMs: ESSENTIA_TIMEOUT_MS })
    ).catch(() => null);
    // `essentiaChecked` records that the pass *ran*, which is not the same as
    // it producing a value -- Essentia declines on material its trackers cannot
    // read. Without that distinction a declined track looks identical to one
    // never attempted, and the cache check below would re-analyse it forever.
    if (!refined) {
      log('essentia-skipped', { trackId, priority: level });
      return { ...rawResult, essentiaChecked: true };
    }
    log('essentia-refined', {
      trackId,
      priority: level,
      elapsedMs: refined.elapsedMs,
      nativeConfidence: Number(rawResult.beatConfidence) || 0,
      essentiaConfidence: refined.essentiaConfidence,
      beatConfidence: refined.beatConfidence,
      nativeBpm: Number(rawResult.bpm) || 0,
      essentiaBpm: refined.essentiaBpm
    });
    // Confidence only. The native tempo stays authoritative: on the one file
    // with known ground truth it was the more accurate of the two.
    return {
      ...rawResult,
      beatConfidence: refined.beatConfidence,
      nativeBeatConfidence: Number(rawResult.beatConfidence) || 0,
      essentiaConfidence: refined.essentiaConfidence,
      essentiaBpm: refined.essentiaBpm,
      essentiaChecked: true
    };
  }

  ipcMain.handle(AUDIO_ANALYSIS.ANALYZE, async (_event, payload = {}) => {
    await cacheReady;
    const trackId = cleanTrackId(payload.trackId);
    if (!trackId) {
      log('native-request-invalid', { reason: 'missing-track-id' });
      throw new Error('A track ID is required for audio analysis.');
    }
    // A request carrying windows wants a model verdict; a cache entry written
    // by a Best Mix analysis has none. Serving it anyway would mean the model
    // never runs for any track Best Mix touched first, so the entry is
    // re-earned. Mirrors satisfiesPriority in the renderer, which prevents
    // most of these calls from being made at all.
    const wantsModel = Array.isArray(payload.beatWindows) && payload.beatWindows.length > 0;
    const existing = cached(trackId);
    if (existing && !(wantsModel && existing.beatModelChecked !== true)) {
      log('native-cache-hit', { trackId });
      return existing;
    }
    if (existing) log('native-cache-below-model', { trackId });
    if (inFlight.has(trackId)) {
      log('native-in-flight-reused', { trackId });
      return inFlight.get(trackId);
    }

    const native = addon();
    if (!native) {
      log('native-request-unavailable', { trackId, loadAttempts: nativeLoadAttempts });
      throw new Error('Native audio analysis is unavailable.');
    }
    const samples = floatSamples(payload.samples);
    const sampleRate = Number(payload.sampleRate);
    const duration = Number(payload.duration);
    // The boundary accepts mono Float32 PCM and caps storage at two hours. It
    // intentionally preserves the existing duration/sample-count assumption.
    if (!samples?.length || !Number.isFinite(sampleRate) || sampleRate < 1000 ||
        !Number.isFinite(duration) || duration <= 0 || samples.length > sampleRate * 60 * 60 * 2) {
      log('native-request-invalid', {
        trackId,
        sampleCount: samples?.length || 0,
        sampleRate,
        duration
      });
      throw new Error('Invalid PCM data for audio analysis.');
    }

    const startedAt = Date.now();
    log('native-analysis-start', { trackId, sampleCount: samples.length, sampleRate, duration });
    let nativeTask;
    try {
      nativeTask = Promise.resolve(native.analyze(samples, sampleRate, duration));
    } catch (error) {
      log('native-analysis-failed', { trackId, elapsedMs: Date.now() - startedAt, ...errorDetails(error) });
      throw error;
    }
    // Optional model windows: head/tail PCM at the beat model's rate. Parsed
    // leniently -- a malformed window degrades to "no model opinion", never to
    // a failed analysis.
    const beatWindows = (Array.isArray(payload.beatWindows) ? payload.beatWindows : [])
      .map((window) => ({
        samples: floatSamples(window?.samples),
        sampleRate: Number(window?.sampleRate) || 0,
        offsetSeconds: Number(window?.offsetSeconds) || 0
      }))
      .filter((window) => window.samples?.length > 0 && window.sampleRate >= 1000 &&
        window.offsetSeconds >= 0 && window.samples.length <= window.sampleRate * 120);

    const task = nativeTask
      .then(async (rawResult) => {
        const modelRefined = await refineWithModelIfPossible(rawResult, beatWindows, trackId);
        // `beatModelChecked` is stamped whenever windows arrived, even when the
        // model declined or broke: it records that the pass ran, exactly like
        // `essentiaChecked` above. Without it, the renderer's cache gate would
        // see a playback request, find no model verdict, and re-decode and
        // re-analyse the same track on every single play.
        const refined = modelRefined ??
          {
            ...await refineIfWorthIt(rawResult, samples, sampleRate, trackId, payload.priority),
            ...(beatWindows.length ? { beatModelChecked: true } : {})
          };
        const result = localAnalysisWithSource(refined, 'local-native');
        if (!result) {
          log('native-analysis-invalid', { trackId, bpm: Number(rawResult?.bpm) || 0 });
          throw new Error('Native audio analysis returned an invalid BPM.');
        }
        cache.set(trackId, { lastUsed: Date.now(), result });
        while (cache.size > MAX_CACHE_ITEMS) cache.delete(cache.keys().next().value);
        schedulePersist();
        log('native-analysis-ready', {
          trackId,
          elapsedMs: Date.now() - startedAt,
          bpm: Number(result?.bpm) || 0,
          mixInTime: Number(result?.mixInTime) || 0,
          mixOutTime: Number(result?.mixOutTime) || 0,
          contentEndTime: Number(result?.contentEndTime) || 0
        });
        return result;
      })
      .catch((error) => {
        log('native-analysis-failed', { trackId, elapsedMs: Date.now() - startedAt, ...errorDetails(error) });
        throw error;
      })
      .finally(() => inFlight.delete(trackId));
    inFlight.set(trackId, task);
    return task;
  });

  return {
    async stop() {
      // Queued native AsyncWorkers cannot be cancelled. Removing ingress and
      // flushing the current cache is therefore best-effort process teardown.
      clearTimeout(saveTimer);
      saveTimer = null;
      ipcMain.removeHandler(AUDIO_ANALYSIS.AVAILABLE);
      ipcMain.removeHandler(AUDIO_ANALYSIS.GET);
      ipcMain.removeHandler(AUDIO_ANALYSIS.DEBUG);
      ipcMain.removeHandler(AUDIO_ANALYSIS.STORE);
      ipcMain.removeHandler(AUDIO_ANALYSIS.ANALYZE);
      ipcMain.removeHandler(AUDIO_ANALYSIS.RENDER_TRANSITION);
      ipcMain.removeHandler(AUDIO_ANALYSIS.VOCAL_MASK);
      modelHost?.stop();
      modelHost = null;
      vocalMaskHost?.stop();
      vocalMaskHost = null;
      if (cache.size) await persist().catch(() => {});
    }
  };
}
