/**
 * AudioFeaturesService — two-pass audio feature extraction.
 *
 * Pass 1 (fast): read BPM and key from embedded file tags via music-metadata.
 *   Many files tagged by beets / MusicBrainz Picard / Mixed In Key already
 *   carry these, so we get the answer for free.
 *
 * Pass 2 (compute): run essentia.js (WebAssembly) on the decoded PCM signal
 *   for anything still missing.  Audio decoding is done by a ffmpeg subprocess
 *   so every format ffmpeg understands (MP3, FLAC, AAC, OGG, …) is supported.
 *   Computed features: BPM, key, energy, danceability, dynamic complexity.
 *
 * The two passes are combined via COALESCE so that embedded-tag values are
 * never overwritten once they exist.
 */
import { spawn } from 'child_process';
import mm from 'music-metadata';
import type { Queryable } from './db.js';
import {
  AUDIO_ANALYSIS_VERSION,
  normaliseDanceability,
  normaliseMeanSquareEnergy,
  validBpm,
  validKey,
} from './audio-analysis.js';

// Lazy WASM singleton — heavy to load (~2.4 MB), so we initialise once and
// reuse across all enrichment jobs within the same worker process.
let essentiaReady: Promise<{ essentia: any }> | null = null;

async function getEssentia() {
  if (!essentiaReady) {
    essentiaReady = (async () => {
      const mod = await import('essentia.js');
      // essentia.js v0.1.3: default export contains { Essentia, EssentiaWASM, ... }
      // EssentiaWASM is already the instantiated WASM module (not a function)
      const EssentiaWASM = mod.default?.EssentiaWASM ?? mod.EssentiaWASM;
      const Essentia = mod.default?.Essentia ?? mod.Essentia;
      if (!EssentiaWASM || !Essentia) {
        throw new Error('essentia.js exports missing EssentiaWASM or Essentia');
      }
      const essentia = new Essentia(EssentiaWASM);
      return { essentia };
    })();
  }
  return essentiaReady;
}

const MAX_DECODE_SECONDS = Math.max(30, Math.min(Number(process.env.MUZICK_AUDIO_MAX_SECONDS || 20 * 60), 2 * 60 * 60));
const MAX_PCM_BYTES = Math.max(4 * 1024 * 1024, Math.min(Number(process.env.MUZICK_AUDIO_MAX_PCM_BYTES || 96 * 1024 * 1024), 256 * 1024 * 1024));

/** Decode a bounded audio prefix to mono Float32Array at 44100 Hz via ffmpeg. */
function decodeAudioToFloat32(filePath: string): Promise<Float32Array> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    let bytes = 0;
    let settled = false;
    const fail = (error: Error) => {
      if (settled) return;
      settled = true;
      reject(error);
    };
    const ff = spawn('ffmpeg', [
      '-i', filePath,
      '-vn',
      '-t', String(MAX_DECODE_SECONDS),
      '-acodec', 'pcm_f32le',
      '-ar', '44100',
      '-ac', '1',
      '-f', 'f32le',
      'pipe:1',
    ], { stdio: ['ignore', 'pipe', 'ignore'] });

    ff.stdout.on('data', (chunk: Buffer) => {
      bytes += chunk.length;
      if (bytes > MAX_PCM_BYTES) {
        ff.kill('SIGTERM');
        fail(new Error(`decoded audio exceeds ${MAX_PCM_BYTES} byte safety limit`));
        return;
      }
      chunks.push(chunk);
    });
    ff.on('error', (error) => fail(error));
    ff.on('close', (code) => {
      if (settled) return;
      if (code !== 0) return fail(new Error(`ffmpeg exited with code ${code} for ${filePath}`));
      const buf = Buffer.concat(chunks);
      settled = true;
      resolve(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
    });
  });
}

export interface AudioFeatures {
  bpm: number | null;
  key: string | null;
  energy: number | null;
  danceability: number | null;
}

export class AudioFeaturesService {
  constructor(private pgClient: Queryable) {}

  async ensureSchema(): Promise<void> {
    await this.pgClient.query(
      `CREATE TABLE IF NOT EXISTS track_audio_features (
         track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
         bpm REAL,
         key TEXT,
         energy REAL,
         danceability REAL,
         valence REAL,
         acousticness REAL,
         instrumentalness REAL,
         liveness REAL,
         valence_score REAL,
         tempo REAL,
         analysis_version SMALLINT NOT NULL DEFAULT 0,
         source_hash TEXT,
         analyzed_at TIMESTAMPTZ
       )`
    );
    // CREATE TABLE IF NOT EXISTS does not evolve an existing library. Keep the
    // worker safe when it starts before the backend has had a chance to apply
    // the corresponding migration.
    await this.pgClient.query(
      `ALTER TABLE track_audio_features
         ADD COLUMN IF NOT EXISTS analysis_version SMALLINT NOT NULL DEFAULT 0,
         ADD COLUMN IF NOT EXISTS source_hash TEXT,
         ADD COLUMN IF NOT EXISTS analyzed_at TIMESTAMPTZ`
    );
  }

  // ── Pass 1: embedded tags ──────────────────────────────────────────────────

