#!/usr/bin/env bun

/**
 * SubMiner launcher (Bun runtime)
 * Local-only wrapper for mpv + SubMiner overlay orchestration.
 */

import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import net from "node:net";
import { spawn, spawnSync } from "node:child_process";
import http from "node:http";
import https from "node:https";
import { parse as parseJsonc } from "jsonc-parser";

const VIDEO_EXTENSIONS = new Set([
  "mkv",
  "mp4",
  "avi",
  "webm",
  "mov",
  "flv",
  "wmv",
  "m4v",
  "ts",
  "m2ts",
]);

const ROFI_THEME_FILE = "subminer.rasi";
const DEFAULT_SOCKET_PATH = "/tmp/subminer-socket";
const DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS = ["ja", "jpn"];
const DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS = ["en", "eng"];
const YOUTUBE_SUB_EXTENSIONS = new Set([".srt", ".vtt", ".ass"]);
const YOUTUBE_AUDIO_EXTENSIONS = new Set([
  ".m4a",
  ".mp3",
  ".webm",
  ".opus",
  ".wav",
  ".aac",
  ".flac",
]);
const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
  os.homedir(),
  ".cache",
  "subminer",
  "youtube-subs",
);
const DEFAULT_MPV_LOG_FILE = path.join(
  os.homedir(),
  ".cache",
  "SubMiner",
  "mp.log",
);
const DEFAULT_YOUTUBE_YTDL_FORMAT = "bestvideo*+bestaudio/best";
const DEFAULT_JIMAKU_API_BASE_URL = "https://jimaku.cc";
const DEFAULT_MPV_SUBMINER_ARGS = [
  "--sub-auto=fuzzy",
  "--sub-file-paths=.;subs;subtitles",
  "--sid=auto",
  "--secondary-sid=auto",
  "--secondary-sub-visibility=no",
  "--slang=ja,jpn,en,eng",
] as const;

type LogLevel = "debug" | "info" | "warn" | "error";
type YoutubeSubgenMode = "automatic" | "preprocess" | "off";

type Backend = "auto" | "hyprland" | "x11" | "macos";

type JimakuLanguagePreference = "ja" | "en" | "none";

interface JimakuEntry {
  id: number;
  name: string;
  english_name?: string | null;
  japanese_name?: string | null;
  flags?: {
    anime?: boolean;
    movie?: boolean;
    adult?: boolean;
    external?: boolean;
    unverified?: boolean;
  };
}

interface JimakuFileEntry {
  name: string;
  url: string;
  size: number;
  last_modified: string;
}

interface JimakuApiError {
  error: string;
  code?: number;
  retryAfter?: number;
}

type JimakuApiResponse<T> =
  | { ok: true; data: T }
  | { ok: false; error: JimakuApiError };

type JimakuDownloadResult =
  | { ok: true; path: string }
  | { ok: false; error: JimakuApiError };

interface JimakuConfig {
  apiKey: string;
  apiKeyCommand: string;
  apiBaseUrl: string;
  languagePreference: JimakuLanguagePreference;
  maxEntryResults: number;
}

interface JimakuMediaInfo {
  title: string;
  season: number | null;
  episode: number | null;
  confidence: "high" | "medium" | "low";
  filename: string;
  rawTitle: string;
}

function getRetryAfter(headers: http.IncomingHttpHeaders): number | undefined {
  const value = headers["x-ratelimit-reset-after"];
  if (!value) return undefined;
  const raw = Array.isArray(value) ? value[0] : value;
  const parsed = Number.parseFloat(raw);
  if (!Number.isFinite(parsed)) return undefined;
  return parsed;
}

function matchEpisodeFromName(name: string): {
  season: number | null;
  episode: number | null;
  index: number | null;
  confidence: "high" | "medium" | "low";
} {
  const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i);
  if (seasonEpisode && seasonEpisode.index !== undefined) {
    return {
      season: Number.parseInt(seasonEpisode[1], 10),
      episode: Number.parseInt(seasonEpisode[2], 10),
      index: seasonEpisode.index,
      confidence: "high",
    };
  }

  const alt = name.match(/(\d{1,2})x(\d{1,3})/i);
  if (alt && alt.index !== undefined) {
    return {
      season: Number.parseInt(alt[1], 10),
      episode: Number.parseInt(alt[2], 10),
      index: alt.index,
      confidence: "high",
    };
  }

  const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i);
  if (epOnly && epOnly.index !== undefined) {
    return {
      season: null,
      episode: Number.parseInt(epOnly[1], 10),
      index: epOnly.index,
      confidence: "medium",
    };
  }

  const numeric = name.match(/(?:^|[-–—]\s*)(\d{1,3})\s*[-–—]/);
  if (numeric && numeric.index !== undefined) {
    return {
      season: null,
      episode: Number.parseInt(numeric[1], 10),
      index: numeric.index,
      confidence: "medium",
    };
  }

  return { season: null, episode: null, index: null, confidence: "low" };
}

function detectSeasonFromDir(mediaPath: string): number | null {
  const parent = path.basename(path.dirname(mediaPath));
  const match = parent.match(/(?:Season|S)\s*(\d{1,2})/i);
  if (!match) return null;
  const parsed = Number.parseInt(match[1], 10);
  return Number.isFinite(parsed) ? parsed : null;
}

function parseGuessitOutput(
  mediaPath: string,
  stdout: string,
): JimakuMediaInfo | null {
  const payload = stdout.trim();
  if (!payload) return null;

  try {
    const parsed = JSON.parse(payload) as {
      title?: string;
      title_original?: string;
      series?: string;
      season?: number | string;
      episode?: number | string;
      episode_list?: Array<number | string>;
    };
    const season =
      typeof parsed.season === "number"
        ? parsed.season
        : typeof parsed.season === "string"
          ? Number.parseInt(parsed.season, 10)
          : null;
    const directEpisode =
      typeof parsed.episode === "number"
        ? parsed.episode
        : typeof parsed.episode === "string"
          ? Number.parseInt(parsed.episode, 10)
          : null;
    const episodeFromList =
      parsed.episode_list && parsed.episode_list.length > 0
        ? Number.parseInt(String(parsed.episode_list[0]), 10)
        : null;
    const episodeValue =
      directEpisode !== null && Number.isFinite(directEpisode)
        ? directEpisode
        : episodeFromList;
    const episode =
      Number.isFinite(episodeValue as number) ? (episodeValue as number) : null;
    const title = (
      parsed.title ||
      parsed.title_original ||
      parsed.series ||
      ""
    ).trim();
    const hasStructuredData =
      title.length > 0 || Number.isFinite(season as number) || Number.isFinite(episodeValue as number);

    if (!hasStructuredData) return null;

    return {
      title: title || "",
      season: Number.isFinite(season as number) ? season : detectSeasonFromDir(mediaPath),
      episode: episode,
      confidence: "high",
      filename: path.basename(mediaPath),
      rawTitle: path.basename(mediaPath).replace(/\.[^/.]+$/, ""),
    };
  } catch {
    return null;
  }
}

function parseMediaInfoWithGuessit(mediaPath: string): JimakuMediaInfo | null {
  if (!commandExists("guessit")) return null;

  try {
    const fileName = path.basename(mediaPath);
    const result = spawnSync("guessit", ["--json", fileName], {
      cwd: path.dirname(mediaPath),
      encoding: "utf8",
      maxBuffer: 2_000_000,
      windowsHide: true,
    });
    if (result.error || result.status !== 0) return null;
    return parseGuessitOutput(mediaPath, result.stdout || "");
  } catch {
    return null;
  }
}

function cleanupTitle(value: string): string {
  return value
    .replace(/^[\s-–—]+/, "")
    .replace(/[\s-–—]+$/, "")
    .replace(/\s+/g, " ")
    .trim();
}

function formatLangScore(name: string, pref: JimakuLanguagePreference): number {
  if (pref === "none") return 0;
  const upper = name.toUpperCase();
  const hasJa =
    /(^|[\W_])JA([\W_]|$)/.test(upper) ||
    /(^|[\W_])JPN([\W_]|$)/.test(upper) ||
    upper.includes(".JA.");
  const hasEn =
    /(^|[\W_])EN([\W_]|$)/.test(upper) ||
    /(^|[\W_])ENG([\W_]|$)/.test(upper) ||
    upper.includes(".EN.");
  if (pref === "ja") {
    if (hasJa) return 2;
    if (hasEn) return 1;
  } else if (pref === "en") {
    if (hasEn) return 2;
    if (hasJa) return 1;
  }
  return 0;
}

async function resolveJimakuApiKey(config: JimakuConfig): Promise<string | null> {
  if (config.apiKey && config.apiKey.trim()) {
    return config.apiKey.trim();
  }
  if (config.apiKeyCommand && config.apiKeyCommand.trim()) {
    try {
      const commandResult = spawnSync(config.apiKeyCommand, {
        shell: true,
        encoding: "utf8",
        timeout: 10000,
      });
      if (commandResult.error) return null;
      const key = (commandResult.stdout || "").trim();
      return key.length > 0 ? key : null;
    } catch {
      return null;
    }
  }
  return null;
}

function jimakuFetchJson<T>(
  endpoint: string,
  query: Record<string, string | number | boolean | null | undefined>,
  options: { baseUrl: string; apiKey: string },
): Promise<JimakuApiResponse<T>> {
  const url = new URL(endpoint, options.baseUrl);
  for (const [key, value] of Object.entries(query)) {
    if (value === null || value === undefined) continue;
    url.searchParams.set(key, String(value));
  }

  return new Promise((resolve) => {
    const requestUrl = new URL(url.toString());
    const transport = requestUrl.protocol === "https:" ? https : http;
    const req = transport.request(
      requestUrl,
      {
        method: "GET",
        headers: {
          Authorization: options.apiKey,
          "User-Agent": "SubMiner",
        },
      },
      (res) => {
        let data = "";
        res.on("data", (chunk) => {
          data += chunk.toString();
        });
        res.on("end", () => {
          const status = res.statusCode || 0;
          if (status >= 200 && status < 300) {
            try {
              const parsed = JSON.parse(data) as T;
              resolve({ ok: true, data: parsed });
            } catch {
              resolve({
                ok: false,
                error: { error: "Failed to parse Jimaku response JSON." },
              });
            }
            return;
          }

          let errorMessage = `Jimaku API error (HTTP ${status})`;
          try {
            const parsed = JSON.parse(data) as { error?: string };
            if (parsed && parsed.error) {
              errorMessage = parsed.error;
            }
          } catch {
            // ignore parse errors
          }

          resolve({
            ok: false,
            error: {
              error: errorMessage,
              code: status || undefined,
              retryAfter:
                status === 429 ? getRetryAfter(res.headers) : undefined,
            },
          });
        });
      },
    );

    req.on("error", (error) => {
      resolve({
        ok: false,
        error: { error: `Jimaku request failed: ${(error as Error).message}` },
      });
    });

    req.end();
  });
}

