import { execFile } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { promises } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';

import { VICTOR_MONO } from './font-metrics.mjs';

const execFileAsync = promisify(execFile);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// VICTOR_MONO (family name plus CELL_WIDTH/LINE_HEIGHT/FILL_ASCENT/FILL_DESCENT) lives in
// font-metrics.mjs now, not here - that file has no Node imports, so it can also be imported by
// the studio's browser-side code (website/src/components/Studio), which cannot import this
// script (it pulls in node:fs et al.). See font-metrics.mjs for the derivation comments; keep
// them there, not duplicated here.

const CONFIG = {
  THEMES_CONFIG_DIR: join(__dirname, '../themes'),
  // Read by plugins/themes at build time (usePluginData('oh-my-posh-themes')) and
  // inlined into docs/themes.mdx via <ThemeGallery/>. Not under static/: nothing
  // references these SVGs by URL any more (they're inlined, not <img>-loaded), so
  // living outside static/ keeps them from also being copied into the deployed
  // build/ output as dead weight. Regenerated on every build (see the "themes" npm
  // script) and gitignored, exactly like the PNGs it replaces.
  MANIFEST_FILE: join(__dirname, 'generated/themes.json'),
  // The prompt the homepage shows, rendered from oh-my-posh's own built-in default config
  // (src/config/default.go) rather than any bundled theme, so it is exactly what a reader gets
  // after running the install command directly above it. Written on its own rather than read out
  // of the manifest: that file is ~500KB of inlined SVG, and a plain import of it from
  // src/pages/index.js would put every one of the 124 themes into the landing page's own chunk -
  // see plugins/themes/index.js on why the gallery pays that cost only on its own page.
  HERO_FILE: join(__dirname, 'generated/hero.json'),
  CONCURRENCY: 8,
  THEME_EXTENSIONS: ['.omp.json', '.omp.toml', '.omp.yaml'],
  SEGMENT_DATA_FILE: join(__dirname, 'segment_data.json'),
  GITHUB_BASE_URL: 'https://github.com/JanDeDobbeleer/oh-my-posh/blob/main/themes',
  // "Open in Studio" (ThemeGallery/index.js) fetches a theme's config from here at click time,
  // rather than this exporter reading the file content into the manifest. That keeps the gallery
  // reading the actual current file on every click - not a snapshot frozen at whenever the site
  // was last built - and keeps generated/themes.json from growing by the size of every theme's
  // source on top of its already-large inlined SVGs.
  RAW_GITHUB_BASE_URL: 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes',
  // Infima's own light-theme background (no --ifm-background-color override lives in
  // custom.css, so this is Infima's default) - every SVG this exporter renders for the site's
  // own light/dark toggle (the hero, and each theme's own light-mode render below) needs an
  // explicit canvas background for its light half: most bundled themes (and always the hero's
  // built-in default config) set no terminal background of their own, so without this flag they
  // fall back to svg's own dark default (svg.go's defaultCanvasBackground) regardless of which
  // mode the reader's browser is in. A theme that *does* set its own terminal background (e.g.
  // tokyonight_storm) is unaffected either way - config_export_image.go's --background-color
  // always loses to a theme's own background, exactly like a real terminal would.
  LIGHT_BACKGROUND: '#ffffff',
  FONT_FAMILY: VICTOR_MONO.FONT_FAMILY,
  CELL_WIDTH: VICTOR_MONO.CELL_WIDTH,
  LINE_HEIGHT: VICTOR_MONO.LINE_HEIGHT,
  FILL_ASCENT: VICTOR_MONO.FILL_ASCENT,
  FILL_DESCENT: VICTOR_MONO.FILL_DESCENT,
};

const TRENDING_FETCH_TIMEOUT_MS = 4000;

/**
 * Small, tasteful deny-list used as a safety net behind each source's own
 * explicit-content flag. Matched word-boundary aware so substrings inside
 * unrelated words (e.g. "sex" in "Essex") don't trigger a false positive.
 */
const CONTENT_DENY_LIST = ['fuck', 'shit', 'bitch', 'nigga', 'cunt', 'motherfucker'];
const CONTENT_DENY_REGEX = new RegExp(`\\b(${CONTENT_DENY_LIST.join('|')})\\b`, 'i');

// There used to be a THEME_CONFIG_OVERRIDES map here (per-theme author/bgColor
// overrides). Both only ever fed the PNG path's --author/--background-color
// flags - image.Settings.Author's caption and image.Renderer's canvas fill -
// neither of which the svg format reads (see config_export_svg.go: it takes
// the terminal background from the theme's own config, not a CLI flag). Now
// that every theme exports as svg, the overrides had no effect left to have.

