"use client";

// Serialize the three audio inputs (input track, timbre ref, structure
// ref) into / out of an exported config file. The OperatorStrip's Export
// dialog opts into this; Import consumes it.
//
// Why this lives separate from lib/config.ts: an RtmgConfig is pure JSON
// describing the engine + sliders, and round-trips losslessly through
// localStorage / the operator-editable public/config.json. Audio inputs
// are big binary blobs that only make sense as a session attachment —
// keeping them in their own module (and their own top-level `inputs`
// key on the exported object) means an export WITHOUT inputs is byte-for-
// byte the old format, and an old DEMON build importing a file WITH
// inputs just ignores the unknown key (mergeConfig drops it).
//
// Wire shape: a clip input embeds its PCM as a base64 16-bit WAV. A
// library-fixture input is server-resolvable by name, so it carries no
// audio. The input track may be either; timbre / structure refs mirror
// the RefSource mode already tracked in usePerformanceStore.
//
// Export carries ONLY the audio — never the encoded latent or ripped
// stems an upload accrues server-side. Those are re-derived on IMPORT:
// when a pod is reachable the clip is pushed back through the normal
// upload pipeline (uploadTrackToServer → server VAE-encode + stem-rip +
// sidecar persist) so it ends up identical to a freshly uploaded track;
// when no pod is connected yet the clip registers in-memory and the
// server derives its sidecar live on first swap (sendSwapSource ships
// PCM and CNN-detects on a miss). Either way the audio is the only thing
// that travels in the file.

import {
  decodeAudioFile,
  listFixtures,
  listUserUploads,
  loadFixtureAudio,
  pickDefaultFixture,
  uploadTrackToServer,
  type DecodedFixture,
  type StemSourceMode,
} from "@/engine/audio/loadFixture";
import {
  arrayBufferToBase64,
  base64ToArrayBuffer,
  encodeWavInterleaved,
  type SerializedInput,
  type SerializedInputs,
} from "@demon/client";

import { getConfig } from "@/lib/config";
import { trimAudioBuffer } from "@/lib/audio/trimAudioBuffer";
import { useCustomTracksStore } from "@/store/useCustomTracksStore";
import { usePerformanceStore, type RefSource } from "@/store/usePerformanceStore";
import { useSessionStore } from "@/store/useSessionStore";

// The SerializedInput(s) shape + WAV/base64 codec now live in the SDK (the
// single source of truth shared with the M4L bridge and the VST). Re-export
// the types so existing `@/lib/inputBundle` consumers keep their imports.
export type { SerializedInput, SerializedInputs };

// Fallback ref-duration cap when engine.max_source_duration_s is unset.
// Mirrors RefControl / TrackPicker.
const DEFAULT_TRIM_CAP_S = 120;

function encodeClip(
  name: string,
  decoded: DecodedFixture,
  sourceMode?: StemSourceMode,
): SerializedInput {
  const wav = encodeWavInterleaved(
    decoded.interleaved,
    decoded.channels,
    decoded.sampleRate,
  );
  return {
    kind: "clip",
    name,
    ...(sourceMode ? { sourceMode } : {}),
    wavBase64: arrayBufferToBase64(wav),
  };
}

// ── Capture (export) ───────────────────────────────────────────────────

/** Resolve a custom track's decoded PCM for embedding. Freshly uploaded
 *  tracks carry it in the store; a seeded "persisted" upload (added via
 *  addPersisted from useSeedUserUploads) has no in-memory buffer until
 *  it's played, and loadFixtureAudio never writes one back — so fetch +
 *  decode it from the pod's /user_uploads on demand. Keeps the export's
 *  portability promise (the audio always travels, even for an upload the
 *  user only re-selected from a prior session). */
async function resolveDecoded(
  name: string,
  cached: DecodedFixture | undefined,
): Promise<DecodedFixture> {
  return cached ?? (await loadFixtureAudio(name));
}

/** Snapshot the active input track. A custom upload (present in
 *  useCustomTracksStore) embeds its PCM; a library fixture serializes by
 *  name. Returns null when nothing is loaded. */
async function captureTrack(): Promise<SerializedInput | null> {
  const name = usePerformanceStore.getState().fixture;
  if (!name) return null;
  const track = useCustomTracksStore.getState().tracks.get(name);
  if (!track) return { kind: "fixture", name };
  const decoded = await resolveDecoded(name, track.decoded);
  return encodeClip(name, decoded, track.sourceMode);
}