function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
  if (!mediaPath) {
    return {
      title: "",
      season: null,
      episode: null,
      confidence: "low",
      filename: "",
      rawTitle: "",
    };
  }

  const guessitInfo = parseMediaInfoWithGuessit(mediaPath);
  if (guessitInfo) return guessitInfo;

  const filename = path.basename(mediaPath);
  let name = filename.replace(/\.[^/.]+$/, "");
  name = name.replace(/\[[^\]]*]/g, " ");
  name = name.replace(/\(\d{4}\)/g, " ");
  name = name.replace(/[._]/g, " ");
  name = name.replace(/[–—]/g, "-");
  name = name.replace(/\s+/g, " ").trim();

  const parsed = matchEpisodeFromName(name);
  let titlePart = name;
  if (parsed.index !== null) {
    titlePart = name.slice(0, parsed.index);
  }

  const seasonFromDir = parsed.season ?? detectSeasonFromDir(mediaPath);
  const title = cleanupTitle(titlePart || name);

  return {
    title,
    season: seasonFromDir,
    episode: parsed.episode,
    confidence: parsed.confidence,
    filename,
    rawTitle: name,
  };
}

function sortJimakuFiles(
  files: JimakuFileEntry[],
  pref: JimakuLanguagePreference,
): JimakuFileEntry[] {
  if (pref === "none") return files;
  return [...files].sort((a, b) => {
    const scoreDiff = formatLangScore(b.name, pref) - formatLangScore(a.name, pref);
    if (scoreDiff !== 0) return scoreDiff;
    return a.name.localeCompare(b.name);
  });
}

async function downloadToFile(
  url: string,
  destPath: string,
  headers: Record<string, string>,
  redirectCount = 0,
): Promise<JimakuDownloadResult> {
  if (redirectCount > 3) {
    return {
      ok: false,
      error: { error: "Too many redirects while downloading subtitle." },
    };
  }

  return new Promise((resolve) => {
    const parsedUrl = new URL(url);
    const transport = parsedUrl.protocol === "https:" ? https : http;

    const req = transport.get(parsedUrl, { headers }, (res) => {
      const status = res.statusCode || 0;
      if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
        const redirectUrl = new URL(res.headers.location, parsedUrl).toString();
        res.resume();
        downloadToFile(redirectUrl, destPath, headers, redirectCount + 1).then(
          resolve,
        );
        return;
      }

      if (status < 200 || status >= 300) {
        res.resume();
        resolve({
          ok: false,
          error: {
            error: `Failed to download subtitle (HTTP ${status}).`,
            code: status,
          },
        });
        return;
      }

      const fileStream = fs.createWriteStream(destPath);
      res.pipe(fileStream);
      fileStream.on("finish", () => {
        fileStream.close(() => {
          resolve({ ok: true, path: destPath });
        });
      });
      fileStream.on("error", (err: Error) => {
        resolve({
          ok: false,
          error: { error: `Failed to save subtitle: ${err.message}` },
        });
      });
    });

    req.on("error", (err) => {
      resolve({
        ok: false,
        error: {
          error: `Download request failed: ${(err as Error).message}`,
        },
      });
    });
  });
}

interface Args {
  backend: Backend;
  directory: string;
  recursive: boolean;
  profile: string;
  startOverlay: boolean;
  youtubeSubgenMode: YoutubeSubgenMode;
  whisperBin: string;
  whisperModel: string;
  youtubeSubgenOutDir: string;
  youtubeSubgenAudioFormat: string;
  youtubeSubgenKeepTemp: boolean;
  youtubePrimarySubLangs: string[];
  youtubeSecondarySubLangs: string[];
  youtubeAudioLangs: string[];
  youtubeWhisperSourceLanguage: string;
  useTexthooker: boolean;
  autoStartOverlay: boolean;
  texthookerOnly: boolean;
  useRofi: boolean;
  logLevel: LogLevel;
  target: string;
  targetKind: "" | "file" | "url";
  jimakuApiKey: string;
  jimakuApiKeyCommand: string;
  jimakuApiBaseUrl: string;
  jimakuLanguagePreference: JimakuLanguagePreference;
  jimakuMaxEntryResults: number;
}

interface LauncherYoutubeSubgenConfig {
  mode?: YoutubeSubgenMode;
  whisperBin?: string;
  whisperModel?: string;
  primarySubLanguages?: string[];
  secondarySubLanguages?: string[];
  jimakuApiKey?: string;
  jimakuApiKeyCommand?: string;
  jimakuApiBaseUrl?: string;
  jimakuLanguagePreference?: JimakuLanguagePreference;
  jimakuMaxEntryResults?: number;
}

interface PluginRuntimeConfig {
  autoStartOverlay: boolean;
  socketPath: string;
}

const COLORS = {
  red: "\x1b[0;31m",
  green: "\x1b[0;32m",
  yellow: "\x1b[0;33m",
  cyan: "\x1b[0;36m",
  reset: "\x1b[0m",
};

const LOG_PRI: Record<LogLevel, number> = {
  debug: 10,
  info: 20,
  warn: 30,
  error: 40,
};

const state = {
  overlayProc: null as ReturnType<typeof spawn> | null,
  mpvProc: null as ReturnType<typeof spawn> | null,
  youtubeSubgenChildren: new Set<ReturnType<typeof spawn>>(),
  appPath: "" as string,
  overlayManagedByLauncher: false,
  stopRequested: false,
};

interface MpvTrack {
  type?: string;
  id?: number;
  lang?: string;
  title?: string;
}

function usage(scriptName: string): string {
  return `subminer - Launch MPV with SubMiner sentence mining overlay

Usage: ${scriptName} [OPTIONS] [FILE|DIRECTORY|URL]

Options:
  -b, --backend BACKEND  Display backend to use: auto, hyprland, x11, macos (default: auto)
  -d, --directory DIR    Directory to browse for videos (default: current directory)
  -r, --recursive        Search for videos recursively
  -p, --profile PROFILE  MPV profile to use (default: subminer)
      --start            Explicitly start SubMiner overlay
      --yt-subgen-mode MODE
                         YouTube subtitle generation mode: automatic, preprocess, off (default: automatic)
      --whisper-bin PATH whisper.cpp CLI binary (used for fallback transcription)
      --whisper-model PATH
                         whisper model file path (used for fallback transcription)
      --yt-subgen-out-dir DIR
                         Output directory for generated YouTube subtitles (default: ${DEFAULT_YOUTUBE_SUBGEN_OUT_DIR})
      --yt-subgen-audio-format FORMAT
                         Audio format for extraction (default: m4a)
      --yt-subgen-keep-temp
                         Keep YouTube subtitle temp directory
  -v, --verbose          Enable verbose/debug logging
      --log-level LEVEL  Set log level: debug, info, warn, error
  -R, --rofi             Use rofi file browser instead of fzf for video selection
  -S, --start-overlay    Auto-start SubMiner overlay after MPV socket is ready
  -T, --no-texthooker    Disable texthooker-ui server
      --texthooker       Launch only texthooker page (no MPV/overlay workflow)
  -h, --help             Show this help message

Environment:
  SUBMINER_APPIMAGE_PATH      Path to SubMiner AppImage/binary (optional override)
  SUBMINER_ROFI_THEME         Path to rofi theme file (optional override)
  SUBMINER_YT_SUBGEN_MODE     automatic, preprocess, off (optional default)
  SUBMINER_WHISPER_BIN        whisper.cpp binary path (optional fallback)
  SUBMINER_WHISPER_MODEL      whisper model path (optional fallback)
  SUBMINER_YT_SUBGEN_OUT_DIR  Generated subtitle output directory

Examples:
  ${scriptName}                        # Browse current directory with fzf
  ${scriptName} -R                     # Browse current directory with rofi
  ${scriptName} -d ~/Videos            # Browse ~/Videos
  ${scriptName} -r -d ~/Anime          # Recursively browse ~/Anime
  ${scriptName} video.mkv              # Play specific file
  ${scriptName} https://youtu.be/...   # Play a YouTube URL
  ${scriptName} ytsearch:query         # Play first YouTube search result
  ${scriptName} --yt-subgen-mode preprocess --whisper-bin /path/whisper-cli --whisper-model /path/model.bin https://youtu.be/...
  ${scriptName} video.mkv              # Play with subminer profile
  ${scriptName} -p gpu-hq video.mkv    # Play with gpu-hq profile
  ${scriptName} -b x11 video.mkv       # Force x11 backend
  ${scriptName} -S video.mkv           # Start overlay immediately after MPV launch
  ${scriptName} --texthooker           # Launch only texthooker page
`;
}

function shouldLog(level: LogLevel, configured: LogLevel): boolean {
  return LOG_PRI[level] >= LOG_PRI[configured];
}

function log(level: LogLevel, configured: LogLevel, message: string): void {
  if (!shouldLog(level, configured)) return;
  const color =
    level === "info"
      ? COLORS.green
      : level === "warn"
        ? COLORS.yellow
        : level === "error"
          ? COLORS.red
          : COLORS.cyan;
  process.stdout.write(
    `${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`,
  );
  appendToMpvLog(`[${level.toUpperCase()}] ${message}`);
}

function getMpvLogPath(): string {
  const envPath = process.env.SUBMINER_MPV_LOG?.trim();
  if (envPath) return envPath;
  return DEFAULT_MPV_LOG_FILE;
}

function appendToMpvLog(message: string): void {
  const logPath = getMpvLogPath();
  try {
    fs.mkdirSync(path.dirname(logPath), { recursive: true });
    fs.appendFileSync(
      logPath,
      `[${new Date().toISOString()}] ${message}\n`,
      { encoding: "utf8" },
    );
  } catch {
    // ignore logging failures
  }
}