function isValidTheme(fileName) {
  return CONFIG.THEME_EXTENSIONS.some((ext) => fileName.endsWith(ext));
}

function getThemeNameFromFile(fileName) {
  const lastDotIndex = fileName.lastIndexOf('.');
  const secondLastDotIndex = fileName.lastIndexOf('.', lastDotIndex - 1);
  return fileName.slice(0, secondLastDotIndex);
}

// The format string the studio's editor/parser (ConfigEditor/serialize.js) and CONFIG_FORMATS
// (Studio/config.js) key on - derived from the same extension isValidTheme already checked,
// so a theme file's own on-disk syntax is what "Open in Studio" loads it as.
function getThemeFormat(fileName) {
  if (fileName.endsWith('.omp.json')) {
    return 'json';
  }

  if (fileName.endsWith('.omp.toml')) {
    return 'toml';
  }

  return 'yaml';
}

async function fetchJsonWithTimeout(url) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), TRENDING_FETCH_TIMEOUT_MS);

  try {
    const response = await fetch(url, { signal: controller.signal });

    if (!response.ok) {
      throw new Error(`request failed with status ${response.status}`);
    }

    return await response.json();
  } finally {
    clearTimeout(timeout);
  }
}

/**
 * Plain-text safety net behind each source's own explicit flag - intentionally
 * short rather than a comprehensive filter.
 */
function isClean(title, artist) {
  return !CONTENT_DENY_REGEX.test(`${title} ${artist}`);
}

async function trendingFromDeezer() {
  const body = await fetchJsonWithTimeout('https://api.deezer.com/chart/0/tracks?limit=25');
  const tracks = body?.data;

  if (!Array.isArray(tracks) || tracks.length === 0) {
    throw new Error('no tracks returned');
  }

  const track = tracks.find((entry) => !entry.explicit_lyrics && isClean(entry.title, entry.artist?.name));

  if (!track) {
    throw new Error('no clean track found in chart');
  }

  return { artist: track.artist.name, track: track.title };
}

async function trendingFromAppleRSS() {
  const body = await fetchJsonWithTimeout('https://rss.marketingtools.apple.com/api/v2/us/music/most-played/25/songs.json');
  const tracks = body?.feed?.results;

  if (!Array.isArray(tracks) || tracks.length === 0) {
    throw new Error('no tracks returned');
  }

  // Apple only includes contentAdvisoryRating when a track is explicit, so presence of the key
  // (not its value) is the signal to filter on. The value is the literal string "Explict" (sic),
  // Apple's own misspelling, verified against the live feed - do not "correct" it here.
  const track = tracks.find((entry) => !('contentAdvisoryRating' in entry) && isClean(entry.name, entry.artistName));

  if (!track) {
    throw new Error('no clean track found in feed');
  }

  return { artist: track.artistName, track: track.name };
}

/**
 * Fetches a trending track (Deezer, then Apple Music RSS as a fallback) and injects
 * it into the spotify and ytm segment payloads of a temporary copy of the committed
 * data file. The committed file is never modified; on any failure the committed
 * file is used as-is.
 */
async function buildDataFileWithTrending() {
  let trending;

  try {
    trending = await trendingFromDeezer();
  } catch (error) {
    console.warn(`Trending track lookup via Deezer failed: ${error.message}`);

    try {
      trending = await trendingFromAppleRSS();
    } catch (fallbackError) {
      console.warn(`Trending track lookup via Apple Music RSS failed: ${fallbackError.message}`);
    }
  }

  if (!trending) {
    return CONFIG.SEGMENT_DATA_FILE;
  }

  try {
    const raw = await promises.readFile(CONFIG.SEGMENT_DATA_FILE, 'utf8');
    const data = JSON.parse(raw);

    for (const key of ['spotify', 'ytm']) {
      const segment = data.segments?.[key];

      if (!segment) {
        continue;
      }

      segment.Artist = trending.artist;
      segment.Track = trending.track;
    }

    const tempPath = join(tmpdir(), `segment_data.${process.pid}.${Date.now()}.json`);
    await promises.writeFile(tempPath, JSON.stringify(data, null, 2));

    console.log(`Using trending track "${trending.track}" by ${trending.artist} for previews`);

    return tempPath;
  } catch (error) {
    console.warn(`Unable to build data file with trending track: ${error.message}`);
    return CONFIG.SEGMENT_DATA_FILE;
  }
}

// OMP_BIN points the export at a specific oh-my-posh binary instead of
// whatever is on PATH, so a gallery can be regenerated from a local build
// without installing it over the one the developer actually uses. CI and the
// normal build leave it unset and get PATH, as before. execFile spawns the
// binary directly with an argv array (no shell in between), so - unlike the
// exec()+string command this replaced - the path never needs quoting even
// when it contains spaces.
const OMP_BIN = process.env.OMP_BIN || 'oh-my-posh';

