import { spawn } from "child_process";
import fs from "fs/promises";
import path from "path";
import ffmpegStatic from "ffmpeg-static";
import { parseFile } from "music-metadata";
import { getVideoAttributionText } from "../branding";
import { getResolution } from "../constants";
import {
  isCuratedFontKey,
  sanitizeFontfileForFilter,
} from "../fonts";
import { resolveCuratedFontPath, resolveSystemFontPath, assertFontFile } from "../fonts-server";
import {
  buildArtTrackFilterComplex,
  buildClassicBlurFillFilterComplex,
  isLowerCornerTemplate,
  normalizeLayoutSettings,
  type LayoutSettings,
} from "../layout";
import {
  WATERMARK_WIDTH_FRACTION,
  watermarkFontSizeForWidth,
} from "../preview-typography";
import { getWatermarkPath } from "../storage";
import {
  buildDrawtextFilter,
  normalizeWatermarkSettings,
  overlayXy,
  sanitizeDrawtext,
  type WatermarkSettings,
} from "../watermark";
import { buildBrandWatermarkOverlayFilters } from "./brand-watermark";

function getFfmpegPath(): string {
  if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
  if (ffmpegStatic) return ffmpegStatic;
  return "ffmpeg";
}

export { getFfmpegPath };

async function getAudioDurationSeconds(audioPath: string): Promise<number> {
  const { format } = await parseFile(audioPath);
  const duration = format.duration;
  if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0) {
    throw new Error(`Could not determine audio duration for ${audioPath}`);
  }
  return duration;
}

/** FFmpeg 5.x mishandles `-shortest` with `-loop 1` still inputs (adds ~50s tail silence). */
function formatOutputDurationSeconds(seconds: number): string {
  return Math.max(0.01, seconds - 0.02).toFixed(3);
}

async function fileExists(p: string): Promise<boolean> {
  try {
    await fs.access(p);
    return true;
  } catch {
    return false;
  }
}

/** Verify PNG magic bytes (prevents MIME spoof → FFmpeg surprises). */
export async function assertPngFile(filePath: string): Promise<void> {
  const fh = await fs.open(filePath, "r");
  try {
    const buf = Buffer.alloc(8);
    await fh.read(buf, 0, 8, 0);
    const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
    if (!buf.equals(sig)) {
      throw new Error("Logo must be a valid PNG file");
    }
  } finally {
    await fh.close();
  }
}

async function resolveFontfileEscaped(
  settings: WatermarkSettings,
  weight: "regular" | "bold" = "regular",
): Promise<string | null> {
  const key = settings.fontKey ?? "system";
  if (key === "system") {
    const preferred = resolveSystemFontPath(weight);
    const fallback = weight === "bold" ? resolveSystemFontPath("regular") : preferred;
    for (const fontPath of weight === "bold" ? [preferred, fallback] : [preferred]) {
      if (!(await fileExists(fontPath))) continue;
      try {
        await assertFontFile(fontPath);
        return sanitizeFontfileForFilter(fontPath);
      } catch (err) {
        console.warn(
          `[ffmpeg] system font invalid at ${fontPath}: ${err instanceof Error ? err.message : err}`,
        );
      }
    }
    console.warn(`[ffmpeg] system font missing; using FFmpeg default`);
    return null;
  }

  if (key === "custom") {
    if (!settings.fontPath) return null;
    if (!(await fileExists(settings.fontPath))) {
      throw new Error("Custom watermark font file not found");
    }
    return sanitizeFontfileForFilter(path.resolve(settings.fontPath));
  }

  if (isCuratedFontKey(key)) {
    const preferred = resolveCuratedFontPath(key, weight);
    const fallback = weight === "bold" ? resolveCuratedFontPath(key, "regular") : preferred;
    for (const fontPath of weight === "bold" && preferred !== fallback ? [preferred, fallback] : [preferred]) {
      if (!(await fileExists(fontPath))) continue;
      try {
        await assertFontFile(fontPath);
        return sanitizeFontfileForFilter(fontPath);
      } catch {
        /* try next */
      }
    }
    console.warn(`[ffmpeg] curated font missing: ${key}; using system font`);
    return null;
  }

  return null;
}