function fail(message: string): never {
  process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`);
  appendToMpvLog(`[ERROR] ${message}`);
  process.exit(1);
}

function isExecutable(filePath: string): boolean {
  try {
    fs.accessSync(filePath, fs.constants.X_OK);
    return true;
  } catch {
    return false;
  }
}

function commandExists(command: string): boolean {
  const pathEnv = process.env.PATH ?? "";
  for (const dir of pathEnv.split(path.delimiter)) {
    if (!dir) continue;
    const full = path.join(dir, command);
    if (isExecutable(full)) return true;
  }
  return false;
}

function resolvePathMaybe(input: string): string {
  if (input.startsWith("~")) {
    return path.join(os.homedir(), input.slice(1));
  }
  return input;
}

function resolveBinaryPathCandidate(input: string): string {
  const trimmed = input.trim();
  if (!trimmed) return "";
  const unquoted = trimmed.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
  return resolvePathMaybe(unquoted);
}

function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
  const configDir = path.join(os.homedir(), ".config", "SubMiner");
  const jsoncPath = path.join(configDir, "config.jsonc");
  const jsonPath = path.join(configDir, "config.json");
  const configPath = fs.existsSync(jsoncPath)
    ? jsoncPath
    : fs.existsSync(jsonPath)
      ? jsonPath
      : "";
  if (!configPath) return {};

  try {
    const data = fs.readFileSync(configPath, "utf8");
    const parsed = configPath.endsWith(".jsonc")
      ? parseJsonc(data)
      : JSON.parse(data);
    if (!parsed || typeof parsed !== "object") return {};
    const root = parsed as {
      youtubeSubgen?: unknown;
      secondarySub?: { secondarySubLanguages?: unknown };
      jimaku?: unknown;
    };
    const youtubeSubgen = root.youtubeSubgen;
    const mode =
      youtubeSubgen && typeof youtubeSubgen === "object"
        ? (youtubeSubgen as { mode?: unknown }).mode
        : undefined;
    const whisperBin =
      youtubeSubgen && typeof youtubeSubgen === "object"
        ? (youtubeSubgen as { whisperBin?: unknown }).whisperBin
        : undefined;
    const whisperModel =
      youtubeSubgen && typeof youtubeSubgen === "object"
        ? (youtubeSubgen as { whisperModel?: unknown }).whisperModel
        : undefined;
    const primarySubLanguagesRaw =
      youtubeSubgen && typeof youtubeSubgen === "object"
        ? (youtubeSubgen as { primarySubLanguages?: unknown }).primarySubLanguages
        : undefined;
    const secondarySubLanguagesRaw = root.secondarySub?.secondarySubLanguages;
    const primarySubLanguages = Array.isArray(primarySubLanguagesRaw)
      ? primarySubLanguagesRaw.filter(
          (value): value is string => typeof value === "string",
        )
      : undefined;
    const secondarySubLanguages = Array.isArray(secondarySubLanguagesRaw)
      ? secondarySubLanguagesRaw.filter(
          (value): value is string => typeof value === "string",
        )
      : undefined;
    const jimaku = root.jimaku;
    const jimakuApiKey =
      jimaku && typeof jimaku === "object"
        ? (jimaku as { apiKey?: unknown }).apiKey
        : undefined;
    const jimakuApiKeyCommand =
      jimaku && typeof jimaku === "object"
        ? (jimaku as { apiKeyCommand?: unknown }).apiKeyCommand
        : undefined;
    const jimakuApiBaseUrl =
      jimaku && typeof jimaku === "object"
        ? (jimaku as { apiBaseUrl?: unknown }).apiBaseUrl
        : undefined;
    const jimakuLanguagePreference = jimaku && typeof jimaku === "object"
      ? (jimaku as { languagePreference?: unknown }).languagePreference
      : undefined;
    const jimakuMaxEntryResults =
      jimaku && typeof jimaku === "object"
        ? (jimaku as { maxEntryResults?: unknown }).maxEntryResults
        : undefined;
    const resolvedJimakuLanguagePreference =
      jimakuLanguagePreference === "ja" ||
      jimakuLanguagePreference === "en" ||
      jimakuLanguagePreference === "none"
        ? jimakuLanguagePreference
        : undefined;
    const resolvedJimakuMaxEntryResults =
      typeof jimakuMaxEntryResults === "number" &&
      Number.isFinite(jimakuMaxEntryResults) &&
      jimakuMaxEntryResults > 0
        ? Math.floor(jimakuMaxEntryResults)
        : undefined;

    return {
      mode:
        mode === "automatic" || mode === "preprocess" || mode === "off"
          ? mode
          : undefined,
      whisperBin: typeof whisperBin === "string" ? whisperBin : undefined,
      whisperModel: typeof whisperModel === "string" ? whisperModel : undefined,
      primarySubLanguages,
      secondarySubLanguages,
      jimakuApiKey: typeof jimakuApiKey === "string" ? jimakuApiKey : undefined,
      jimakuApiKeyCommand:
        typeof jimakuApiKeyCommand === "string"
          ? jimakuApiKeyCommand
          : undefined,
      jimakuApiBaseUrl:
        typeof jimakuApiBaseUrl === "string"
          ? jimakuApiBaseUrl
          : undefined,
      jimakuLanguagePreference: resolvedJimakuLanguagePreference,
      jimakuMaxEntryResults: resolvedJimakuMaxEntryResults,
    };
  } catch {
    return {};
  }
}

function realpathMaybe(filePath: string): string {
  try {
    return fs.realpathSync(filePath);
  } catch {
    return path.resolve(filePath);
  }
}

function isUrlTarget(target: string): boolean {
  return /^https?:\/\//.test(target) || /^ytsearch:/.test(target);
}

function isYoutubeTarget(target: string): boolean {
  return (
    /^ytsearch:/.test(target) ||
    /^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//.test(target)
  );
}

interface CommandExecOptions {
  allowFailure?: boolean;
  captureStdout?: boolean;
  logLevel?: LogLevel;
  commandLabel?: string;
  streamOutput?: boolean;
  env?: NodeJS.ProcessEnv;
}

interface CommandExecResult {
  code: number;
  stdout: string;
  stderr: string;
}

interface SubtitleCandidate {
  path: string;
  lang: "primary" | "secondary";
  ext: string;
  size: number;
  source: "manual" | "auto" | "whisper" | "whisper-translate";
}

interface YoutubeSubgenOutputs {
  basename: string;
  primaryPath?: string;
  secondaryPath?: string;
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function sanitizeToken(value: string): string {
  return String(value)
    .toLowerCase()
    .replace(/[^a-z0-9._-]+/g, "-")
    .replace(/-+/g, "-")
    .replace(/^-|-$/g, "");
}

function normalizeBasename(value: string, fallback: string): string {
  const safe = sanitizeToken(value.replace(/[\\/]+/g, "-"));
  if (safe) return safe;
  const fallbackSafe = sanitizeToken(fallback);
  if (fallbackSafe) return fallbackSafe;
  return `${Date.now()}`;
}

function normalizeLangCode(value: string): string {
  return value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "");
}

function uniqueNormalizedLangCodes(values: string[]): string[] {
  const seen = new Set<string>();
  const out: string[] = [];
  for (const value of values) {
    const normalized = normalizeLangCode(value);
    if (!normalized || seen.has(normalized)) continue;
    seen.add(normalized);
    out.push(normalized);
  }
  return out;
}

function toYtdlpLangPattern(langCodes: string[]): string {
  return langCodes.map((lang) => `${lang}.*`).join(",");
}

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function filenameHasLanguageTag(filenameLower: string, langCode: string): boolean {
  const escaped = escapeRegExp(langCode);
  const pattern = new RegExp(`(^|[._-])${escaped}([._-]|$)`);
  return pattern.test(filenameLower);
}

function classifyLanguage(
  filename: string,
  primaryLangCodes: string[],
  secondaryLangCodes: string[],
): "primary" | "secondary" | null {
  const lower = filename.toLowerCase();
  const primary = primaryLangCodes.some((code) =>
    filenameHasLanguageTag(lower, code),
  );
  const secondary = secondaryLangCodes.some((code) =>
    filenameHasLanguageTag(lower, code),
  );
  if (primary && !secondary) return "primary";
  if (secondary && !primary) return "secondary";
  return null;
}

function preferredLangLabel(langCodes: string[], fallback: string): string {
  return uniqueNormalizedLangCodes(langCodes)[0] || fallback;
}

function inferWhisperLanguage(langCodes: string[], fallback: string): string {
  for (const lang of uniqueNormalizedLangCodes(langCodes)) {
    if (lang === "jpn") return "ja";
    if (lang.length >= 2) return lang.slice(0, 2);
  }
  return fallback;
}

function sourceTag(source: SubtitleCandidate["source"]): string {
  if (source === "manual" || source === "auto") return `ytdlp-${source}`;
  if (source === "whisper-translate") return "whisper-translate";
  return "whisper";
}

function runExternalCommand(
  executable: string,
  args: string[],
  opts: CommandExecOptions = {},
): Promise<CommandExecResult> {
  const allowFailure = opts.allowFailure === true;
  const captureStdout = opts.captureStdout === true;
  const configuredLogLevel = opts.logLevel ?? "info";
  const commandLabel = opts.commandLabel || executable;
  const streamOutput = opts.streamOutput === true;

  return new Promise((resolve, reject) => {
    log("debug", configuredLogLevel, `[${commandLabel}] spawn: ${executable} ${args.join(" ")}`);
    const child = spawn(executable, args, {
      stdio: ["ignore", "pipe", "pipe"],
      env: { ...process.env, ...opts.env },
    });
    state.youtubeSubgenChildren.add(child);

    let stdout = "";
    let stderr = "";
    let stdoutBuffer = "";
    let stderrBuffer = "";
    const flushLines = (
      buffer: string,
      level: LogLevel,
      sink: (remaining: string) => void,
    ): void => {
      const lines = buffer.split(/\r?\n/);
      const remaining = lines.pop() ?? "";
      for (const line of lines) {
        const trimmed = line.trim();
        if (trimmed.length > 0) {
          log(level, configuredLogLevel, `[${commandLabel}] ${trimmed}`);
        }
      }
      sink(remaining);
    };

    child.stdout.on("data", (chunk: Buffer) => {
      const text = chunk.toString();
      if (captureStdout) stdout += text;
      if (streamOutput) {
        stdoutBuffer += text;
        flushLines(stdoutBuffer, "debug", (remaining) => {
          stdoutBuffer = remaining;
        });
      }
    });

    child.stderr.on("data", (chunk: Buffer) => {
      const text = chunk.toString();
      stderr += text;
      if (streamOutput) {
        stderrBuffer += text;
        flushLines(stderrBuffer, "debug", (remaining) => {
          stderrBuffer = remaining;
        });
      }
    });

    child.on("error", (error) => {
      state.youtubeSubgenChildren.delete(child);
      reject(new Error(`Failed to start "${executable}": ${error.message}`));
    });

    child.on("close", (code) => {
      state.youtubeSubgenChildren.delete(child);
      if (streamOutput) {
        const trailingOut = stdoutBuffer.trim();
        if (trailingOut.length > 0) {
          log("debug", configuredLogLevel, `[${commandLabel}] ${trailingOut}`);
        }
        const trailingErr = stderrBuffer.trim();
        if (trailingErr.length > 0) {
          log("debug", configuredLogLevel, `[${commandLabel}] ${trailingErr}`);
        }
      }
      log(
        code === 0 ? "debug" : "warn",
        configuredLogLevel,
        `[${commandLabel}] exit code ${code ?? 1}`,
      );
      if (code !== 0 && !allowFailure) {
        const commandString = `${executable} ${args.join(" ")}`;
        reject(
          new Error(
            `Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`,
          ),
        );
        return;
      }
      resolve({ code: code ?? 1, stdout, stderr });
    });
  });
}

function pickBestCandidate(candidates: SubtitleCandidate[]): SubtitleCandidate | null {
  if (candidates.length === 0) return null;
  const scored = [...candidates].sort((a, b) => {
    const sourceA = a.source === "manual" ? 1 : 0;
    const sourceB = b.source === "manual" ? 1 : 0;
    if (sourceA !== sourceB) return sourceB - sourceA;
    const srtA = a.ext === ".srt" ? 1 : 0;
    const srtB = b.ext === ".srt" ? 1 : 0;
    if (srtA !== srtB) return srtB - srtA;
    return b.size - a.size;
  });
  return scored[0];
}

function scanSubtitleCandidates(
  tempDir: string,
  knownSet: Set<string>,
  source: "manual" | "auto",
  primaryLangCodes: string[],
  secondaryLangCodes: string[],
): SubtitleCandidate[] {
  const entries = fs.readdirSync(tempDir);
  const out: SubtitleCandidate[] = [];
  for (const name of entries) {
    const fullPath = path.join(tempDir, name);
    if (knownSet.has(fullPath)) continue;
    let stat: fs.Stats;
    try {
      stat = fs.statSync(fullPath);
    } catch {
      continue;
    }
    if (!stat.isFile()) continue;
    const ext = path.extname(fullPath).toLowerCase();
    if (!YOUTUBE_SUB_EXTENSIONS.has(ext)) continue;
    const lang = classifyLanguage(name, primaryLangCodes, secondaryLangCodes);
    if (!lang) continue;
    out.push({ path: fullPath, lang, ext, size: stat.size, source });
  }
  return out;
}

async function convertToSrt(
  inputPath: string,
  tempDir: string,
  langLabel: string,
): Promise<string> {
  if (path.extname(inputPath).toLowerCase() === ".srt") return inputPath;
  const outputPath = path.join(tempDir, `converted.${langLabel}.srt`);
  await runExternalCommand("ffmpeg", ["-y", "-loglevel", "error", "-i", inputPath, outputPath]);
  return outputPath;
}

function findAudioFile(tempDir: string, preferredExt: string): string | null {
  const entries = fs.readdirSync(tempDir);
  const audioFiles: Array<{ path: string; ext: string; mtimeMs: number }> = [];
  for (const name of entries) {
    const fullPath = path.join(tempDir, name);
    let stat: fs.Stats;
    try {
      stat = fs.statSync(fullPath);
    } catch {
      continue;
    }
    if (!stat.isFile()) continue;
    const ext = path.extname(name).toLowerCase();
    if (!YOUTUBE_AUDIO_EXTENSIONS.has(ext)) continue;
    audioFiles.push({ path: fullPath, ext, mtimeMs: stat.mtimeMs });
  }
  if (audioFiles.length === 0) return null;
  const preferred = audioFiles.find((entry) => entry.ext === `.${preferredExt.toLowerCase()}`);
  if (preferred) return preferred.path;
  audioFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
  return audioFiles[0].path;
}

async function runWhisper(
  whisperBin: string,
  modelPath: string,
  audioPath: string,
  language: string,
  translate: boolean,
  outputPrefix: string,
): Promise<string> {
  const args = [
    "-m",
    modelPath,
    "-f",
    audioPath,
    "--output-srt",
    "--output-file",
    outputPrefix,
    "--language",
    language,
  ];
  if (translate) args.push("--translate");
  await runExternalCommand(whisperBin, args, {
    commandLabel: "whisper",
    streamOutput: true,
  });
  const outputPath = `${outputPrefix}.srt`;
  if (!fs.existsSync(outputPath)) {
    throw new Error(`whisper output not found: ${outputPath}`);
  }
  return outputPath;
}

async function convertAudioForWhisper(inputPath: string, tempDir: string): Promise<string> {
  const wavPath = path.join(tempDir, "whisper-input.wav");
  await runExternalCommand("ffmpeg", [
    "-y",
    "-loglevel",
    "error",
    "-i",
    inputPath,
    "-ar",
    "16000",
    "-ac",
    "1",
    "-c:a",
    "pcm_s16le",
    wavPath,
  ]);
  if (!fs.existsSync(wavPath)) {
    throw new Error(`Failed to prepare whisper audio input: ${wavPath}`);
  }
  return wavPath;
}

function sendMpvCommand(socketPath: string, command: unknown[]): Promise<void> {
  return new Promise((resolve, reject) => {
    const socket = net.createConnection(socketPath);
    socket.once("connect", () => {
      socket.write(`${JSON.stringify({ command })}\n`);
      socket.end();
      resolve();
    });
    socket.once("error", (error) => {
      reject(error);
    });
  });
}

async function loadSubtitleIntoMpv(
  socketPath: string,
  subtitlePath: string,
  select: boolean,
  logLevel: LogLevel,
): Promise<void> {
  for (let attempt = 1; ; attempt += 1) {
    const mpvExited =
      state.mpvProc !== null &&
      state.mpvProc.exitCode !== null &&
      state.mpvProc.exitCode !== undefined;
    if (mpvExited) {
      throw new Error(`mpv exited before subtitle could be loaded: ${subtitlePath}`);
    }

    if (!fs.existsSync(socketPath)) {
      if (attempt % 20 === 0) {
        log(
          "debug",
          logLevel,
          `Waiting for mpv socket before loading subtitle (${attempt} attempts): ${path.basename(subtitlePath)}`,
        );
      }
      await sleep(250);
      continue;
    }
    try {
      await sendMpvCommand(
        socketPath,
        select ? ["sub-add", subtitlePath, "select"] : ["sub-add", subtitlePath],
      );
      log(
        "info",
        logLevel,
        `Loaded generated subtitle into mpv: ${path.basename(subtitlePath)}`,
      );
      return;
    } catch {
      if (attempt % 20 === 0) {
        log(
          "debug",
          logLevel,
          `Retrying subtitle load into mpv (${attempt} attempts): ${path.basename(subtitlePath)}`,
        );
      }
      await sleep(250);
    }
  }
}

interface MpvResponseEnvelope {
  request_id?: number;
  error?: string;
  data?: unknown;
}

function sendMpvCommandWithResponse(
  socketPath: string,
  command: unknown[],
  timeoutMs = 5000,
): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const requestId = Date.now() + Math.floor(Math.random() * 1000);
    const socket = net.createConnection(socketPath);
    let buffer = "";

    const cleanup = (): void => {
      try {
        socket.destroy();
      } catch {
        // ignore
      }
    };

    const timer = setTimeout(() => {
      cleanup();
      reject(new Error(`MPV command timed out after ${timeoutMs}ms`));
    }, timeoutMs);

    const finish = (value: unknown): void => {
      clearTimeout(timer);
      cleanup();
      resolve(value);
    };

    socket.once("connect", () => {
      const message = JSON.stringify({ command, request_id: requestId });
      socket.write(`${message}\n`);
    });

    socket.on("data", (chunk: Buffer) => {
      buffer += chunk.toString();
      const lines = buffer.split(/\r?\n/);
      buffer = lines.pop() ?? "";
      for (const line of lines) {
        if (!line.trim()) continue;
        let parsed: MpvResponseEnvelope;
        try {
          parsed = JSON.parse(line);
        } catch {
          continue;
        }
        if (parsed.request_id !== requestId) continue;
        if (parsed.error && parsed.error !== "success") {
          reject(new Error(`MPV error: ${parsed.error}`));
          cleanup();
          clearTimeout(timer);
          return;
        }
        finish(parsed.data);
        return;
      }
    });

    socket.once("error", (error) => {
      clearTimeout(timer);
      cleanup();
      reject(error);
    });
  });
}

async function getMpvTracks(socketPath: string): Promise<MpvTrack[]> {
  const response = await sendMpvCommandWithResponse(
    socketPath,
    ["get_property", "track-list"],
    8000,
  );
  if (!Array.isArray(response)) return [];

  return response
    .filter((track): track is MpvTrack => {
      if (!track || typeof track !== "object") return false;
      const candidate = track as Record<string, unknown>;
      return candidate.type === "sub";
    })
    .map((track) => {
      const candidate = track as Record<string, unknown>;
      return {
        type:
          typeof candidate.type === "string" ? candidate.type : undefined,
        id:
          typeof candidate.id === "number"
            ? candidate.id
            : typeof candidate.id === "string"
              ? Number.parseInt(candidate.id, 10)
              : undefined,
        lang:
          typeof candidate.lang === "string" ? candidate.lang : undefined,
        title:
          typeof candidate.title === "string" ? candidate.title : undefined,
      };
    });
}

function isPreferredStreamLang(candidate: string, preferred: string[]): boolean {
  const normalized = normalizeLangCode(candidate);
  if (!normalized) return false;
  if (preferred.includes(normalized)) return true;
  if (normalized === "ja" && preferred.includes("jpn")) return true;
  if (normalized === "jpn" && preferred.includes("ja")) return true;
  if (normalized === "en" && preferred.includes("eng")) return true;
  if (normalized === "eng" && preferred.includes("en")) return true;
  return false;
}

function findPreferredSubtitleTrack(
  tracks: MpvTrack[],
  preferredLanguages: string[],
): MpvTrack | null {
  const normalizedPreferred = uniqueNormalizedLangCodes(preferredLanguages);
  const subtitleTracks = tracks.filter((track) => track.type === "sub");
  if (normalizedPreferred.length === 0) return subtitleTracks[0] ?? null;

  for (const lang of normalizedPreferred) {
    const matched = subtitleTracks.find(
      (track) => track.lang && isPreferredStreamLang(track.lang, [lang]),
    );
    if (matched) return matched;
  }

  return null;
}

async function waitForSubtitleTrackList(
  socketPath: string,
  logLevel: LogLevel,
): Promise<MpvTrack[]> {
  const maxAttempts = 40;
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const tracks = await getMpvTracks(socketPath).catch(() => [] as MpvTrack[]);
    if (tracks.length > 0) return tracks;
    if (attempt % 10 === 0) {
      log(
        "debug",
        logLevel,
        `Waiting for mpv tracks (${attempt}/${maxAttempts})`,
      );
    }
    await sleep(250);
  }
  return [];
}

function isValidSubtitleCandidateFile(filename: string): boolean {
  const ext = path.extname(filename).toLowerCase();
  return (
    ext === ".srt" ||
    ext === ".vtt" ||
    ext === ".ass" ||
    ext === ".ssa" ||
    ext === ".sub"
  );
}

function mapPreferenceToLanguages(preference: JimakuLanguagePreference): string[] {
  if (preference === "en") return ["en", "eng"];
  if (preference === "none") return [];
  return ["ja", "jpn"];
}

function makeTempDir(prefix: string): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}

function detectBackend(backend: Backend): Exclude<Backend, "auto"> {
  if (backend !== "auto") return backend;
  if (process.platform === "darwin") return "macos";
  const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase();
  const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || "").toLowerCase();
  const xdgSessionType = (process.env.XDG_SESSION_TYPE || "").toLowerCase();
  const hasWayland = Boolean(process.env.WAYLAND_DISPLAY) || xdgSessionType === "wayland";

  if (
    process.env.HYPRLAND_INSTANCE_SIGNATURE ||
    xdgCurrentDesktop.includes("hyprland") ||
    xdgSessionDesktop.includes("hyprland")
  ) {
    return "hyprland";
  }
  if (hasWayland && commandExists("hyprctl")) return "hyprland";
  if (process.env.DISPLAY) return "x11";
  fail("Could not detect display backend");
}

function findRofiTheme(scriptPath: string): string | null {
  const envTheme = process.env.SUBMINER_ROFI_THEME;
  if (envTheme && fs.existsSync(envTheme)) return envTheme;

  const scriptDir = path.dirname(realpathMaybe(scriptPath));
  const candidates: string[] = [];

  if (process.platform === "darwin") {
    candidates.push(
      path.join(
        os.homedir(),
        "Library/Application Support/SubMiner/themes",
        ROFI_THEME_FILE,
      ),
    );
  } else {
    const xdgDataHome =
      process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local/share");
    candidates.push(path.join(xdgDataHome, "SubMiner/themes", ROFI_THEME_FILE));
    candidates.push(
      path.join("/usr/local/share/SubMiner/themes", ROFI_THEME_FILE),
    );
    candidates.push(path.join("/usr/share/SubMiner/themes", ROFI_THEME_FILE));
  }

  candidates.push(path.join(scriptDir, ROFI_THEME_FILE));

  for (const candidate of candidates) {
    if (fs.existsSync(candidate)) return candidate;
  }

  return null;
}

function collectVideos(dir: string, recursive: boolean): string[] {
  const root = path.resolve(dir);
  const out: string[] = [];

  const walk = (current: string): void => {
    let entries: fs.Dirent[];
    try {
      entries = fs.readdirSync(current, { withFileTypes: true });
    } catch {
      return;
    }

    for (const entry of entries) {
      const full = path.join(current, entry.name);
      if (entry.isDirectory()) {
        if (recursive) walk(full);
        continue;
      }
      if (!entry.isFile()) continue;
      const ext = path.extname(entry.name).slice(1).toLowerCase();
      if (VIDEO_EXTENSIONS.has(ext)) out.push(full);
    }
  };

  walk(root);
  return out.sort((a, b) =>
    a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
  );
}

function buildRofiMenu(
  videos: string[],
  dir: string,
  recursive: boolean,
): Buffer {
  const chunks: Buffer[] = [];
  for (const video of videos) {
    const display = recursive
      ? path.relative(dir, video)
      : path.basename(video);
    const line = `${display}\0icon\x1fthumbnail://${video}\n`;
    chunks.push(Buffer.from(line, "utf8"));
  }
  return Buffer.concat(chunks);
}