/** Snapshot a timbre / structure RefSource. Clip refs embed PCM pulled
 *  from useCustomTracksStore (fetched from the pod if the upload was
 *  only seeded this session); a clip whose upload record is gone is
 *  dropped (null) rather than exported as an unloadable name. */
async function captureRef(ref: RefSource | null): Promise<SerializedInput | null> {
  if (!ref) return null;
  if (ref.mode === "fixture") return { kind: "fixture", name: ref.name };
  const track = useCustomTracksStore.getState().tracks.get(ref.name);
  if (!track) return null;
  try {
    const decoded = await resolveDecoded(ref.name, track.decoded);
    return encodeClip(ref.name, decoded, track.sourceMode);
  } catch {
    // Upload record present but audio no longer fetchable — drop it
    // rather than ship a ref the importer can't decode.
    return null;
  }
}

/** Build the `inputs` object for an export — captures every active input
 *  (track, timbre ref, structure ref). Async: a seeded upload's PCM may
 *  need a pod fetch before it can be embedded. */
export async function captureInputs(): Promise<SerializedInputs> {
  const perf = usePerformanceStore.getState();
  const [track, timbre, structure] = await Promise.all([
    captureTrack(),
    captureRef(perf.timbreRef),
    captureRef(perf.structRef),
  ]);
  return { track, timbre, structure };
}

/** Lightweight, name-only snapshot of the active input track for a local
 *  export: records the track by name with NO embedded audio, so the file
 *  reopens the correct upload from the user's own library/pod uploads
 *  without carrying the multi-MB base64 WAV. An upload and a library
 *  fixture both serialize as a by-name `fixture` reference — a
 *  server-resident upload swaps by name on import exactly like a library
 *  track (applyTrack → setFixture → useFixtureSwap's serverResident path).
 *  Timbre / structure refs are intentionally omitted; they only resolve
 *  against a live session and a name-only ref can't be re-sent, so the
 *  full-serialize path remains the way to carry those. Returns {} when no
 *  track is active, or when the active track is a non-persisted in-memory
 *  upload (no-pod fallback): its name resolves nowhere on a later load, so
 *  a by-name ref would be dead — only the Serialize-on embed can carry it.
 *  A built-in fixture and a persisted upload both pass isServerResident
 *  (the upload lives on the pod's /user_uploads and re-seeds into the store
 *  as persisted), so both serialize as a resolvable by-name reference. */
export function captureTrackReference(): SerializedInputs {
  const name = usePerformanceStore.getState().fixture;
  if (!name) return {};
  if (!useCustomTracksStore.getState().isServerResident(name)) return {};
  return { track: { kind: "fixture", name } };
}

/** Whether any input axis is currently active — gates the Export
 *  dialog's "Serialize inputs" checkbox. */
export function anyInputPresent(): boolean {
  const perf = usePerformanceStore.getState();
  return Boolean(perf.fixture || perf.timbreRef || perf.structRef);
}

// ── Apply (import) ─────────────────────────────────────────────────────

/** Snapshot of what names resolve on this pod, used to validate by-name
 *  (`kind:"fixture"`) inputs at import time. A name resolves if it's a
 *  built-in fixture, a persisted pod upload, or an in-memory custom track.
 *  `canValidate` is false when the fixture catalog couldn't be fetched
 *  (pod unreachable) — callers then skip validation rather than falsely
 *  flag a valid name as missing. */
interface ResolveCtx {
  /** Built-in fixture names, for picking the default when a track is
   *  missing. Empty when the catalog couldn't be fetched. */
  fixtureNames: string[];
  /** Union of every name that resolves by name on this pod. */
  known: Set<string>;
  canValidate: boolean;
}

async function buildResolveCtx(): Promise<ResolveCtx> {
  const storeNames = useCustomTracksStore.getState().names;
  let fixtureNames: string[] | null = null;
  try {
    fixtureNames = await listFixtures();
  } catch {
    // Pod unreachable / catalog endpoint down — can't validate names.
    fixtureNames = null;
  }
  let uploadNames: string[] = [];
  try {
    uploadNames = await listUserUploads();
  } catch {
    uploadNames = [];
  }
  const known = new Set<string>([
    ...storeNames,
    ...uploadNames,
    ...(fixtureNames ?? []),
  ]);
  return {
    fixtureNames: fixtureNames ?? [],
    known,
    canValidate: fixtureNames !== null,
  };
}