// configPath is null for the homepage's own render: oh-my-posh with no --config falls back to
// the config it builds in Go (src/config/default.go), which is the prompt someone sees before
// they have configured anything. There is no file to point at, and no bundled theme matches it.
//
// backgroundColor is optional (see LIGHT_BACKGROUND) - it is what makes a theme's own light-mode
// render actually light when the theme sets no terminal background of its own; a theme that does
// (e.g. tokyonight_storm) keeps its own look regardless, since --background-color always loses
// to a theme's own background (config_export_image.go).
function buildPoshArgs(configPath, outputPath, backgroundColor) {
  return [
    'config',
    'export',
    'image',
    ...(configPath ? [`--config=${configPath}`] : []),
    `--output=${outputPath}`,
    `--font-family=${CONFIG.FONT_FAMILY}`,
    `--cell-width=${CONFIG.CELL_WIDTH}`,
    `--line-height=${CONFIG.LINE_HEIGHT}`,
    `--fill-ascent=${CONFIG.FILL_ASCENT}`,
    `--fill-descent=${CONFIG.FILL_DESCENT}`,
    ...(backgroundColor ? [`--background-color=${backgroundColor}`] : []),
    // segment_data.json is hand-written on purpose (see buildDataFileWithTrending):
    // its synthetic values are what make the renders look like a plausible
    // machine. oh-my-posh warns that the file carries no recorder marker, which is
    // expected here and handled as a non-fatal stderr line below. --data-derive
    // would not silence it - that flag only forces an already-recorded file to
    // re-derive, so it is a no-op for a hand-written one.
    `--data=${CONFIG.SEGMENT_DATA_FILE}`,
    // Without this the build machine leaks into the gallery. A segment the data
    // file does not cover used to derive itself from whatever machine ran the
    // export: free-ukraine and markbull published a worktree count read from the
    // exporting checkout's own .git/worktrees, so the number changed with whoever
    // built the site. --data-only cuts the environment off, leaving a segment
    // either rendering from this file or reporting itself absent - never from the
    // machine. It does not suppress segments the file misses: path and session own
    // no entry and still render from the env section's pinned PWD and user.
    '--data-only',
  ];
}

// buildManifestEntry builds the one object plugins/themes hands to
// <ThemeGallery/> per theme: the raw svg markup (inlined verbatim via
// dangerouslySetInnerHTML, never parsed as MDX/JSX - MDX compiles a document's body as JSX, and
// the exporter's raw SVG attributes are not valid JSX, so the markup has to arrive as an opaque
// string rather than live in the .mdx source itself), the name to label it
// with, and the GitHub URL both the heading and the render link to. rawConfigUrl and format are
// what "Open in Studio" (ThemeGallery/index.js) needs to fetch and parse the theme's actual
// config at click time - see RAW_GITHUB_BASE_URL's own comment for why that's a URL, not the
// file content itself. svgLight is the theme's own light-mode render (see LIGHT_BACKGROUND) -
// <ThemeCard/> ships both and picks between them the same way the homepage hero does.
function buildManifestEntry(themeName, themeFile, svg, svgLight) {
  return {
    name: themeName,
    githubUrl: `${CONFIG.GITHUB_BASE_URL}/${themeFile}`,
    rawConfigUrl: `${CONFIG.RAW_GITHUB_BASE_URL}/${themeFile}`,
    format: getThemeFormat(themeFile),
    svg,
    svgLight,
  };
}

async function* asyncPool(concurrency, iterable, iteratorFn) {
  const executing = new Set();

  async function consume() {
    const [promise, value] = await Promise.race(executing);
    executing.delete(promise);
    return value;
  }

  for (const item of iterable) {
    const promise = (async () => await iteratorFn(item))().then(
      value => [promise, value]
    );
    executing.add(promise);

    if (executing.size >= concurrency) {
      yield await consume();
    }
  }

  while (executing.size) {
    yield await consume();
  }
}

async function exportTheme(themeFile) {
  if (!isValidTheme(themeFile)) {
    return null;
  }

  const configPath = join(CONFIG.THEMES_CONFIG_DIR, themeFile);
  const themeName = getThemeNameFromFile(themeFile);
  // A per-run scratch path, like buildDataFileWithTrending's temp data file below:
  // the svg only needs to exist long enough to be read back into the manifest, so
  // it lives in the OS temp dir rather than under website/, leaving nothing behind
  // for git status to notice.
  const svg = await renderSVG(configPath, themeFile);
  const svgLight = await renderSVG(configPath, `${themeFile} (light)`, CONFIG.LIGHT_BACKGROUND);

  console.info(`Exported ${themeFile}`);

  return { entry: buildManifestEntry(themeName, themeFile, svg, svgLight), fileName: themeFile };
}