function showRofiMenu(
  videos: string[],
  dir: string,
  recursive: boolean,
  scriptPath: string,
  logLevel: LogLevel,
): string {
  const args = [
    "-dmenu",
    "-i",
    "-p",
    "Select Video ",
    "-show-icons",
    "-theme-str",
    'configuration { font: "Noto Sans CJK JP Regular 8";}',
  ];

  const theme = findRofiTheme(scriptPath);
  if (theme) {
    args.push("-theme", theme);
  } else {
    log(
      "warn",
      logLevel,
      "Rofi theme not found; using rofi defaults (set SUBMINER_ROFI_THEME to override)",
    );
  }

  const result = spawnSync("rofi", args, {
    input: buildRofiMenu(videos, dir, recursive),
    encoding: "utf8",
    stdio: ["pipe", "pipe", "ignore"],
  });
  if (result.error) {
    fail(
      formatPickerLaunchError("rofi", result.error as NodeJS.ErrnoException),
    );
  }

  const selection = (result.stdout || "").trim();
  if (!selection) return "";
  return path.join(dir, selection);
}

function buildFzfMenu(videos: string[]): string {
  return videos.map((video) => `${path.basename(video)}\t${video}`).join("\n");
}

function showFzfMenu(videos: string[]): string {
  const chafaFormat = process.env.TMUX
    ? "--format=symbols --symbols=vhalf+wide --color-space=din99d"
    : "--format=kitty";

  const previewCmd = commandExists("chafa")
    ? `
video={2}
thumb_dir="$HOME/.cache/thumbnails/large"
video_uri="file://$(realpath "$video")"
if command -v md5sum >/dev/null 2>&1; then
  thumb_hash=$(echo -n "$video_uri" | md5sum | cut -d' ' -f1)
else
  thumb_hash=$(echo -n "$video_uri" | md5 -q)
fi
thumb_path="$thumb_dir/$thumb_hash.png"

get_thumb() {
  if [[ -f "$thumb_path" ]]; then
    echo "$thumb_path"
  elif command -v ffmpegthumbnailer >/dev/null 2>&1; then
    tmp="/tmp/subminer-preview.jpg"
    ffmpegthumbnailer -i "$video" -o "$tmp" -s 512 -q 5 2>/dev/null && echo "$tmp"
  elif command -v ffmpeg >/dev/null 2>&1; then
    tmp="/tmp/subminer-preview.jpg"
    ffmpeg -y -i "$video" -ss 00:00:05 -vframes 1 -vf "scale=512:-1" "$tmp" 2>/dev/null && echo "$tmp"
  fi
}

thumb=$(get_thumb)
[[ -n "$thumb" ]] && chafa ${chafaFormat} --size=${"${FZF_PREVIEW_COLUMNS}"}x${"${FZF_PREVIEW_LINES}"} "$thumb" 2>/dev/null
`.trim()
    : 'echo "Install chafa for thumbnail preview"';

  const result = spawnSync(
    "fzf",
    [
      "--ansi",
      "--reverse",
      "--prompt=Select Video: ",
      "--delimiter=\t",
      "--with-nth=1",
      "--preview-window=right:50%:wrap",
      "--preview",
      previewCmd,
    ],
    {
      input: buildFzfMenu(videos),
      encoding: "utf8",
      stdio: ["pipe", "pipe", "inherit"],
    },
  );
  if (result.error) {
    fail(formatPickerLaunchError("fzf", result.error as NodeJS.ErrnoException));
  }

  const selection = (result.stdout || "").trim();
  if (!selection) return "";
  const tabIndex = selection.indexOf("\t");
  if (tabIndex === -1) return "";
  return selection.slice(tabIndex + 1);
}