/** A by-name input resolves when validation is impossible (offline) or the
 *  name is in the known set. Clip inputs carry their own audio, so they
 *  never route through here. */
function fixtureResolves(ctx: ResolveCtx, name: string): boolean {
  return !ctx.canValidate || ctx.known.has(name);
}

/** Decode a clip's embedded WAV and register it as a custom track,
 *  best-effort pre-encoding it through the upload pipeline first.
 *
 *  When a pod is reachable, uploadTrackToServer makes the server
 *  VAE-encode the latent, rip stems, and persist both as sidecars
 *  (mirrors commitUploadedTrack) — the imported clip then behaves
 *  exactly like a freshly uploaded track, and the server owns the
 *  canonical de-duplicated name.
 *
 *  The pre-encode is an OPTIMIZATION, not a requirement: the swap path
 *  (sendSwapSource) and the ref path (sendSetTimbreSource) both ship raw
 *  PCM, and the server encodes + CNN-detects live on a sidecar miss. So
 *  if the upload can't run (no pod connected yet) we fall back to a
 *  plain in-memory register under a client-de-duplicated name — the clip
 *  is still selectable and swappable, its sidecar just gets derived
 *  lazily on first use. This keeps import-before-connect working with no
 *  regression instead of dropping the input. Returns the chosen name. */
async function registerClip(input: {
  name: string;
  sourceMode?: StemSourceMode;
  wavBase64: string;
}): Promise<string> {
  const bytes = base64ToArrayBuffer(input.wavBase64);
  const file = new File([bytes], input.name, { type: "audio/wav" });
  // decodeAudioFile re-applies pool alignment + the browser-memory
  // length ceiling, exactly as a fresh upload would. A decode failure
  // (bad base64 / too short) is genuinely unusable, so it propagates and
  // the caller skips this input.
  const decoded = await decodeAudioFile(file);
  const custom = useCustomTracksStore.getState();
  const sourceMode = input.sourceMode ?? "full";

  // Surface progress on the session status bar: the server encode can
  // take a few seconds and the import toast only fires once we resolve.
  const { setStatus, status } = useSessionStore.getState();
  setStatus(status, `Encoding ${input.name}...`);
  try {
    const uploaded = await uploadTrackToServer(input.name, decoded);
    // Persisted on the pod (audio + sidecars + stems) → swap by name.
    custom.add(uploaded.name, decoded, file, sourceMode, true);
    return uploaded.name;
  } catch {
    // No pod reachable (or encode error) — register in-memory; the
    // sidecar is derived live when the clip is first swapped in.
    let chosen = input.name;
    let i = 1;
    while (custom.has(chosen)) chosen = `${input.name} (${i++})`;
    custom.add(chosen, decoded, file, sourceMode);
    return chosen;
  } finally {
    const s = useSessionStore.getState();
    s.setStatus(s.status, "");
  }
}

/** Head-trim a decoded ref to the configured source-duration cap, the
 *  same clamp RefControl applies before shipping a ref over the WS. */
function clampRefDuration(decoded: DecodedFixture, capS: number): DecodedFixture {
  const durS = decoded.frames / decoded.sampleRate;
  if (durS <= capS) return decoded;
  return trimAudioBuffer(decoded, 0, capS);
}

/** Apply the input track. Returns "applied" when the requested track was
 *  used, or "defaulted" when its by-name reference didn't resolve on this
 *  pod and we fell back to the default fixture (the caller warns). */
async function applyTrack(
  input: SerializedInput,
  ctx: ResolveCtx,
): Promise<"applied" | "defaulted"> {
  const perf = usePerformanceStore.getState();
  if (input.kind === "fixture") {
    if (!fixtureResolves(ctx, input.name)) {
      // The imported config names a track this pod doesn't have. Fall back
      // to the default fixture so the session is still usable, and let the
      // caller surface a warning. pickDefaultFixture returns "" only when
      // the catalog is empty — leave the current selection untouched then.
      const def = pickDefaultFixture(ctx.fixtureNames);
      if (def) perf.setFixture(def);
      return "defaulted";
    }
    perf.setFixture(input.name);
    return "applied";
  }
  const name = await registerClip(input);
  // Writing perf.fixture drives useFixtureSwap: live sessions hot-swap
  // the source; a not-yet-started session picks it up on the next Play
  // (resolveFixtureForConnect reads perf.fixture, loadFixtureAudio finds
  // the clip in the custom-tracks cache).
  perf.setFixture(name);
  return "applied";
}