// One render, returning the SVG. label only ever appears in messages, so the caller can name
// what it asked for - a theme file, or the built-in default config, which has no file at all.
// backgroundColor is optional (see buildPoshArgs).
async function renderSVG(configPath, label, backgroundColor) {
  // A per-run scratch path, like buildDataFileWithTrending's temp data file: the svg only needs
  // to exist long enough to be read back, so it lives in the OS temp dir rather than under
  // website/, leaving nothing behind for git status to notice.
  const outputPath = join(tmpdir(), `omp-theme-${randomUUID()}.svg`);

  let stderr;

  try {
    ({ stderr } = await execFileAsync(OMP_BIN, buildPoshArgs(configPath, outputPath, backgroundColor)));
  } catch (error) {
    // execFileAsync only rejects on a non-zero exit code - a genuine render failure, not
    // incidental stderr output. Fail the build loudly instead of silently dropping the render.
    throw new Error(`Failed to export ${label}: ${error.message}`);
  }

  if (stderr) {
    // A non-zero exit already threw above, so stderr here is incidental (e.g. the
    // hand-written-data-file warning) and must not fail the build.
    console.warn(`${label}: ${stderr.trim()}`);
  }

  try {
    return await promises.readFile(outputPath, 'utf8');
  } finally {
    await promises.unlink(outputPath).catch(() => {});
  }
}

async function ensureDirectories() {
  // recursive: true is idempotent (no error if the directory already exists), so there is nothing
  // an access()-then-mkdir() dance would catch that a bare mkdir() doesn't already handle.
  await promises.mkdir(dirname(CONFIG.MANIFEST_FILE), { recursive: true });
}

async function main() {
  try {
    console.log('Starting theme export process...');

    await ensureDirectories();

    const committedDataFile = CONFIG.SEGMENT_DATA_FILE;
    CONFIG.SEGMENT_DATA_FILE = await buildDataFileWithTrending();

    const themes = await promises.readdir(CONFIG.THEMES_CONFIG_DIR);
    const validThemes = themes.filter(isValidTheme);

    console.log(`Found ${validThemes.length} valid themes to process`);

    const resultsMap = new Map();

    for await (const result of asyncPool(CONFIG.CONCURRENCY, validThemes, exportTheme)) {
      if (result) {
        // Use the original filename as the key for efficient sorting
        resultsMap.set(result.fileName, result);
      }
    }

    // Sort by filename keys alphabetically - the same order the page has shown
    // since the PNG era, preserved here even though the sort key (the source
    // config's filename) is no longer part of the emitted manifest entry itself.
    const sortedFileNames = Array.from(resultsMap.keys()).sort();
    const manifest = sortedFileNames.map((fileName) => resultsMap.get(fileName).entry);

    await promises.writeFile(CONFIG.MANIFEST_FILE, JSON.stringify(manifest));

    console.log(`Successfully exported ${manifest.length} themes to ${CONFIG.MANIFEST_FILE}`);

    const heroSVG = await renderSVG(null, 'the default config');
    const heroSVGLight = await renderSVG(null, 'the default config (light)', CONFIG.LIGHT_BACKGROUND);

    await promises.writeFile(CONFIG.HERO_FILE, JSON.stringify({ svg: heroSVG, svgLight: heroSVGLight }));

    console.log(`Wrote the default config to ${CONFIG.HERO_FILE} for the homepage`);

    // buildDataFileWithTrending only returns a path other than the committed file when it
    // actually wrote a scratch copy with the trending track baked in (see its own comment); on any
    // failure - fetch failed, write failed - it falls back to returning the committed file itself.
    // Only clean up the scratch copy: the committed file must never be deleted, and skipping this
    // check would do exactly that on every run where trending lookup failed.
    if (CONFIG.SEGMENT_DATA_FILE !== committedDataFile) {
      await promises.unlink(CONFIG.SEGMENT_DATA_FILE).catch(() => {});
    }

  } catch (error) {
    console.error('Export process failed:', error.message);
    process.exit(1);
  }
}

// Execute main function if this file is run directly
// In ES modules, we check if import.meta.url matches the process argv
if (process.argv[1] === __filename) {
  main();
}

export {
  CONFIG,
  exportTheme,
  isValidTheme,
  getThemeNameFromFile,
  getThemeFormat,
  buildManifestEntry,
  asyncPool,
  main,
  fetchJsonWithTimeout,
  trendingFromDeezer,
  trendingFromAppleRSS,
  isClean,
  buildDataFileWithTrending,
};
