#!/usr/bin/env node

/* Copyright 2026 Mozilla Foundation
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * Checks that every message ID defined in l10n/en-US/viewer.ftl is referenced
 * in at least one HTML or JS/MJS file under the web/ directory.
 *
 * Usage:  node external/check_l10n/check_l10n.mjs
 */

import { extname, join } from "path";
import { readdirSync, readFileSync, statSync } from "fs";

const ROOT = join(import.meta.dirname, "..", "..");
const FTL_PATH = join(ROOT, "l10n", "en-US", "viewer.ftl");
const SEARCH_DIRS = ["web", "src"];
const SEARCH_EXTENSIONS = new Set([".html", ".js", ".mjs"]);
// Minimum number of characters a prefix or suffix fragment must have to be
// considered a meaningful match when detecting dynamically-built IDs.
const MIN_FRAGMENT_LENGTH = 6;

/**
 * Extract all message IDs from a Fluent (.ftl) file.
 * A message ID is an identifier at the start of a line followed by " =".
 * @param {string} ftlPath - Absolute path to the .ftl file.
 * @returns {string[]} Ordered list of message IDs.
 */
function extractFtlIds(ftlPath) {
  const lines = readFileSync(ftlPath, "utf8").split("\n");
  const ids = [];
  for (const line of lines) {
    const match = line.match(/^([a-zA-Z][a-zA-Z0-9-]*)\s*=/);
    if (match) {
      ids.push(match[1]);
    }
  }
  return ids;
}

/**
 * Recursively collect all files with matching extensions under a directory.
 * @param {string} dir - Directory to walk.
 * @param {Set<string>} extensions - Allowed file extensions (e.g. `".js"`).
 * @returns {string[]} Absolute paths of matching files.
 */
function collectFiles(dir, extensions) {
  const results = [];
  for (const entry of readdirSync(dir)) {
    const fullPath = join(dir, entry);
    const stat = statSync(fullPath);
    if (stat.isDirectory()) {
      results.push(...collectFiles(fullPath, extensions));
    } else if (extensions.has(extname(entry))) {
      results.push(fullPath);
    }
  }
  return results;
}

/**
 * Load the contents of all source files found under the given directories.
 * @param {string[]} dirs - Directory names relative to ROOT.
 * @param {Set<string>} extensions - Allowed file extensions.
 * @returns {{ path: string, content: string }[]}
 */
function loadSources(dirs, extensions) {
  const files = dirs.flatMap(d => collectFiles(join(ROOT, d), extensions));
  return files.map(f => ({ path: f, content: readFileSync(f, "utf8") }));
}

/**
 * Check whether a message ID appears as a quoted string literal in any source
 * file. Handles double quotes, single quotes, and backticks, covering:
 *   - `data-l10n-id="pdfjs-foo"` (HTML attribute)
 *   - `"pdfjs-foo"` / `'pdfjs-foo'` / `` `pdfjs-foo` `` (JS string literals,
 *     `setAttribute`, `l10n.get`, …)
 * @param {string} id - Message ID to look up.
 * @param {{ path: string, content: string }[]} sources
 * @returns {boolean}
 */
function isUsed(id, sources) {
  const dq = `"${id}"`;
  const sq = `'${id}'`;
  const bt = `\`${id}\``;
  return sources.some(
    ({ content }) =>
      content.includes(dq) || content.includes(sq) || content.includes(bt)
  );
}

/**
 * For IDs not found as complete literals, check whether the ID is likely
 * constructed dynamically via a template literal such as:
 *   `pdfjs-editor-${editorType}-added-alert`
 *
 * Strategy: try every (prefix, suffix) pair obtained by splitting the ID's
 * dash-separated components, leaving one or more components as the "variable"
 * gap. The prefix must appear immediately followed by `${` in a template
 * literal; the suffix (if non-empty) must also appear in the same file.
 * Minimum length guards prevent matches on trivially short fragments.
 *
 * @param {string} id - Message ID to test.
 * @param {{ path: string, content: string }[]} sources
 * @returns {{ path: string, line: number } | null} Location of the first
 *   matching template literal, or `null` if none found.
 */
function findDynamicLocation(id, sources) {
  const parts = id.split("-");
  // i = end of prefix (exclusive), j = start of suffix (inclusive)
  for (let i = 1; i < parts.length; i++) {
    for (let j = i + 1; j <= parts.length; j++) {
      const prefix = parts.slice(0, i).join("-") + "-"; // e.g. "pdfjs-editor-"
      const suffix = j < parts.length ? "-" + parts.slice(j).join("-") : ""; // e.g. "-added-alert"
      if (prefix.length < MIN_FRAGMENT_LENGTH) {
        continue;
      }
      if (suffix !== "" && suffix.length < MIN_FRAGMENT_LENGTH) {
        continue;
      }
      // The prefix must be immediately followed by "${" in a template literal.
      const prefixWithVar = prefix + "${";
      for (const { path, content } of sources) {
        if (
          content.includes(prefixWithVar) &&
          (suffix === "" || content.includes(suffix))
        ) {
          const idx = content.indexOf(prefixWithVar);
          const line = content.slice(0, idx).split("\n").length;
          return { path, line };
        }
      }
    }
  }
  return null;
}

function main() {
  const ids = extractFtlIds(FTL_PATH);
  console.log(`Found ${ids.length} message IDs in viewer.ftl\n`);

  const sources = loadSources(SEARCH_DIRS, SEARCH_EXTENSIONS);
  console.log(
    `Searching in ${sources.length} files under: ${SEARCH_DIRS.join(", ")}\n`
  );

  const notFound = ids.filter(id => !isUsed(id, sources));
  const dynamicEntries = notFound
    .map(id => ({ id, loc: findDynamicLocation(id, sources) }))
    .filter(({ loc }) => loc !== null);
  const dynamicIds = new Set(dynamicEntries.map(({ id }) => id));
  const unused = notFound.filter(id => !dynamicIds.has(id));

  if (dynamicEntries.length > 0) {
    console.log(
      `~ ${dynamicEntries.length} ID(s) likely built dynamically (template literals):\n`
    );
    for (const { id, loc } of dynamicEntries) {
      const rel = loc.path.replace(ROOT + "/", "").replace(ROOT + "\\", "");
      console.log(`  ${id}`);
      console.log(`    → ${rel}:${loc.line}`);
    }
    console.log();
  }

  if (unused.length === 0) {
    console.log("✓ All remaining message IDs are used.");
  } else {
    console.log(`✗ ${unused.length} unused message ID(s):\n`);
    for (const id of unused) {
      console.log(`  ${id}`);
    }
    process.exitCode = 1;
  }
}

main();