/** Apply a timbre / structure ref. Returns "applied" when it reached the
 *  server, "needSession" when its audio is registered/selectable but no
 *  live session exists to send it to yet (refs aren't part of
 *  SessionConfig, so the server boots without them), or "missing" when a
 *  by-name ref doesn't resolve on this pod (dropped — no default analog).
 *  Clip audio is always registered first so it shows in the dropdowns. */
async function applyRef(
  kind: "timbre" | "structure",
  input: SerializedInput,
  ctx: ResolveCtx,
): Promise<"applied" | "needSession" | "missing"> {
  const session = useSessionStore.getState();
  const perf = usePerformanceStore.getState();
  const ready = session.status === "ready" && session.remote != null;
  const setRef =
    kind === "timbre" ? perf.setTimbreRef : perf.setStructRef;

  if (input.kind === "fixture") {
    // A ref has no "default" analog (it's an optional overlay), so an
    // unresolvable by-name ref is dropped rather than substituted — the
    // caller warns and the axis simply stays unset.
    if (!fixtureResolves(ctx, input.name)) return "missing";
    if (!ready || !session.remote) return "needSession";
    if (kind === "timbre") session.remote.sendSetTimbreFixture(input.name);
    else session.remote.sendSetStructureFixture(input.name);
    setRef({ mode: "fixture", name: input.name });
    return "applied";
  }

  // Clip: register first so it's selectable regardless of session state.
  const name = await registerClip(input);
  if (!ready || !session.remote) return "needSession";
  const decoded = useCustomTracksStore.getState().tracks.get(name)?.decoded;
  if (!decoded) return "needSession";
  const capS = getConfig().engine.max_source_duration_s ?? DEFAULT_TRIM_CAP_S;
  const clamped = clampRefDuration(decoded, capS);
  const ok =
    kind === "timbre"
      ? session.remote.sendSetTimbreSource(
          clamped.interleaved,
          clamped.channels,
          name,
        )
      : session.remote.sendSetStructureSource(
          clamped.interleaved,
          clamped.channels,
          name,
        );
  if (ok) {
    setRef({ mode: "clip", name });
    return "applied";
  }
  return "needSession";
}

export interface ApplyInputsResult {
  /** Inputs that took full effect. */
  applied: string[];
  /** Refs whose audio was registered but couldn't be sent because no
   *  session is live yet — the caller can hint the user to press Play. */
  needSession: string[];
  /** Inputs whose by-name reference didn't resolve on this pod. Each entry
   *  is a human phrase describing the axis and what happened (track →
   *  defaulted, refs → skipped) so the caller can warn directly. */
  missing: string[];
}

/** Apply a deserialized `inputs` object to the live stores + session.
 *  Best-effort: a malformed or unresolvable input is skipped (or, for the
 *  track, replaced with the default fixture) rather than aborting the whole
 *  import. Validation of by-name inputs uses one catalog snapshot
 *  (buildResolveCtx) shared across all three axes. */
export async function applyInputs(
  inputs: SerializedInputs,
): Promise<ApplyInputsResult> {
  const applied: string[] = [];
  const needSession: string[] = [];
  const missing: string[] = [];
  const ctx = await buildResolveCtx();

  if (inputs.track) {
    try {
      const result = await applyTrack(inputs.track, ctx);
      if (result === "applied") applied.push("track");
      else missing.push("track not found (using default)");
    } catch {
      // Unresolvable embedded clip (bad base64 / too short) — leave the
      // current source untouched and warn.
      missing.push("track unreadable");
    }
  }

  const refs: Array<["timbre" | "structure", SerializedInput | null | undefined]> = [
    ["timbre", inputs.timbre],
    ["structure", inputs.structure],
  ];
  for (const [kind, ref] of refs) {
    if (!ref) continue;
    try {
      const result = await applyRef(kind, ref, ctx);
      if (result === "applied") applied.push(kind);
      else if (result === "needSession") needSession.push(kind);
      else missing.push(`${kind} not found (skipped)`);
    } catch {
      // Skip a ref that fails to decode / send.
      missing.push(`${kind} unreadable`);
    }
  }

  return { applied, needSession, missing };
}

/** True when an imported, parsed object actually carries any input. */
export function hasInputs(inputs: SerializedInputs | null | undefined): boolean {
  if (!inputs) return false;
  return Boolean(inputs.track || inputs.timbre || inputs.structure);
}