function formatPickerLaunchError(
  picker: "rofi" | "fzf",
  error: NodeJS.ErrnoException,
): string {
  if (error.code === "ENOENT") {
    return picker === "rofi"
      ? "rofi not found. Install rofi or use --no-rofi to use fzf."
      : "fzf not found. Install fzf or use --rofi to use rofi.";
  }
  return `Failed to launch ${picker}: ${error.message}`;
}

function findAppBinary(selfPath: string): string | null {
  const envPaths = [
    process.env.SUBMINER_APPIMAGE_PATH,
    process.env.SUBMINER_BINARY_PATH,
  ].filter((candidate): candidate is string => Boolean(candidate));

  for (const envPath of envPaths) {
    const resolved = resolveBinaryPathCandidate(envPath);
    if (resolved && isExecutable(resolved)) {
      return resolved;
    }
  }

  const candidates: string[] = [];
  if (process.platform === "darwin") {
    candidates.push("/Applications/SubMiner.app/Contents/MacOS/SubMiner");
    candidates.push("/Applications/SubMiner.app/Contents/MacOS/subminer");
    candidates.push(
      path.join(
        os.homedir(),
        "Applications/SubMiner.app/Contents/MacOS/SubMiner",
      ),
    );
    candidates.push(
      path.join(
        os.homedir(),
        "Applications/SubMiner.app/Contents/MacOS/subminer",
      ),
    );
  }

  candidates.push(path.join(os.homedir(), ".local/bin/SubMiner.AppImage"));
  candidates.push("/opt/SubMiner/SubMiner.AppImage");

  for (const candidate of candidates) {
    if (isExecutable(candidate)) return candidate;
  }

  const fromPath = process.env.PATH?.split(path.delimiter)
    .map((dir) => path.join(dir, "subminer"))
    .find((candidate) => isExecutable(candidate));

  if (fromPath) {
    const resolvedSelf = realpathMaybe(selfPath);
    const resolvedCandidate = realpathMaybe(fromPath);
    if (resolvedSelf !== resolvedCandidate) return fromPath;
  }

  return null;
}

function normalizeJimakuSearchInput(mediaPath: string): string {
  const trimmed = (mediaPath || "").trim();
  if (!trimmed) return "";
  if (!/^https?:\/\/.*/.test(trimmed)) return trimmed;

  try {
    const url = new URL(trimmed);
    const titleParam =
      url.searchParams.get("title") || url.searchParams.get("name") ||
      url.searchParams.get("q");
    if (titleParam && titleParam.trim()) return titleParam.trim();

    const pathParts = url.pathname.split("/").filter(Boolean).reverse();
    const candidate = pathParts.find((part) => {
      const decoded = decodeURIComponent(part || "").replace(/\.[^/.]+$/, "");
      const lowered = decoded.toLowerCase();
      return (
        lowered.length > 2 &&
        !/^[0-9.]+$/.test(lowered) &&
        !/^[a-f0-9]{16,}$/i.test(lowered)
      );
    });

    const fallback = candidate || url.hostname.replace(/^www\./, "");
    return sanitizeJimakuQueryInput(decodeURIComponent(fallback));
  } catch {
    return trimmed;
  }
}