function classicScaleFilter(width: number, height: number): string {
  return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`;
}

/**
 * Plan-aware audio for MP4.
 * OSS / self-hosted defaults to Pro-quality 320k AAC.
 */
export type AudioEncodeOptions = {
  bitrateKbps: 192 | 320;
  copyWhenSafe: boolean;
  audioPath: string;
};

function isCopySafeAudioPath(audioPath: string): boolean {
  const ext = path.extname(audioPath).toLowerCase();
  return ext === ".aac" || ext === ".m4a" || ext === ".mp4";
}

function pushAudioEncodeArgs(args: string[], audio: AudioEncodeOptions): void {
  if (audio.copyWhenSafe && isCopySafeAudioPath(audio.audioPath)) {
    args.push("-c:a", "copy");
    return;
  }
  args.push(
    "-c:a",
    "aac",
    "-b:a",
    `${audio.bitrateKbps}k`,
    "-ar",
    "44100",
    "-ac",
    "2",
  );
}

/**
 * Art-track filter that ends at `outLabel` instead of hardcoded [laid].
 */
function artTrackFilterEndingAt(
  opts: Parameters<typeof buildArtTrackFilterComplex>[0],
  outLabel: string,
): string {
  return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
}

function classicBlurFillEndingAt(
  opts: Parameters<typeof buildClassicBlurFillFilterComplex>[0],
  outLabel: string,
): string {
  return buildClassicBlurFillFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
}

export async function encodeVideo(options: {
  imagePath: string;
  audioPath: string;
  outputPath: string;
  resolution: string;
  includeWatermark: boolean;
  watermark?: Partial<WatermarkSettings> | null;
  layout?: LayoutSettings | null;
  /** On-video song title (art-track layouts). */
  songTitle?: string;
  artist?: string | null;
  /**
   * Optional full-frame background for lower-corner templates.
   * When set with LOWER_LEFT / LOWER_RIGHT, blur fill uses this image; cover stays sharp.
   */
  backgroundImagePath?: string | null;
  /** Defaults to 320k (self-hosted / studio). */
  audioBitrateKbps?: 192 | 320;
  audioCopyWhenSafe?: boolean;
}): Promise<void> {
  const res = getResolution(options.resolution);
  if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);

  const audioEncode: AudioEncodeOptions = {
    bitrateKbps: options.audioBitrateKbps ?? 320,
    copyWhenSafe: options.audioCopyWhenSafe ?? true,
    audioPath: options.audioPath,
  };

  await fs.mkdir(path.dirname(options.outputPath), { recursive: true });

  const outputDurationSec = formatOutputDurationSeconds(
    await getAudioDurationSeconds(options.audioPath),
  );

  const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
  const layoutSettings = options.layout
    ? normalizeLayoutSettings(options.layout)
    : null;
  const artTrack = Boolean(layoutSettings?.template);
  const classicBlurFill = Boolean(layoutSettings && !artTrack && layoutSettings.blurFill);
  const layout = artTrack && layoutSettings ? layoutSettings : null;
  const watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION));
  const fontSize = watermarkFontSizeForWidth(res.width);
  const fontfile = await resolveFontfileEscaped(settings, "regular");
  const titleFontfile =
    layout?.titleBold ? await resolveFontfileEscaped(settings, "bold") : null;
  // Only pass a distinct bold face when it differs from regular (avoids faux-bold when bold TTF exists).
  const titleFontfileDistinct =
    titleFontfile && titleFontfile !== fontfile ? titleFontfile : null;

  const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];

  let nextInput = 2;
  let separateBackgroundInputIndex: number | null = null;
  let logoInputIndex: number | null = null;
  let defaultWmInputIndex: number | null = null;

  const useSeparateBackground =
    Boolean(layout && isLowerCornerTemplate(layout.template) && options.backgroundImagePath);

  if (useSeparateBackground && options.backgroundImagePath) {
    if (!(await fileExists(options.backgroundImagePath))) {
      throw new Error("Background image file not found");
    }
    args.push("-loop", "1", "-r", "1", "-i", options.backgroundImagePath);
    separateBackgroundInputIndex = nextInput++;
  }

  const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
  const defaultPath = getWatermarkPath();
  const useDefaultPng =
    settings.mode === "default" && (await fileExists(defaultPath));

  if (needsLogo && settings.logoPath) {
    if (!(await fileExists(settings.logoPath))) {
      throw new Error("Watermark logo file not found");
    }
    await assertPngFile(settings.logoPath);
    args.push("-i", settings.logoPath);
    logoInputIndex = nextInput++;
  } else if (useDefaultPng) {
    args.push("-i", defaultPath);
    defaultWmInputIndex = nextInput++;
  }

  const applyWm =
    settings.mode === "logo" && logoInputIndex !== null
      ? ("logo" as const)
      : settings.mode === "text" && settings.text?.trim()
        ? ("text" as const)
        : settings.mode === "default" && defaultWmInputIndex !== null
          ? ("default-png" as const)
          : settings.mode === "default"
            ? ("default-text" as const)
            : ("none" as const);

  // Fast path: classic letterbox (black bars), no watermark, no blur fill
  if (!layout && !classicBlurFill && applyWm === "none") {
    args.push("-vf", classicScaleFilter(res.width, res.height));
    args.push(
      "-c:v",
      "libx264",
      "-preset",
      "ultrafast",
      "-tune",
      "stillimage",
      "-r",
      "1",
    );
    pushAudioEncodeArgs(args, audioEncode);
    args.push(
      "-t",
      outputDurationSec,
      "-pix_fmt",
      "yuv420p",
      options.outputPath,
    );
    await runFfmpeg(args);
    return;
  }

  const outDirect = applyWm === "none";
  const baseLabel = outDirect ? "vout" : "base";
  const filterParts: string[] = [];

  if (layout) {
    const titleEscaped = sanitizeDrawtext(options.songTitle?.trim() || "Untitled");
    const artistRaw = options.artist?.trim();
    const artistEscaped = artistRaw ? sanitizeDrawtext(artistRaw) : null;
    filterParts.push(
      artTrackFilterEndingAt(
        {
          width: res.width,
          height: res.height,
          layout,
          titleEscaped,
          artistEscaped,
          fontfileEscaped: fontfile,
          titleFontfileEscaped: titleFontfileDistinct,
          separateBackgroundInputIndex,
        },
        baseLabel,
      ),
    );
  } else if (classicBlurFill && layoutSettings) {
    filterParts.push(
      classicBlurFillEndingAt(
        {
          width: res.width,
          height: res.height,
          blurAmount: layoutSettings.blurAmount,
          blurOpacity: layoutSettings.blurOpacity,
        },
        baseLabel,
      ),
    );
  } else {
    filterParts.push(`[0:v]${classicScaleFilter(res.width, res.height)}[${baseLabel}]`);
  }

  if (applyWm === "logo" && logoInputIndex !== null) {
    const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY);
    filterParts.push(
      `[${logoInputIndex}:v]scale=${watermarkWidth}:-1[wm]`,
      `[${baseLabel}][wm]overlay=${x}:${y}[vout]`,
    );
  } else if (applyWm === "text") {
    const draw = buildDrawtextFilter({
      text: settings.text!.trim(),
      fontSize,
      fontColor: "white@0.9",
      position: settings.position,
      offsetX: settings.offsetX,
      offsetY: settings.offsetY,
      fontfileEscaped: fontfile,
    });
    filterParts.push(`[${baseLabel}]${draw}[vout]`);
  } else if (applyWm === "default-png" && defaultWmInputIndex !== null) {
    filterParts.push(
      ...buildBrandWatermarkOverlayFilters({
        baseLabel,
        watermarkInputIndex: defaultWmInputIndex,
        watermarkWidthPx: watermarkWidth,
        position: settings.position,
        offsetX: settings.offsetX,
        offsetY: settings.offsetY,
      }),
    );
  } else if (applyWm === "default-text") {
    const draw = buildDrawtextFilter({
      text: getVideoAttributionText(),
      fontSize,
      fontColor: "white@0.85",
      position: settings.position,
      offsetX: settings.offsetX,
      offsetY: settings.offsetY,
      fontfileEscaped: fontfile,
    });
    filterParts.push(`[${baseLabel}]${draw}[vout]`);
  }

  args.push("-filter_complex", filterParts.join(";"), "-map", "[vout]", "-map", "1:a");
  args.push(
    "-c:v",
    "libx264",
    "-preset",
    "ultrafast",
    "-tune",
    "stillimage",
    "-r",
    "1",
  );
  pushAudioEncodeArgs(args, audioEncode);
  args.push(
    "-t",
    outputDurationSec,
    "-pix_fmt",
    "yuv420p",
    options.outputPath,
  );

  await runFfmpeg(args);
}

function runFfmpeg(args: string[]): Promise<void> {
  return new Promise((resolve, reject) => {
    const proc = spawn(getFfmpegPath(), args, { stdio: ["ignore", "pipe", "pipe"] });
    let stderr = "";
    proc.stderr.on("data", (chunk) => {
      stderr += chunk.toString();
      if (stderr.length > 64_000) stderr = stderr.slice(-32_000);
    });
    proc.on("close", (code) => {
      if (code === 0) resolve();
      else reject(new Error(`FFmpeg failed (code ${code}): ${stderr.slice(-500)}`));
    });
    proc.on("error", (err) => {
      reject(new Error(`FFmpeg not found or failed to start: ${err.message}`));
    });
  });
}

export async function cleanupFiles(paths: string[]) {
  for (const p of paths) {
    try {
      await fs.unlink(p);
    } catch {
      // ignore missing files
    }
  }
}
