import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

/**
 * The one list of owned, publishable @openmaic packages.
 *
 * Several checks and the release workflow each used to carry their own copy of
 * this list, which meant a package missing from one of them was silently exempt
 * from that check rather than failing anything. The scripts now share this
 * module; the workflow cannot import JavaScript into its `on:` trigger, so
 * {@link assertPackageListIsComplete} verifies from the outside that the
 * workflow's own list still agrees with this one.
 *
 * Ordered by dependency: dsl first, followed by its dependents.
 *
 * ── THREAT MODEL FOR EVERY GATE THAT READS THIS ──────────────────────────────
 *
 * This list, and the checks built on it, are configuration held in the same
 * repository as the code they check. Anyone who can merge a change to
 * `scripts/` can also edit the gate itself: shorten this list, relax a rule,
 * delete a workflow step. No amount of additional checking closes that, because
 * every new check arrives with its own editable configuration.
 *
 * That is fine, because it is not the threat these gates are for. They exist to
 * catch MISTAKES — a package added in one place and forgotten in another, a
 * dependency range that publishes differently from how it reads, a test suite
 * that stops running without anyone noticing. Those are silent by default and
 * expensive to find later, which is exactly what a gate is good at. Deliberate
 * subversion is not in scope and cannot be: someone willing to edit these
 * scripts to hide a change can edit the production sources directly, and code
 * review, not tooling, is what stands in the way of that.
 */
export const OPENMAIC_PACKAGES = ['dsl', 'generation', 'storage', 'renderer', 'editor', 'importer'];

/** The packages that depend on other owned packages, and on which ones. */
export const INTERNAL_DEPENDENTS = {
  generation: ['@openmaic/dsl'],
  storage: ['@openmaic/dsl'],
  renderer: ['@openmaic/dsl'],
  editor: ['@openmaic/dsl', '@openmaic/renderer'],
  importer: ['@openmaic/dsl'],
};

export const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');

export const PACKAGES_DIRECTORY = join(repositoryRoot, 'packages/@openmaic');

export function packageDirectory(name) {
  return `packages/@openmaic/${name}`;
}

export function readManifest(name) {
  return JSON.parse(readFileSync(join(PACKAGES_DIRECTORY, name, 'package.json'), 'utf8'));
}

const PUBLISH_WORKFLOW = '.github/workflows/publish-packages.yml';

/**
 * Fail if this list has drifted from what actually exists on disk, or from the
 * release workflow's own hardcoded copies.
 *
 * Being wrong in either direction is silent by nature: a package present on
 * disk but missing here is exempt from every check that reads this module, and
 * a package here but missing from the workflow is never published. Both read as
 * a pass unless something asserts otherwise, which is what this does.
 *
 * Returns the problems rather than exiting, so a caller can report them
 * alongside its own.
 */
export function assertPackageListIsComplete() {
  const problems = [];

  const onDisk = readdirSync(PACKAGES_DIRECTORY, { withFileTypes: true })
    .filter((entry) => entry.isDirectory())
    .map((entry) => entry.name)
    .sort();
  const listed = [...OPENMAIC_PACKAGES].sort();

  for (const name of onDisk) {
    if (!listed.includes(name)) {
      problems.push(
        `packages/@openmaic/${name} exists but is not in OPENMAIC_PACKAGES, so every check ` +
          'that reads this list skips it. Add it there and to publish-packages.yml.',
      );
    }
  }
  for (const name of listed) {
    if (!onDisk.includes(name)) {
      problems.push(`OPENMAIC_PACKAGES lists ${name}, but packages/@openmaic/${name} is gone.`);
    }
  }

  let workflow;
  try {
    workflow = readFileSync(join(repositoryRoot, PUBLISH_WORKFLOW), 'utf8');
  } catch {
    problems.push(`${PUBLISH_WORKFLOW} is missing, so its package list cannot be cross-checked.`);
    return problems;
  }
  problems.push(...crossCheckPublishWorkflow(workflow));

  return problems;
}