function sanitizeJimakuQueryInput(value: string): string {
  return value
    .replace(/^\s*-\s*/, "")
    .replace(/[^\w\s\-'".:(),]/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

function buildJimakuConfig(args: Args): {
  apiKey: string;
  apiKeyCommand: string;
  apiBaseUrl: string;
  languagePreference: JimakuLanguagePreference;
  maxEntryResults: number;
} {
  return {
    apiKey: args.jimakuApiKey,
    apiKeyCommand: args.jimakuApiKeyCommand,
    apiBaseUrl: args.jimakuApiBaseUrl || DEFAULT_JIMAKU_API_BASE_URL,
    languagePreference: args.jimakuLanguagePreference,
    maxEntryResults: args.jimakuMaxEntryResults || 10,
  };
}

function checkDependencies(args: Args): void {
  const missing: string[] = [];

  if (!commandExists("mpv")) missing.push("mpv");

  if (
    args.targetKind === "url" &&
    isYoutubeTarget(args.target) &&
    !commandExists("yt-dlp")
  ) {
    missing.push("yt-dlp");
  }

  if (
    args.targetKind === "url" &&
    isYoutubeTarget(args.target) &&
    args.youtubeSubgenMode !== "off" &&
    !commandExists("ffmpeg")
  ) {
    missing.push("ffmpeg");
  }

  if (missing.length > 0) fail(`Missing dependencies: ${missing.join(" ")}`);
}

function resolveWhisperBinary(args: Args): string | null {
  const explicit = args.whisperBin.trim();
  if (explicit) return resolvePathMaybe(explicit);
  if (commandExists("whisper-cli")) return "whisper-cli";
  return null;
}

async function generateYoutubeSubtitles(
  target: string,
  args: Args,
  onReady?: (lang: "primary" | "secondary", pathToLoad: string) => Promise<void>,
): Promise<YoutubeSubgenOutputs> {
  const outDir = path.resolve(resolvePathMaybe(args.youtubeSubgenOutDir));
  fs.mkdirSync(outDir, { recursive: true });

  const primaryLangCodes = uniqueNormalizedLangCodes(args.youtubePrimarySubLangs);
  const secondaryLangCodes = uniqueNormalizedLangCodes(args.youtubeSecondarySubLangs);
  const primaryLabel = preferredLangLabel(primaryLangCodes, "primary");
  const secondaryLabel = preferredLangLabel(secondaryLangCodes, "secondary");
  const secondaryCanUseWhisperTranslate =
    secondaryLangCodes.includes("en") || secondaryLangCodes.includes("eng");
  const ytdlpManualLangs = toYtdlpLangPattern([
    ...primaryLangCodes,
    ...secondaryLangCodes,
  ]);

  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-yt-subgen-"));
  const knownFiles = new Set<string>();
  let keepTemp = args.youtubeSubgenKeepTemp;

  const publishTrack = async (
    lang: "primary" | "secondary",
    source: SubtitleCandidate["source"],
    selectedPath: string,
    basename: string,
  ): Promise<string> => {
    const langLabel = lang === "primary" ? primaryLabel : secondaryLabel;
    const taggedPath = path.join(
      outDir,
      `${basename}.${langLabel}.${sourceTag(source)}.srt`,
    );
    const aliasPath = path.join(outDir, `${basename}.${langLabel}.srt`);
    fs.copyFileSync(selectedPath, taggedPath);
    fs.copyFileSync(taggedPath, aliasPath);
    log(
      "info",
      args.logLevel,
      `Generated subtitle (${langLabel}, ${source}) -> ${aliasPath}`,
    );
    if (onReady) await onReady(lang, aliasPath);
    return aliasPath;
  };

  try {
    log("debug", args.logLevel, `YouTube subtitle temp dir: ${tempDir}`);
    const meta = await runExternalCommand(
      "yt-dlp",
      ["--dump-single-json", "--no-warnings", target],
      {
        captureStdout: true,
        logLevel: args.logLevel,
        commandLabel: "yt-dlp:meta",
      },
    );
    const metadata = JSON.parse(meta.stdout) as { id?: string };
    const videoId = metadata.id || `${Date.now()}`;
    const basename = normalizeBasename(videoId, videoId);

    await runExternalCommand(
      "yt-dlp",
      [
        "--skip-download",
        "--no-warnings",
        "--write-subs",
        "--sub-format",
        "srt/vtt/best",
        "--sub-langs",
        ytdlpManualLangs,
        "-o",
        path.join(tempDir, "%(id)s.%(ext)s"),
        target,
      ],
      {
        allowFailure: true,
        logLevel: args.logLevel,
        commandLabel: "yt-dlp:manual-subs",
        streamOutput: true,
      },
    );

    const manualSubs = scanSubtitleCandidates(
      tempDir,
      knownFiles,
      "manual",
      primaryLangCodes,
      secondaryLangCodes,
    );
    for (const sub of manualSubs) knownFiles.add(sub.path);
    let primaryCandidates = manualSubs.filter((entry) => entry.lang === "primary");
    let secondaryCandidates = manualSubs.filter(
      (entry) => entry.lang === "secondary",
    );

    const missingAuto: string[] = [];
    if (primaryCandidates.length === 0)
      missingAuto.push(toYtdlpLangPattern(primaryLangCodes));
    if (secondaryCandidates.length === 0)
      missingAuto.push(toYtdlpLangPattern(secondaryLangCodes));

    if (missingAuto.length > 0) {
      await runExternalCommand(
        "yt-dlp",
        [
          "--skip-download",
          "--no-warnings",
          "--write-auto-subs",
          "--sub-format",
          "srt/vtt/best",
          "--sub-langs",
          missingAuto.join(","),
          "-o",
          path.join(tempDir, "%(id)s.%(ext)s"),
          target,
        ],
        {
          allowFailure: true,
          logLevel: args.logLevel,
          commandLabel: "yt-dlp:auto-subs",
          streamOutput: true,
        },
      );

      const autoSubs = scanSubtitleCandidates(
        tempDir,
        knownFiles,
        "auto",
        primaryLangCodes,
        secondaryLangCodes,
      );
      for (const sub of autoSubs) knownFiles.add(sub.path);
      primaryCandidates = primaryCandidates.concat(
        autoSubs.filter((entry) => entry.lang === "primary"),
      );
      secondaryCandidates = secondaryCandidates.concat(
        autoSubs.filter((entry) => entry.lang === "secondary"),
      );
    }

    let primaryAlias = "";
    let secondaryAlias = "";
    const selectedPrimary = pickBestCandidate(primaryCandidates);
    const selectedSecondary = pickBestCandidate(secondaryCandidates);

    if (selectedPrimary) {
      const srt = await convertToSrt(selectedPrimary.path, tempDir, primaryLabel);
      primaryAlias = await publishTrack(
        "primary",
        selectedPrimary.source,
        srt,
        basename,
      );
    }
    if (selectedSecondary) {
      const srt = await convertToSrt(
        selectedSecondary.path,
        tempDir,
        secondaryLabel,
      );
      secondaryAlias = await publishTrack(
        "secondary",
        selectedSecondary.source,
        srt,
        basename,
      );
    }

    const needsPrimaryWhisper = !selectedPrimary;
    const needsSecondaryWhisper = !selectedSecondary && secondaryCanUseWhisperTranslate;
    if (needsPrimaryWhisper || needsSecondaryWhisper) {
      const whisperBin = resolveWhisperBinary(args);
      const modelPath = args.whisperModel.trim()
        ? path.resolve(resolvePathMaybe(args.whisperModel.trim()))
        : "";
      const hasWhisperFallback = !!whisperBin && !!modelPath && fs.existsSync(modelPath);

      if (!hasWhisperFallback) {
        log(
          "warn",
          args.logLevel,
          "Whisper fallback is not configured; continuing with available subtitle tracks.",
        );
      } else {
        try {
          await runExternalCommand(
            "yt-dlp",
            [
              "-f",
              "bestaudio/best",
              "--extract-audio",
              "--audio-format",
              args.youtubeSubgenAudioFormat,
              "--no-warnings",
              "-o",
              path.join(tempDir, "%(id)s.%(ext)s"),
              target,
            ],
            {
              logLevel: args.logLevel,
              commandLabel: "yt-dlp:audio",
              streamOutput: true,
            },
          );
          const audioPath = findAudioFile(tempDir, args.youtubeSubgenAudioFormat);
          if (!audioPath) {
            throw new Error("Audio extraction succeeded, but no audio file was found.");
          }
          const whisperAudioPath = await convertAudioForWhisper(audioPath, tempDir);

          if (needsPrimaryWhisper) {
            try {
              const primaryPrefix = path.join(tempDir, `${basename}.${primaryLabel}`);
              const primarySrt = await runWhisper(
                whisperBin!,
                modelPath,
                whisperAudioPath,
                args.youtubeWhisperSourceLanguage,
                false,
                primaryPrefix,
              );
              primaryAlias = await publishTrack(
                "primary",
                "whisper",
                primarySrt,
                basename,
              );
            } catch (error) {
              log(
                "warn",
                args.logLevel,
                `Failed to generate primary subtitle via whisper fallback: ${(error as Error).message}`,
              );
            }
          }

          if (needsSecondaryWhisper) {
            try {
              const secondaryPrefix = path.join(
                tempDir,
                `${basename}.${secondaryLabel}`,
              );
              const secondarySrt = await runWhisper(
                whisperBin!,
                modelPath,
                whisperAudioPath,
                args.youtubeWhisperSourceLanguage,
                true,
                secondaryPrefix,
              );
              secondaryAlias = await publishTrack(
                "secondary",
                "whisper-translate",
                secondarySrt,
                basename,
              );
            } catch (error) {
              log(
                "warn",
                args.logLevel,
                `Failed to generate secondary subtitle via whisper fallback: ${(error as Error).message}`,
              );
            }
          }
        } catch (error) {
          log(
            "warn",
            args.logLevel,
            `Whisper fallback pipeline failed: ${(error as Error).message}`,
          );
        }
      }
    }

    if (!secondaryCanUseWhisperTranslate && !selectedSecondary) {
      log(
        "warn",
        args.logLevel,
        `Secondary subtitle language (${secondaryLabel}) has no whisper translate fallback; relying on yt-dlp subtitles only.`,
      );
    }

    if (!primaryAlias && !secondaryAlias) {
      throw new Error("Failed to generate any subtitle tracks.");
    }
    if (!primaryAlias || !secondaryAlias) {
      log(
        "warn",
        args.logLevel,
        `Generated partial subtitle result: primary=${primaryAlias ? "ok" : "missing"}, secondary=${secondaryAlias ? "ok" : "missing"}`,
      );
    }

    return {
      basename,
      primaryPath: primaryAlias || undefined,
      secondaryPath: secondaryAlias || undefined,
    };
  } catch (error) {
    keepTemp = true;
    throw error;
  } finally {
    if (keepTemp) {
      log("warn", args.logLevel, `Keeping subtitle temp dir: ${tempDir}`);
    } else {
      try {
        fs.rmSync(tempDir, { recursive: true, force: true });
      } catch {
        // ignore cleanup failures
      }
    }
  }
}

function checkPickerDependencies(args: Args): void {
  if (args.useRofi) {
    if (!commandExists("rofi")) fail("Missing dependency: rofi");
    return;
  }

  if (!commandExists("fzf")) fail("Missing dependency: fzf");
}

function parseArgs(
  argv: string[],
  scriptName: string,
  launcherConfig: LauncherYoutubeSubgenConfig,
): Args {
  const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || "").toLowerCase();
  const defaultMode: YoutubeSubgenMode =
    envMode === "preprocess" || envMode === "off" || envMode === "automatic"
      ? (envMode as YoutubeSubgenMode)
      : launcherConfig.mode
        ? launcherConfig.mode
        : "automatic";
  const configuredSecondaryLangs = uniqueNormalizedLangCodes(
    launcherConfig.secondarySubLanguages ?? [],
  );
  const configuredPrimaryLangs = uniqueNormalizedLangCodes(
    launcherConfig.primarySubLanguages ?? [],
  );
  const primarySubLangs =
    configuredPrimaryLangs.length > 0
      ? configuredPrimaryLangs
      : [...DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS];
  const secondarySubLangs =
    configuredSecondaryLangs.length > 0
      ? configuredSecondaryLangs
      : [...DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS];
  const youtubeAudioLangs = uniqueNormalizedLangCodes([
    ...primarySubLangs,
    ...secondarySubLangs,
  ]);
  const parsed: Args = {
    backend: "auto",
    directory: ".",
    recursive: false,
    profile: "subminer",
    startOverlay: false,
    youtubeSubgenMode: defaultMode,
    whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || "",
    whisperModel:
      process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || "",
    youtubeSubgenOutDir:
      process.env.SUBMINER_YT_SUBGEN_OUT_DIR || DEFAULT_YOUTUBE_SUBGEN_OUT_DIR,
    youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || "m4a",
    youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === "1",
    jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || "",
    jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || "",
    jimakuApiBaseUrl:
      process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
    jimakuLanguagePreference:
      launcherConfig.jimakuLanguagePreference || "ja",
    jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10,
    youtubePrimarySubLangs: primarySubLangs,
    youtubeSecondarySubLangs: secondarySubLangs,
    youtubeAudioLangs,
    youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, "ja"),
    useTexthooker: true,
    autoStartOverlay: false,
    texthookerOnly: false,
    useRofi: false,
    logLevel: "info",
    target: "",
    targetKind: "",
  };

  if (launcherConfig.jimakuApiKey) parsed.jimakuApiKey = launcherConfig.jimakuApiKey;
  if (launcherConfig.jimakuApiKeyCommand)
    parsed.jimakuApiKeyCommand = launcherConfig.jimakuApiKeyCommand;
  if (launcherConfig.jimakuApiBaseUrl)
    parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl;
  if (launcherConfig.jimakuLanguagePreference)
    parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference;
  if (launcherConfig.jimakuMaxEntryResults !== undefined)
    parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults;

  const isValidLogLevel = (value: string): value is LogLevel =>
    value === "debug" ||
    value === "info" ||
    value === "warn" ||
    value === "error";
  const isValidYoutubeSubgenMode = (value: string): value is YoutubeSubgenMode =>
    value === "automatic" || value === "preprocess" || value === "off";

  let i = 0;
  while (i < argv.length) {
    const arg = argv[i];

    if (arg === "-b" || arg === "--backend") {
      const value = argv[i + 1];
      if (!value) fail("--backend requires a value");
      if (!["auto", "hyprland", "x11", "macos"].includes(value)) {
        fail(
          `Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`,
        );
      }
      parsed.backend = value as Backend;
      i += 2;
      continue;
    }

    if (arg === "-d" || arg === "--directory") {
      const value = argv[i + 1];
      if (!value) fail("--directory requires a value");
      parsed.directory = value;
      i += 2;
      continue;
    }

    if (arg === "-r" || arg === "--recursive") {
      parsed.recursive = true;
      i += 1;
      continue;
    }

    if (arg === "-p" || arg === "--profile") {
      const value = argv[i + 1];
      if (!value) fail("--profile requires a value");
      parsed.profile = value;
      i += 2;
      continue;
    }

    if (arg === "--start") {
      parsed.startOverlay = true;
      i += 1;
      continue;
    }

    if (arg === "--yt-subgen-mode" || arg === "--youtube-subgen-mode") {
      const value = (argv[i + 1] || "").toLowerCase();
      if (!isValidYoutubeSubgenMode(value)) {
        fail(
          `Invalid yt-subgen mode: ${value || ""} (must be automatic, preprocess, or off)`,
        );
      }
      parsed.youtubeSubgenMode = value;
      i += 2;
      continue;
    }

    if (
      arg.startsWith("--yt-subgen-mode=") ||
      arg.startsWith("--youtube-subgen-mode=")
    ) {
      const value = arg.split("=", 2)[1]?.toLowerCase() || "";
      if (!isValidYoutubeSubgenMode(value)) {
        fail(
          `Invalid yt-subgen mode: ${value || ""} (must be automatic, preprocess, or off)`,
        );
      }
      parsed.youtubeSubgenMode = value;
      i += 1;
      continue;
    }

    if (arg === "--whisper-bin") {
      const value = argv[i + 1];
      if (!value) fail("--whisper-bin requires a value");
      parsed.whisperBin = value;
      i += 2;
      continue;
    }

    if (arg.startsWith("--whisper-bin=")) {
      const value = arg.slice("--whisper-bin=".length);
      if (!value) fail("--whisper-bin requires a value");
      parsed.whisperBin = value;
      i += 1;
      continue;
    }

    if (arg === "--whisper-model") {
      const value = argv[i + 1];
      if (!value) fail("--whisper-model requires a value");
      parsed.whisperModel = value;
      i += 2;
      continue;
    }

    if (arg.startsWith("--whisper-model=")) {
      const value = arg.slice("--whisper-model=".length);
      if (!value) fail("--whisper-model requires a value");
      parsed.whisperModel = value;
      i += 1;
      continue;
    }

    if (arg === "--yt-subgen-out-dir") {
      const value = argv[i + 1];
      if (!value) fail("--yt-subgen-out-dir requires a value");
      parsed.youtubeSubgenOutDir = value;
      i += 2;
      continue;
    }

    if (arg.startsWith("--yt-subgen-out-dir=")) {
      const value = arg.slice("--yt-subgen-out-dir=".length);
      if (!value) fail("--yt-subgen-out-dir requires a value");
      parsed.youtubeSubgenOutDir = value;
      i += 1;
      continue;
    }

    if (arg === "--yt-subgen-audio-format") {
      const value = argv[i + 1];
      if (!value) fail("--yt-subgen-audio-format requires a value");
      parsed.youtubeSubgenAudioFormat = value;
      i += 2;
      continue;
    }

    if (arg.startsWith("--yt-subgen-audio-format=")) {
      const value = arg.slice("--yt-subgen-audio-format=".length);
      if (!value) fail("--yt-subgen-audio-format requires a value");
      parsed.youtubeSubgenAudioFormat = value;
      i += 1;
      continue;
    }

    if (arg === "--yt-subgen-keep-temp") {
      parsed.youtubeSubgenKeepTemp = true;
      i += 1;
      continue;
    }

    if (arg === "-v" || arg === "--verbose") {
      parsed.logLevel = "debug";
      i += 1;
      continue;
    }

    if (arg === "--log-level") {
      const value = argv[i + 1];
      if (!value || !isValidLogLevel(value)) {
        fail(
          `Invalid log level: ${value ?? ""} (must be debug, info, warn, or error)`,
        );
      }
      parsed.logLevel = value;
      i += 2;
      continue;
    }

    if (arg.startsWith("--log-level=")) {
      const value = arg.slice("--log-level=".length);
      if (!isValidLogLevel(value)) {
        fail(
          `Invalid log level: ${value} (must be debug, info, warn, or error)`,
        );
      }
      parsed.logLevel = value;
      i += 1;
      continue;
    }

    if (arg === "-R" || arg === "--rofi") {
      parsed.useRofi = true;
      i += 1;
      continue;
    }

    if (arg === "-S" || arg === "--start-overlay") {
      parsed.autoStartOverlay = true;
      i += 1;
      continue;
    }

    if (arg === "-T" || arg === "--no-texthooker") {
      parsed.useTexthooker = false;
      i += 1;
      continue;
    }

    if (arg === "--texthooker") {
      parsed.texthookerOnly = true;
      i += 1;
      continue;
    }

    if (arg === "-h" || arg === "--help") {
      process.stdout.write(usage(scriptName));
      process.exit(0);
    }

    if (arg === "--") {
      i += 1;
      break;
    }

    if (arg.startsWith("-")) {
      fail(`Unknown option: ${arg}`);
    }

    break;
  }

  const positional = argv.slice(i);
  if (positional.length > 0) {
    const target = positional[0];
    if (isUrlTarget(target)) {
      parsed.target = target;
      parsed.targetKind = "url";
    } else {
      const resolved = resolvePathMaybe(target);
      if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
        parsed.target = resolved;
        parsed.targetKind = "file";
      } else if (
        fs.existsSync(resolved) &&
        fs.statSync(resolved).isDirectory()
      ) {
        parsed.directory = resolved;
      } else {
        fail(`Not a file, directory, or supported URL: ${target}`);
      }
    }
  }

  return parsed;
}