  private async readEmbeddedTags(filePath: string): Promise<{ bpm: number | null; key: string | null; replayGainDb: number | null }> {
    try {
      const { common } = await mm.parseFile(filePath, { duration: false });
      const bpm = validBpm(common.bpm);
      const key = validKey(common.key);
      const rgRaw = (common as any).replaygain_track_gain;
      let replayGainDb: number | null = null;
      if (rgRaw != null) {
        const val = typeof rgRaw === 'object' ? rgRaw.dB : Number(String(rgRaw).replace(/[^\d.-]/g, ''));
        if (Number.isFinite(val)) replayGainDb = val;
      }
      return { bpm, key, replayGainDb };
    } catch {
      return { bpm: null, key: null, replayGainDb: null };
    }
  }

  // ── Pass 2: essentia.js DSP ────────────────────────────────────────────────

  private async analyseWithEssentia(filePath: string, needBpm: boolean, needKey: boolean): Promise<AudioFeatures> {
    const result: AudioFeatures = { bpm: null, key: null, energy: null, danceability: null };

    const { essentia } = await getEssentia();
    const signal = await decodeAudioToFloat32(filePath);
    if (signal.length === 0) return result;

    const vec = essentia.arrayToVector(signal);

    if (needBpm) {
      try {
        const rhythm = essentia.RhythmExtractor2013(vec);
        result.bpm = validBpm(rhythm.bpm);
      } catch (err) {
        console.warn('[AudioFeatures] RhythmExtractor2013 failed:', (err as Error).message);
      }
    }

    if (needKey) {
      try {
        const keyResult = essentia.KeyExtractor(vec);
        if (keyResult.key) result.key = validKey(`${keyResult.key} ${keyResult.scale}`);
      } catch (err) {
        console.warn('[AudioFeatures] KeyExtractor failed:', (err as Error).message);
      }
    }

    // Energy and danceability are only available from signal analysis — always compute.
    try {
      const energy = essentia.Energy(vec);
      // Energy is the sum of squared samples. Convert it to a mean-square
      // level, then use the shared dBFS normalization rather than an arbitrary
      // linear multiplier that saturates mastered music near 1.0.
      result.energy = normaliseMeanSquareEnergy(energy.energy / signal.length);
    } catch (err) {
      console.warn('[AudioFeatures] Energy failed:', (err as Error).message);
    }

    try {
      const dance = essentia.Danceability(vec);
      result.danceability = normaliseDanceability(dance.danceability);
    } catch (err) {
      console.warn('[AudioFeatures] Danceability failed:', (err as Error).message);
    }

    vec.delete(); // free WASM memory
    return result;
  }

  // ── Public entry point ─────────────────────────────────────────────────────

  async extractAndPersist(trackId: string): Promise<void> {
    const pathRes = await this.pgClient.query<{ path: string; hash: string }>(
      'SELECT path, hash FROM tracks WHERE id = $1',
      [trackId]
    );
    const row = pathRes.rows[0];
    if (!row) {
      console.warn(`[AudioFeatures] track not found: ${trackId}`);
      return;
    }

    const tags = await this.readEmbeddedTags(row.path);

    // Rough energy proxy from ReplayGain when available (free, no DSP).
    let tagEnergy: number | null = null;
    if (tags.replayGainDb !== null) {
      tagEnergy = Math.min(1, Math.max(0, (tags.replayGainDb + 18) / 21));
    }

    // Only spin up Essentia when embedded tags are missing something useful.
    const needBpm = tags.bpm === null;
    const needKey = tags.key === null;
    // Always run Essentia for energy/danceability since no tag source covers those.
    const dsq = await this.analyseWithEssentia(row.path, needBpm, needKey);

    const bpm = tags.bpm ?? dsq.bpm;
    const key = tags.key ?? dsq.key;
    const energy = tagEnergy ?? dsq.energy;
    const danceability = dsq.danceability;
    const tempo = bpm;

    await this.pgClient.query(
      `INSERT INTO track_audio_features
         (track_id, bpm, key, energy, danceability, tempo, analysis_version, source_hash, analyzed_at)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
       ON CONFLICT (track_id) DO UPDATE SET
         bpm              = EXCLUDED.bpm,
         key              = EXCLUDED.key,
         energy           = EXCLUDED.energy,
         danceability     = EXCLUDED.danceability,
         tempo            = EXCLUDED.tempo,
         analysis_version = EXCLUDED.analysis_version,
         source_hash      = EXCLUDED.source_hash,
         analyzed_at      = EXCLUDED.analyzed_at`,
      [trackId, bpm, key, energy, danceability, tempo, AUDIO_ANALYSIS_VERSION, row.hash]
    );

    const parts: string[] = [];
    if (bpm) parts.push(`bpm:${bpm.toFixed(1)}${tags.bpm ? '(tag)' : '(dsp)'}`);
    if (key) parts.push(`key:${key}${tags.key ? '(tag)' : '(dsp)'}`);
    if (energy !== null) parts.push(`energy:${energy.toFixed(2)}`);
    if (danceability !== null) parts.push(`dance:${danceability.toFixed(2)}`);
    console.log(
      `[AudioFeatures] ${trackId}: ${parts.length ? parts.join(', ') : 'no features extracted'}`
    );
  }
}