/** Compare two name sets and describe the difference from `expected`. */
function describeSetDifference(expected, observed, subject) {
  const problems = [];
  for (const name of expected) {
    if (!observed.includes(name)) {
      problems.push(`${subject} omits ${name}.`);
    }
  }
  for (const name of observed) {
    if (!expected.includes(name)) {
      problems.push(`${subject} names ${name}, which is not in OPENMAIC_PACKAGES.`);
    }
  }
  return problems;
}

/**
 * Check that every place `publish-packages.yml` enumerates packages still names
 * exactly {@link OPENMAIC_PACKAGES}.
 *
 * Textual, because this runs before `pnpm install` and there is no YAML parser
 * on hand. Done honestly rather than by substring search: full-line comments
 * are stripped first, so a commented-out trigger path no longer satisfies the
 * check while quietly disabling a package's release; and each enumeration is
 * compared as a SET, so an unexpected entry fails as well as a missing one.
 *
 * WHAT IT COVERS, and therefore what it does not. Two enumerations are read as
 * exact sets: the `on.push.paths` trigger, and every `for pkg in ...;` loop
 * (the build, pack, publish and tag loops). `--filter "@openmaic/<name>"` is
 * checked more loosely — every package must appear as a filter somewhere, and
 * no filter may name an unknown package — because individual steps legitimately
 * filter subsets, as the typecheck step does by omitting importer. A textual
 * check cannot tell a deliberate subset from an accidental one; converting the
 * workflow to a matrix would, and is a larger change than this.
 */
function crossCheckPublishWorkflow(workflow) {
  const problems = [];
  const source = workflow
    .split('\n')
    .filter((line) => !/^\s*#/.test(line))
    .join('\n');

  // Scoped to the `paths:` list itself. Matching the whole file would also pick
  // up `packages/@openmaic/$pkg/package.json` inside the shell loops, where
  // `$pkg` is the loop variable rather than a package name.
  const pathsBlock = /^\s*paths:\s*$((?:\n\s*-\s*.*)*)/m.exec(source);
  if (!pathsBlock) {
    problems.push(
      `${PUBLISH_WORKFLOW} has no on.push.paths list, so either every push triggers a ` +
        'release or none does; this check expected the trigger to enumerate the packages.',
    );
  } else {
    const triggerPaths = [
      ...pathsBlock[1].matchAll(/packages\/@openmaic\/([^/"'\s]+)\/package\.json/g),
    ].map((match) => match[1]);
    problems.push(
      ...describeSetDifference(
        OPENMAIC_PACKAGES,
        [...new Set(triggerPaths)],
        `${PUBLISH_WORKFLOW} on.push.paths`,
      ),
    );
  }

  const loops = [...source.matchAll(/for pkg in ([^;]+);/g)];
  if (loops.length === 0) {
    problems.push(`${PUBLISH_WORKFLOW} has no \`for pkg in ...\` loop; this check expected one.`);
  }
  for (const loop of loops) {
    const names = loop[1].trim().split(/\s+/).filter(Boolean);
    problems.push(
      ...describeSetDifference(
        OPENMAIC_PACKAGES,
        names,
        `${PUBLISH_WORKFLOW} loop \`for pkg in ${loop[1].trim()}\``,
      ),
    );
  }

  const filtered = new Set(
    [...source.matchAll(/--filter\s+"?@openmaic\/([^"\s]+)"?/g)].map((match) => match[1]),
  );
  for (const name of OPENMAIC_PACKAGES) {
    if (!filtered.has(name)) {
      problems.push(
        `${PUBLISH_WORKFLOW} never passes --filter "@openmaic/${name}", so it is never built ` +
          'or tested on the release path.',
      );
    }
  }
  for (const name of filtered) {
    if (!OPENMAIC_PACKAGES.includes(name)) {
      problems.push(
        `${PUBLISH_WORKFLOW} filters @openmaic/${name}, which is not in OPENMAIC_PACKAGES.`,
      );
    }
  }

  return problems;
}