function startOverlay(
  appPath: string,
  args: Args,
  socketPath: string,
): Promise<void> {
  const backend = detectBackend(args.backend);
  log(
    "info",
    args.logLevel,
    `Starting SubMiner overlay (backend: ${backend})...`,
  );

  const overlayArgs = ["--start", "--backend", backend, "--socket", socketPath];
  if (args.logLevel === "debug") overlayArgs.push("--verbose");
  else if (args.logLevel !== "info")
    overlayArgs.push("--log-level", args.logLevel);
  if (args.useTexthooker) overlayArgs.push("--texthooker");

  state.overlayProc = spawn(appPath, overlayArgs, {
    stdio: "inherit",
    env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
  });
  state.overlayManagedByLauncher = true;

  return new Promise((resolve) => {
    setTimeout(resolve, 2000);
  });
}

function launchTexthookerOnly(appPath: string, args: Args): never {
  const overlayArgs = ["--texthooker"];
  if (args.logLevel === "debug") overlayArgs.push("--verbose");
  else if (args.logLevel !== "info")
    overlayArgs.push("--log-level", args.logLevel);

  log("info", args.logLevel, "Launching texthooker mode...");
  const result = spawnSync(appPath, overlayArgs, { stdio: "inherit" });
  process.exit(result.status ?? 0);
}

function stopOverlay(args: Args): void {
  if (state.stopRequested) return;
  state.stopRequested = true;

  if (state.overlayManagedByLauncher && state.appPath) {
    log("info", args.logLevel, "Stopping SubMiner overlay...");

    const stopArgs = ["--stop"];
    if (args.logLevel === "debug") stopArgs.push("--verbose");
    else if (args.logLevel !== "info")
      stopArgs.push("--log-level", args.logLevel);

    spawnSync(state.appPath, stopArgs, { stdio: "ignore" });

    if (state.overlayProc && !state.overlayProc.killed) {
      try {
        state.overlayProc.kill("SIGTERM");
      } catch {
        // ignore
      }
    }
  }

  if (state.mpvProc && !state.mpvProc.killed) {
    try {
      state.mpvProc.kill("SIGTERM");
    } catch {
      // ignore
    }
  }

  for (const child of state.youtubeSubgenChildren) {
    if (!child.killed) {
      try {
        child.kill("SIGTERM");
      } catch {
        // ignore
      }
    }
  }
  state.youtubeSubgenChildren.clear();

}

