import { readdir, readFile } from "node:fs/promises";
import path from "node:path";

const root = process.cwd();
const stylesDir = path.join(root, "src", "styles");
if (process.argv.length > 2) {
  console.error("Usage: node scripts/check-css-usage.mjs");
  process.exit(1);
}

const cssFiles = await orderedCssFiles();
const sourceFiles = await filesInTree(path.join(root, "src"), new Set([".ts", ".tsx"]), {
  excludedPrefixes: [path.join(root, "src", "generated")],
});
const testFiles = await filesInTree(path.join(root, "tests"), new Set([".ts", ".tsx"]));

const cssClasses = await collectCssClasses(cssFiles);
const cssCustomProperties = await collectCssCustomProperties(cssFiles);
const referencedCssCustomProperties = await collectReferencedCssCustomProperties(cssFiles);
const sourceTexts = await readTexts(sourceFiles);
const testTexts = await readTexts(testFiles);
const sourceText = sourceTexts.map((item) => item.text).join("\n");
const dynamicPrefixes = collectDynamicClassPrefixes(sourceText);

const testOnlyCandidates = [];
const candidates = [];
const unusedCustomProperties = [];

for (const [className, locations] of cssClasses) {
  const sourceMatches = locationsInTexts(sourceTexts, className);
  if (sourceMatches.length > 0) continue;

  const testMatches = locationsInTexts(testTexts, className);
  if (testMatches.length > 0) {
    testOnlyCandidates.push({ className, locations, testMatches });
    continue;
  }

  candidates.push({ className, locations });
}

for (const [propertyName, locations] of cssCustomProperties) {
  if (referencedCssCustomProperties.has(propertyName) || locationsInTexts(sourceTexts, propertyName).length > 0) continue;
  unusedCustomProperties.push({ propertyName, locations });
}

if (candidates.length + testOnlyCandidates.length + unusedCustomProperties.length + dynamicPrefixes.length > 0) {
  printCandidates({ testOnlyCandidates, candidates, unusedCustomProperties, dynamicPrefixes });
  process.exit(1);
}

async function orderedCssFiles() {
  const orderPath = path.join(stylesDir, "order.json");
  const order = JSON.parse(await readFile(orderPath, "utf8"));
  if (!Array.isArray(order) || !order.every((item) => typeof item === "string")) {
    throw new Error(`${relative(orderPath)} must be a JSON array of CSS file names.`);
  }
  return order.map((file) => path.join(stylesDir, file));
}

async function collectCssClasses(files) {
  const result = new Map();
  for (const file of files) {
    const text = stripCssComments(await readFile(file, "utf8"));
    const lines = text.split("\n");
    for (const [index, line] of lines.entries()) {
      const classPattern = /(^|[^\\])\.([_a-zA-Z][\w-]*)/g;
      for (const match of line.matchAll(classPattern)) {
        const className = match[2];
        if (!className.startsWith("codex-panel")) continue;
        const locations = result.get(className) ?? [];
        locations.push(`${relative(file)}:${index + 1}`);
        result.set(className, locations);
      }
    }
  }
  return new Map([...result.entries()].sort(([left], [right]) => left.localeCompare(right)));
}

async function collectCssCustomProperties(files) {
  const result = new Map();
  for (const file of files) {
    const text = stripCssComments(await readFile(file, "utf8"));
    const lines = text.split("\n");
    for (const [index, line] of lines.entries()) {
      for (const match of line.matchAll(/(--codex-panel-[\w-]+)\s*:/g)) {
        const propertyName = match[1];
        const locations = result.get(propertyName) ?? [];
        locations.push(`${relative(file)}:${index + 1}`);
        result.set(propertyName, locations);
      }
    }
  }
  return new Map([...result.entries()].sort(([left], [right]) => left.localeCompare(right)));
}

async function collectReferencedCssCustomProperties(files) {
  const result = new Set();
  for (const file of files) {
    const text = stripCssComments(await readFile(file, "utf8"));
    for (const match of text.matchAll(/var\(\s*(--codex-panel-[\w-]+)/g)) {
      result.add(match[1]);
    }
  }
  return result;
}

function stripCssComments(text) {
  return text.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ""));
}

function collectDynamicClassPrefixes(text) {
  const prefixes = new Set();
  const templatePrefixPattern = /codex-panel(?:-[a-z]+)?__[a-z0-9-]+--\$\{/g;
  for (const match of text.matchAll(templatePrefixPattern)) {
    prefixes.add(match[0].slice(0, -"${".length));
  }
  return [...prefixes].sort((left, right) => left.localeCompare(right));
}

async function readTexts(files) {
  const result = [];
  for (const file of files) {
    result.push({ file, text: await readFile(file, "utf8") });
  }
  return result;
}

function locationsInTexts(texts, needle) {
  const locations = [];
  const escapedNeedle = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const pattern = new RegExp(`(^|[^\\w-])${escapedNeedle}(?![\\w-])`);
  for (const { file, text } of texts) {
    const match = pattern.exec(text);
    if (!match) continue;
    const offset = match.index + match[1].length;
    const line = text.slice(0, offset).split("\n").length;
    locations.push(`${relative(file)}:${line}`);
  }
  return locations;
}

async function filesInTree(directory, extensions, options = {}) {
  const excludedPrefixes = options.excludedPrefixes ?? [];
  const result = [];
  await collectFiles(directory, extensions, excludedPrefixes, result);
  return result.sort((left, right) => relative(left).localeCompare(relative(right)));
}

async function collectFiles(directory, extensions, excludedPrefixes, result) {
  if (excludedPrefixes.some((prefix) => directory === prefix || directory.startsWith(`${prefix}${path.sep}`))) return;

  const entries = await readdir(directory, { withFileTypes: true });
  for (const entry of entries) {
    const file = path.join(directory, entry.name);
    if (entry.isDirectory()) {
      await collectFiles(file, extensions, excludedPrefixes, result);
    } else if (entry.isFile() && extensions.has(path.extname(entry.name))) {
      result.push(file);
    }
  }
}

function printCandidates({ testOnlyCandidates, candidates, unusedCustomProperties, dynamicPrefixes }) {
  console.error("CSS usage check failed.");
  if (dynamicPrefixes.length > 0) {
    console.error("");
    console.error("Dynamic CSS class prefixes are not allowed:");
    for (const prefix of dynamicPrefixes) {
      console.error(`  ${prefix}`);
    }
  }

  if (testOnlyCandidates.length > 0) {
    console.error("");
    console.error("Test-only unused CSS class candidates:");
    for (const item of testOnlyCandidates) {
      console.error(`  ${item.className}`);
      console.error(`    css: ${item.locations.join(", ")}`);
      console.error(`    tests: ${item.testMatches.join(", ")}`);
    }
  }

  if (candidates.length > 0) {
    console.error("");
    console.error("Unused CSS class candidates:");
    for (const item of candidates) {
      console.error(`  ${item.className}`);
      console.error(`    css: ${item.locations.join(", ")}`);
    }
  }

  if (unusedCustomProperties.length > 0) {
    console.error("");
    console.error("Unused CSS custom property candidates:");
    for (const item of unusedCustomProperties) {
      console.error(`  ${item.propertyName}`);
      console.error(`    css: ${item.locations.join(", ")}`);
    }
  }
}

function relative(file) {
  return path.relative(root, file);
}