function parseBoolLike(value: string): boolean | null {
  const normalized = value.trim().toLowerCase();
  if (
    normalized === "yes" ||
    normalized === "true" ||
    normalized === "1" ||
    normalized === "on"
  ) {
    return true;
  }
  if (
    normalized === "no" ||
    normalized === "false" ||
    normalized === "0" ||
    normalized === "off"
  ) {
    return false;
  }
  return null;
}

function getPluginConfigCandidates(): string[] {
  const xdgConfigHome =
    process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
  return Array.from(
    new Set([
      path.join(xdgConfigHome, "mpv", "script-opts", "subminer.conf"),
      path.join(os.homedir(), ".config", "mpv", "script-opts", "subminer.conf"),
    ]),
  );
}

function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
  const runtimeConfig: PluginRuntimeConfig = {
    autoStartOverlay: false,
    socketPath: DEFAULT_SOCKET_PATH,
  };
  const candidates = getPluginConfigCandidates();

  for (const configPath of candidates) {
    if (!fs.existsSync(configPath)) continue;
    try {
      const content = fs.readFileSync(configPath, "utf8");
      const lines = content.split(/\r?\n/);
      for (const line of lines) {
        const trimmed = line.trim();
        if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
        const autoStartMatch = trimmed.match(/^auto_start\s*=\s*(.+)$/i);
        if (autoStartMatch) {
          const value = (autoStartMatch[1] || "").split("#", 1)[0]?.trim() || "";
          const parsed = parseBoolLike(value);
          if (parsed !== null) {
            runtimeConfig.autoStartOverlay = parsed;
          }
          continue;
        }

        const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
        if (socketMatch) {
          const value = (socketMatch[1] || "").split("#", 1)[0]?.trim() || "";
          if (value) runtimeConfig.socketPath = value;
        }
      }
      log(
        "debug",
        logLevel,
        `Using mpv plugin settings from ${configPath}: auto_start=${runtimeConfig.autoStartOverlay ? "yes" : "no"} socket_path=${runtimeConfig.socketPath}`,
      );
      return runtimeConfig;
    } catch {
      log(
        "warn",
        logLevel,
        `Failed to read ${configPath}; using launcher defaults`,
      );
      return runtimeConfig;
    }
  }

  log(
    "debug",
    logLevel,
    `No mpv subminer.conf found; using launcher defaults (auto_start=no socket_path=${runtimeConfig.socketPath})`,
  );
  return runtimeConfig;
}

function waitForSocket(
  socketPath: string,
  timeoutMs = 10000,
): Promise<boolean> {
  const start = Date.now();
  return new Promise((resolve) => {
    const timer = setInterval(() => {
      if (fs.existsSync(socketPath)) {
        clearInterval(timer);
        resolve(true);
        return;
      }
      if (Date.now() - start >= timeoutMs) {
        clearInterval(timer);
        resolve(false);
      }
    }, 100);
  });
}

function startMpv(
  target: string,
  targetKind: "file" | "url",
  args: Args,
  socketPath: string,
  preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
): void {
  if (
    targetKind === "file" &&
    (!fs.existsSync(target) || !fs.statSync(target).isFile())
  ) {
    fail(`Video file not found: ${target}`);
  }

  if (targetKind === "url") {
    log("info", args.logLevel, `Playing URL: ${target}`);
  } else {
    log("info", args.logLevel, `Playing: ${path.basename(target)}`);
  }

  const mpvArgs: string[] = [];
  if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
  mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);

  if (targetKind === "url" && isYoutubeTarget(target)) {
    log("info", args.logLevel, "Applying URL playback options");
    mpvArgs.push("--ytdl=yes", "--ytdl-raw-options=");

    if (isYoutubeTarget(target)) {
      const subtitleLangs = uniqueNormalizedLangCodes([
        ...args.youtubePrimarySubLangs,
        ...args.youtubeSecondarySubLangs,
      ]).join(",");
      const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(",");
      log("info", args.logLevel, "Applying YouTube playback options");
      log("debug", args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`);
      log("debug", args.logLevel, `YouTube audio langs: ${audioLangs}`);
      mpvArgs.push(
        `--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`,
        `--alang=${audioLangs}`,
      );

      if (args.youtubeSubgenMode === "off") {
        mpvArgs.push(
          "--sub-auto=fuzzy",
          `--slang=${subtitleLangs}`,
          "--ytdl-raw-options-append=write-auto-subs=",
          "--ytdl-raw-options-append=write-subs=",
          "--ytdl-raw-options-append=sub-format=vtt/best",
          `--ytdl-raw-options-append=sub-langs=${subtitleLangs}`,
        );
      }
    }
  }

  if (preloadedSubtitles?.primaryPath) {
    mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`);
  }
  if (preloadedSubtitles?.secondaryPath) {
    mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
  }
  mpvArgs.push(`--log-file=${getMpvLogPath()}`);

  try {
    fs.rmSync(socketPath, { force: true });
  } catch {
    // ignore
  }

  mpvArgs.push(`--input-ipc-server=${socketPath}`);
  mpvArgs.push(target);

  state.mpvProc = spawn("mpv", mpvArgs, { stdio: "inherit" });
}

async function chooseTarget(
  args: Args,
  scriptPath: string,
): Promise<{ target: string; kind: "file" | "url" } | null> {
  if (args.target) {
    return { target: args.target, kind: args.targetKind as "file" | "url" };
  }

  const searchDir = realpathMaybe(resolvePathMaybe(args.directory));
  if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) {
    fail(`Directory not found: ${searchDir}`);
  }

  const videos = collectVideos(searchDir, args.recursive);
  if (videos.length === 0) {
    fail(`No video files found in: ${searchDir}`);
  }

  log(
    "info",
    args.logLevel,
    `Browsing: ${searchDir} (${videos.length} videos found)`,
  );

  const selected = args.useRofi
    ? showRofiMenu(videos, searchDir, args.recursive, scriptPath, args.logLevel)
    : showFzfMenu(videos);

  if (!selected) return null;
  return { target: selected, kind: "file" };
}

function registerCleanup(args: Args): void {
  process.on("SIGINT", () => {
    stopOverlay(args);
    process.exit(130);
  });
  process.on("SIGTERM", () => {
    stopOverlay(args);
    process.exit(143);
  });
}

async function main(): Promise<void> {
  const scriptPath = process.argv[1] || "subminer";
  const scriptName = path.basename(scriptPath);
  const launcherConfig = loadLauncherYoutubeSubgenConfig();
  const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig);
  const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
  const mpvSocketPath = pluginRuntimeConfig.socketPath;

  log("debug", args.logLevel, `Wrapper log level set to: ${args.logLevel}`);

  const appPath = findAppBinary(process.argv[1] || "subminer");
  if (!appPath) {
    if (process.platform === "darwin") {
      fail(
        "SubMiner app binary not found. Install SubMiner.app to /Applications or ~/Applications, or set SUBMINER_APPIMAGE_PATH.",
      );
    }
    fail(
      "SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.",
    );
  }
  state.appPath = appPath;

  if (args.texthookerOnly) {
    launchTexthookerOnly(appPath, args);
  }

  if (!args.target) {
    checkPickerDependencies(args);
  }

  const targetChoice = await chooseTarget(args, process.argv[1] || "subminer");
  if (!targetChoice) {
    log("info", args.logLevel, "No video selected, exiting");
    process.exit(0);
  }

  checkDependencies({
    ...args,
    target: targetChoice ? targetChoice.target : args.target,
    targetKind: targetChoice ? targetChoice.kind : "url",
  });

  registerCleanup(args);

  let selectedTarget = targetChoice
    ? {
        target: targetChoice.target,
        kind: targetChoice.kind as "file" | "url",
      }
    : { target: args.target, kind: "url" as const };

  const isYoutubeUrl =
    selectedTarget.kind === "url" && isYoutubeTarget(selectedTarget.target);
  let preloadedSubtitles:
    | { primaryPath?: string; secondaryPath?: string }
    | undefined;

  if (isYoutubeUrl && args.youtubeSubgenMode === "preprocess") {
    log("info", args.logLevel, "YouTube subtitle mode: preprocess");
    const generated = await generateYoutubeSubtitles(
      selectedTarget.target,
      args,
    );
    preloadedSubtitles = {
      primaryPath: generated.primaryPath,
      secondaryPath: generated.secondaryPath,
    };
    log(
      "info",
      args.logLevel,
      `YouTube preprocess result: primary=${generated.primaryPath ? "ready" : "missing"}, secondary=${generated.secondaryPath ? "ready" : "missing"}`,
    );
  } else if (isYoutubeUrl && args.youtubeSubgenMode === "automatic") {
    log("info", args.logLevel, "YouTube subtitle mode: automatic (background)");
  } else if (isYoutubeUrl) {
    log("info", args.logLevel, "YouTube subtitle mode: off");
  }

  startMpv(
    selectedTarget.target,
    selectedTarget.kind,
    args,
    mpvSocketPath,
    preloadedSubtitles,
  );

  if (isYoutubeUrl && args.youtubeSubgenMode === "automatic") {
    void generateYoutubeSubtitles(
      selectedTarget.target,
      args,
      async (lang, subtitlePath) => {
      try {
        await loadSubtitleIntoMpv(
          mpvSocketPath,
          subtitlePath,
          lang === "primary",
          args.logLevel,
        );
      } catch (error) {
        log(
          "warn",
          args.logLevel,
          `Generated subtitle ready but failed to load in mpv: ${(error as Error).message}`,
        );
      }
    }).catch((error) => {
      log(
        "warn",
        args.logLevel,
        `Background subtitle generation failed: ${(error as Error).message}`,
      );
    });
  }

  const ready = await waitForSocket(mpvSocketPath);
  const shouldStartOverlay =
    args.startOverlay || args.autoStartOverlay || pluginRuntimeConfig.autoStartOverlay;
  if (shouldStartOverlay) {
    if (ready) {
      log(
        "info",
        args.logLevel,
        "MPV IPC socket ready, starting SubMiner overlay",
      );
    } else {
      log(
        "info",
        args.logLevel,
        "MPV IPC socket not ready after timeout, starting SubMiner overlay anyway",
      );
    }
    await startOverlay(appPath, args, mpvSocketPath);
  } else if (ready) {
    log(
      "info",
      args.logLevel,
      "MPV IPC socket ready, overlay auto-start disabled (use y-s to start)",
    );
  } else {
    log(
      "info",
      args.logLevel,
      "MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)",
    );
  }

  await new Promise<void>((resolve) => {
    if (!state.mpvProc) {
      stopOverlay(args);
      resolve();
      return;
    }
    state.mpvProc.on("exit", (code) => {
      stopOverlay(args);
      process.exitCode = code ?? 0;
      resolve();
    });
  });
}

main().catch((error: unknown) => {
  const message = error instanceof Error ? error.message : String(error);
  fail(message);
});

// vim: ft=typescript
