#!/usr/bin/env node

import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..");
const PRESETS_DIR = __dirname;
const SPEC_DIR = path.join(REPO_ROOT, "spec");
const ADAPTER_CATALOG_NAME = "adapters.json";
const TOOL_STATE_CATALOG_NAME = "toolstate.json";
const EVENTS_SCHEMA_NAME = "events.schema.json";
const CURRENT_TIERS = ["low", "medium", "high"];
const RETIRED_TIERS = ["simple", "regular", "critical"];
const RENAME_VALIDATION_PATHS = [
  "docs/specs/2026-06-27-mega-plan-harness-design.md",
  "docs/plans/2026-06-27-mega-plan-harness.md",
  "docs/plans/2026-06-27-mega-plan-harness.jsonl",
  "lib/resolve-seat.sh",
  "spec/FORK.md",
  "spec/PRESETS.md",
  "spec/presets.schema.json",
  "spec/runconfig.schema.json",
  "src/dynwf-driver.js",
  "test/dynwf-drift-guard.sh"
];

const defaultExampleRunconfigs = [
  {
    name: "anthropic-less-default",
    data: {
      version: "runconfig/v1",
      preset: "anthropic-less"
    }
  },
  {
    name: "anthropic-less-overrides",
    data: {
      version: "runconfig/v1",
      preset: "anthropic-less",
      overrides: {
        "coder.high": {
          wrapper: "wrappers/codex.sh",
          model: "gpt-5.5-high",
          fallback: "coder.medium"
        },
        reviewer: {
          wrapper: "wrappers/codex.sh",
          model: "gpt-5.5-low"
        }
      }
    }
  },
  {
    name: "codex-default",
    data: {
      version: "runconfig/v1",
      preset: "codex"
    }
  }
];

function main() {
  try {
    if (process.argv.includes("--self-test")) {
      runSelfTest();
      console.log("self-test: ok");
      return;
    }

    const result = validateRepositoryState({
      repoRoot: REPO_ROOT,
      presetsDir: PRESETS_DIR,
      specDir: SPEC_DIR,
      examples: defaultExampleRunconfigs
    });

    console.log(`validated ${result.presetCount} preset(s) and ${result.runconfigCount} example runconfig(s)`);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error(message);
    process.exitCode = 1;
  }
}

function runSelfTest() {
  const tempRoot = fs.mkdtempSync(path.join(process.cwd(), "tmp-validate-presets-"));
  try {
    const repoRoot = tempRoot;
    const specDir = path.join(repoRoot, "spec");
    const presetsDir = path.join(repoRoot, "presets");
    const wrappersDir = path.join(repoRoot, "wrappers");

    fs.mkdirSync(specDir, { recursive: true });
    fs.mkdirSync(presetsDir, { recursive: true });
    fs.mkdirSync(wrappersDir, { recursive: true });

    fs.writeFileSync(path.join(specDir, "presets.schema.json"), JSON.stringify(loadJson(path.join(SPEC_DIR, "presets.schema.json")), null, 2));
    fs.writeFileSync(path.join(specDir, "runconfig.schema.json"), JSON.stringify(loadJson(path.join(SPEC_DIR, "runconfig.schema.json")), null, 2));
    fs.writeFileSync(path.join(specDir, EVENTS_SCHEMA_NAME), JSON.stringify(loadJson(path.join(SPEC_DIR, EVENTS_SCHEMA_NAME)), null, 2));

    writeExecutable(path.join(wrappersDir, "codex.sh"));
    writeExecutable(path.join(wrappersDir, "ca.sh"));
    writeExecutable(path.join(wrappersDir, "na.sh"));

    const goodPreset = {
      name: "good",
      version: "preset/v1",
      seats: {
        coder: {
          low: {
            wrapper: "wrappers/na.sh",
            model: "cohere/north-mini-code:free"
          },
          medium: {
            wrapper: "wrappers/ca.sh",
            model: "composer-2.5",
            fallback: [
              "coder.low",
              {
                wrapper: "wrappers/na.sh",
                model: "cohere/north-mini-code:free"
              }
            ]
          },
          high: {
            wrapper: "wrappers/codex.sh",
            model: "gpt-5.5-high",
            fallback: [
              "coder.medium",
              {
                wrapper: "wrappers/ca.sh",
                model: "composer-2.5"
              }
            ]
          }
        },
        reviewer: {
          wrapper: "wrappers/codex.sh",
          model: "gpt-5.5-low"
        }
      }
    };
    fs.writeFileSync(path.join(presetsDir, "good.json"), JSON.stringify(goodPreset, null, 2));
    fs.writeFileSync(
      path.join(presetsDir, ADAPTER_CATALOG_NAME),
      JSON.stringify(
        {
          version: "adapters/v1",
          adapters: [
            {
              id: "cursor",
              wrapper: "wrappers/ca.sh",
              metered: false,
              models: ["composer-2.5"]
            },
            {
              id: "codex",
              wrapper: "wrappers/codex.sh",
              metered: true,
              models: ["gpt-5.5-high", "gpt-5.5-medium", "gpt-5.5-low"]
            },
            {
              id: "north",
              wrapper: "wrappers/na.sh",
              metered: false,
              models: ["cohere/north-mini-code:free"]
            }
          ]
        },
        null,
        2
      )
    );

    const goodExamples = [
      {
        name: "good-default",
        data: { version: "runconfig/v1", preset: "good" }
      },
      {
        name: "good-override",
        data: {
          version: "runconfig/v1",
          preset: "good",
          overrides: {
            "coder.medium": {
              wrapper: "wrappers/codex.sh",
              model: "gpt-5.5-medium"
            }
          }
        }
      }
    ];

    const goodResult = validateRepositoryState({ repoRoot, presetsDir, specDir, examples: goodExamples });
    assert.equal(goodResult.presetCount, 1);
    assert.equal(goodResult.runconfigCount, 2);

    const partialTierPreset = {
      name: "partial-tier",
      version: "preset/v1",
      seats: {
        coder: {
          medium: {
            wrapper: "wrappers/codex.sh",
            model: "gpt-5.5-medium"
          },
          high: {
            wrapper: "wrappers/ca.sh",
            model: "composer-2.5"
          }
        }
      }
    };
    fs.writeFileSync(path.join(presetsDir, "partial-tier.json"), JSON.stringify(partialTierPreset, null, 2));
    assert.throws(
      () => validateRepositoryState({ repoRoot, presetsDir, specDir, examples: goodExamples }),
      /partial-tier: seats\.coder tiered seat is missing required tier "low"/
    );
    fs.unlinkSync(path.join(presetsDir, "partial-tier.json"));

    const vaporPreset = {
      name: "vapor",
      version: "preset/v1",
      seats: {
        coder: {
          wrapper: "wrappers/missing.sh",
          model: "ghost-model"
        }
      }
    };
    fs.writeFileSync(path.join(presetsDir, "vapor.json"), JSON.stringify(vaporPreset, null, 2));

    assert.throws(
      () => validateRepositoryState({ repoRoot, presetsDir, specDir, examples: goodExamples }),
      /wrapper path .* does not exist/
    );

    fs.unlinkSync(path.join(presetsDir, "vapor.json"));

    const missingModelPreset = {
      name: "missing-model",
      version: "preset/v1",
      seats: {
        coder: {
          wrapper: "wrappers/codex.sh",
          model: "gpt-4o"
        }
      }
    };
    fs.writeFileSync(path.join(presetsDir, "missing-model.json"), JSON.stringify(missingModelPreset, null, 2));
    assert.throws(
      () => validateRepositoryState({ repoRoot, presetsDir, specDir, examples: goodExamples }),
      /binding model "gpt-4o" is not listed for adapter "codex"/
    );
    fs.unlinkSync(path.join(presetsDir, "missing-model.json"));

    fs.writeFileSync(
      path.join(presetsDir, ADAPTER_CATALOG_NAME),
      JSON.stringify(
        {
          version: "adapters/v1",
          adapters: [
            {
              id: "codex-a",
              wrapper: "wrappers/codex.sh",
              metered: true,
              models: ["gpt-5.5-high"]
            },
            {
              id: "codex-b",
              wrapper: "wrappers/codex.sh",
              metered: true,
              models: ["gpt-5.5-low"]
            }
          ]
        },
        null,
        2
      )
    );
    assert.throws(
      () => validateRepositoryState({ repoRoot, presetsDir, specDir, examples: goodExamples }),
      /duplicate adapter wrapper path: wrappers\/codex\.sh/
    );

    fs.writeFileSync(
      path.join(specDir, EVENTS_SCHEMA_NAME),
      JSON.stringify(
        {
          ...loadJson(path.join(SPEC_DIR, EVENTS_SCHEMA_NAME)),
          properties: {
            ...loadJson(path.join(SPEC_DIR, EVENTS_SCHEMA_NAME)).properties,
            dialect: {
              type: "string",
              enum: ["claude", "cursor", "codex"]
            }
          }
        },
        null,
        2
      )
    );
    fs.writeFileSync(
      path.join(presetsDir, ADAPTER_CATALOG_NAME),
      JSON.stringify(
        {
          version: "adapters/v1",
          adapters: [
            {
              id: "codex",
              wrapper: "wrappers/codex.sh",
              metered: true,
              models: ["gpt-5.5-high"]
            },
            {
              id: "north",
              wrapper: "wrappers/na.sh",
              metered: false,
              models: ["cohere/north-mini-code:free"]
            }
          ]
        },
        null,
        2
      )
    );
    assert.throws(
      () => validateRepositoryState({ repoRoot, presetsDir, specDir, examples: goodExamples }),
      /adapter id "north" is missing from spec\/events\.schema\.json dialect enum/
    );

    assert.throws(
      () =>
        validateRunconfigExamples({
          schema: loadJson(path.join(specDir, "runconfig.schema.json")),
          examples: [
            {
              name: "bad-runconfig",
              data: {
                version: "runconfig/v1",
                preset: "good",
                extra: true
              }
            }
          ]
        }),
      /unknown property "extra"/
    );
  } finally {
    fs.rmSync(tempRoot, { recursive: true, force: true });
  }
}

function validateRepositoryState({ repoRoot, presetsDir, specDir, examples }) {
  const presetSchema = loadJson(path.join(specDir, "presets.schema.json"));
  const runconfigSchema = loadJson(path.join(specDir, "runconfig.schema.json"));
  const eventsSchema = loadJson(path.join(specDir, EVENTS_SCHEMA_NAME));
  const adapterCatalog = loadAdapterCatalog({ repoRoot, presetsDir, eventsSchema });
  const presetEntries = loadPresetEntries(presetsDir);

  if (presetEntries.length === 0) {
    throw new Error(`no preset files found in ${relativeTo(repoRoot, presetsDir)}`);
  }

  for (const entry of presetEntries) {
    validateDocument({
      schema: presetSchema,
      data: entry.data,
      rootSchema: presetSchema,
      pathLabel: entry.name
    });
    validatePresetWrapperPaths({
      repoRoot,
      adapterCatalog,
      presetName: entry.data.name,
      value: entry.data.seats,
      location: "seats"
    });
  }

  validateTierCoverage({ presetEntries });
  validateTierRenameConsistency({
    repoRoot,
    allowedTiers: readAllowedTiersFromSchema(presetSchema)
  });

  validateRunconfigExamples({
    schema: runconfigSchema,
    examples
  });

  return {
    presetCount: presetEntries.length,
    runconfigCount: examples.length
  };
}

function validateRunconfigExamples({ schema, examples }) {
  for (const example of examples) {
    validateDocument({
      schema,
      data: example.data,
      rootSchema: schema,
      pathLabel: `${example.name}`
    });
  }
}

function validateTierCoverage({ presetEntries }) {
  const declaredTiers = collectDeclaredTiers(presetEntries);
  if (declaredTiers.length === 0) {
    return;
  }

  for (const entry of presetEntries) {
    for (const [seatName, seatValue] of Object.entries(entry.data.seats || {})) {
      if (!isTierMap(seatValue)) {
        continue;
      }

      for (const tierName of declaredTiers) {
        if (Object.prototype.hasOwnProperty.call(seatValue, tierName)) {
          continue;
        }
        throw new Error(`${entry.data.name}: seats.${seatName} tiered seat is missing required tier ${JSON.stringify(tierName)}`);
      }
    }
  }
}

function validateTierRenameConsistency({ repoRoot, allowedTiers }) {
  if (allowedTiers.join(",") !== CURRENT_TIERS.join(",")) {
    throw new Error(`tier schema must declare ${CURRENT_TIERS.join(", ")}, got ${allowedTiers.join(", ")}`);
  }

  const findings = [];
  for (const relativePath of RENAME_VALIDATION_PATHS) {
    const absolutePath = path.join(repoRoot, relativePath);
    if (!fs.existsSync(absolutePath)) {
      continue;
    }

    const content = fs.readFileSync(absolutePath, "utf8");
    const lines = content.split(/\r?\n/);
    lines.forEach((line, index) => {
      const matches = RETIRED_TIERS.filter((tier) => new RegExp(`\\b${tier}\\b`).test(line));
      if (matches.length === 0) {
        return;
      }
      findings.push(`${relativePath}:${index + 1} uses retired tier vocabulary (${matches.join(", ")}): ${line.trim()}`);
    });
  }

  if (findings.length > 0) {
    throw new Error(`retired tier vocabulary found:\n${findings.join("\n")}`);
  }
}

function collectDeclaredTiers(presetEntries) {
  const tierNames = new Set();
  for (const entry of presetEntries) {
    for (const seatValue of Object.values(entry.data.seats || {})) {
      if (!isTierMap(seatValue)) {
        continue;
      }
      for (const tierName of Object.keys(seatValue)) {
        tierNames.add(tierName);
      }
    }
  }
  return Array.from(tierNames).sort();
}

function resolveSchemaRef(schema, node) {
  const prefix = "#/$defs/";
  if (typeof node?.$ref !== "string" || !node.$ref.startsWith(prefix)) return node;
  return schema?.$defs?.[node.$ref.slice(prefix.length)];
}

function readAllowedTiersFromSchema(schema) {
  const candidates = schema?.properties?.seats?.additionalProperties?.oneOf ?? [];

  for (const candidate of candidates) {
    const resolved = resolveSchemaRef(schema, candidate);
    if (Array.isArray(resolved?.propertyNames?.enum) && resolved.propertyNames.enum.length > 0) {
      return [...resolved.propertyNames.enum];
    }
    // A tier map declares each tier as a $ref to the binding definition; a plain
    // binding declares scalar fields, so it never matches.
    const keys = Object.keys(resolved?.properties ?? {});
    if (keys.length > 0 && keys.every((key) => typeof resolved.properties[key]?.$ref === "string")) {
      return keys;
    }
  }

  throw new Error("presets schema is missing the tier enum");
}

function validatePresetWrapperPaths({ repoRoot, adapterCatalog, presetName, value, location }) {
  if (isBinding(value)) {
    validateBindingWrapperPaths({ repoRoot, adapterCatalog, presetName, binding: value, location });
    return;
  }

  if (!value || typeof value !== "object" || Array.isArray(value)) {
    return;
  }

  for (const [key, nested] of Object.entries(value)) {
    validatePresetWrapperPaths({
      repoRoot,
      adapterCatalog,
      presetName,
      value: nested,
      location: `${location}.${key}`
    });
  }
}

function validateBindingWrapperPaths({ repoRoot, adapterCatalog, presetName, binding, location }) {
  validateWrapperPath({
    repoRoot,
    presetName,
    wrapperPath: binding.wrapper,
    location: `${location}.wrapper`
  });
  validateBindingCatalogReference({
    adapterCatalog,
    presetName,
    binding,
    location
  });

  if (!Object.prototype.hasOwnProperty.call(binding, "fallback")) {
    return;
  }

  validateFallbackWrapperPaths({
    repoRoot,
    adapterCatalog,
    presetName,
    fallback: binding.fallback,
    location: `${location}.fallback`
  });
}

function validateFallbackWrapperPaths({ repoRoot, adapterCatalog, presetName, fallback, location }) {
  if (typeof fallback === "string") {
    return;
  }

  if (Array.isArray(fallback)) {
    fallback.forEach((item, index) => {
      validateFallbackWrapperPaths({
        repoRoot,
        adapterCatalog,
        presetName,
        fallback: item,
        location: `${location}[${index}]`
      });
    });
    return;
  }

  if (isBinding(fallback)) {
    validateBindingWrapperPaths({ repoRoot, adapterCatalog, presetName, binding: fallback, location });
  }
}

function validateWrapperPath({ repoRoot, presetName, wrapperPath, location }) {
  if (typeof wrapperPath !== "string" || wrapperPath.length === 0) {
    throw new Error(`${presetName}: ${location} must be a non-empty string`);
  }

  const resolved = path.resolve(repoRoot, wrapperPath);
  if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) {
    throw new Error(`${presetName}: wrapper path ${wrapperPath} escapes repo root`);
  }

  let stat;
  try {
    stat = fs.statSync(resolved);
  } catch {
    throw new Error(`${presetName}: wrapper path ${wrapperPath} does not exist`);
  }

  if (!stat.isFile()) {
    throw new Error(`${presetName}: wrapper path ${wrapperPath} is not a file`);
  }

  try {
    fs.accessSync(resolved, fs.constants.X_OK);
  } catch {
    throw new Error(`${presetName}: wrapper path ${wrapperPath} is not executable`);
  }
}

function loadPresetEntries(presetsDir) {
  return fs
    .readdirSync(presetsDir)
    .filter((name) => name.endsWith(".json") && name !== ADAPTER_CATALOG_NAME && name !== TOOL_STATE_CATALOG_NAME)
    .sort()
    .map((name) => ({
      name,
      data: loadJson(path.join(presetsDir, name))
    }));
}

function loadAdapterCatalog({ repoRoot, presetsDir, eventsSchema }) {
  const catalogPath = path.join(presetsDir, ADAPTER_CATALOG_NAME);
  let catalog;
  try {
    catalog = loadJson(catalogPath);
  } catch (error) {
    throw new Error(`adapter catalog ${relativeTo(repoRoot, catalogPath)} could not be loaded: ${error instanceof Error ? error.message : String(error)}`);
  }

  if (!isPlainObject(catalog)) {
    throw new Error(`adapter catalog ${relativeTo(repoRoot, catalogPath)} must be an object`);
  }

  if (!Array.isArray(catalog.adapters) || catalog.adapters.length === 0) {
    throw new Error(`adapter catalog ${relativeTo(repoRoot, catalogPath)} must contain at least one adapter`);
  }

  const wrappers = new Map();
  const dialects = readDialectEnum(eventsSchema);
  for (const adapter of catalog.adapters) {
    if (!isPlainObject(adapter)) {
      throw new Error(`adapter catalog ${relativeTo(repoRoot, catalogPath)} contains a non-object adapter entry`);
    }
    if (typeof adapter.id !== "string" || adapter.id.length === 0) {
      throw new Error(`adapter catalog ${relativeTo(repoRoot, catalogPath)} has an adapter with an empty id`);
    }
    if (typeof adapter.wrapper !== "string" || adapter.wrapper.length === 0) {
      throw new Error(`adapter ${JSON.stringify(adapter.id)} must define a non-empty wrapper`);
    }
    validateWrapperPath({
      repoRoot,
      presetName: `adapter ${JSON.stringify(adapter.id)}`,
      wrapperPath: adapter.wrapper,
      location: "wrapper"
    });
    if (wrappers.has(adapter.wrapper)) {
      throw new Error(`duplicate adapter wrapper path: ${adapter.wrapper}`);
    }
    if (!Array.isArray(adapter.models) || adapter.models.length === 0 || adapter.models.some((model) => typeof model !== "string" || model.length === 0)) {
      throw new Error(`adapter ${JSON.stringify(adapter.id)} must define a non-empty string models array`);
    }
    if (Object.prototype.hasOwnProperty.call(adapter, "metered") && typeof adapter.metered !== "boolean") {
      throw new Error(`adapter ${JSON.stringify(adapter.id)} metered must be boolean when present`);
    }
    if (!dialects.has(adapter.id)) {
      throw new Error(`adapter id ${JSON.stringify(adapter.id)} is missing from spec/events.schema.json dialect enum`);
    }
    wrappers.set(adapter.wrapper, {
      id: adapter.id,
      models: new Set(adapter.models)
    });
  }

  return {
    wrappers
  };
}

function readDialectEnum(eventsSchema) {
  const dialects = eventsSchema?.properties?.dialect?.enum;
  if (!Array.isArray(dialects) || dialects.some((dialect) => typeof dialect !== "string" || dialect.length === 0)) {
    throw new Error("spec/events.schema.json dialect enum must be a non-empty string array");
  }
  return new Set(dialects);
}

function validateBindingCatalogReference({ adapterCatalog, presetName, binding, location }) {
  const adapter = adapterCatalog.wrappers.get(binding.wrapper);
  if (!adapter) {
    throw new Error(`${presetName}: ${location}.wrapper ${JSON.stringify(binding.wrapper)} does not map to any adapter catalog entry`);
  }

  if (!Object.prototype.hasOwnProperty.call(binding, "model")) {
    return;
  }

  if (!adapter.models.has(binding.model)) {
    throw new Error(
      `${presetName}: ${location}.model binding model ${JSON.stringify(binding.model)} is not listed for adapter ${JSON.stringify(adapter.id)}`
    );
  }
}

function loadJson(filePath) {
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

function isBinding(value) {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, "wrapper");
}

function isTierMap(value) {
  return isPlainObject(value) && !isBinding(value);
}

function validateDocument({ schema, data, rootSchema, pathLabel }) {
  const errors = [];
  validateNode({ schema, data, schemaRoot: rootSchema, pathLabel, errors });
  if (errors.length > 0) {
    throw new Error(`${pathLabel}: ${errors.join("; ")}`);
  }
}

function validateNode({ schema, data, schemaRoot, pathLabel, errors }) {
  const resolvedSchema = resolveSchema(schema, schemaRoot);
  const nextSchemaRoot = resolvedSchema.__schemaRoot || schemaRoot;
  if (resolvedSchema.oneOf) {
    validateOneOf({ schema: resolvedSchema, data, schemaRoot: nextSchemaRoot, pathLabel, errors });
    return;
  }

  if (Object.prototype.hasOwnProperty.call(resolvedSchema, "const") && data !== resolvedSchema.const) {
    errors.push(`${pathLabel} must equal ${JSON.stringify(resolvedSchema.const)}`);
    return;
  }

  if (resolvedSchema.type) {
    const typeError = checkType(resolvedSchema.type, data);
    if (typeError) {
      errors.push(`${pathLabel} ${typeError}`);
      return;
    }
  }

  if (typeof data === "string" && resolvedSchema.pattern) {
    const pattern = new RegExp(resolvedSchema.pattern);
    if (!pattern.test(data)) {
      errors.push(`${pathLabel} must match pattern ${resolvedSchema.pattern}`);
    }
  }

  if (resolvedSchema.type === "integer") {
    if (!Number.isInteger(data)) {
      errors.push(`${pathLabel} must be an integer`);
      return;
    }
    if (typeof resolvedSchema.minimum === "number" && data < resolvedSchema.minimum) {
      errors.push(`${pathLabel} must be >= ${resolvedSchema.minimum}`);
    }
  }

  if (Array.isArray(data) && resolvedSchema.items) {
    data.forEach((item, index) => {
      validateNode({
        schema: resolvedSchema.items,
        data: item,
        schemaRoot: nextSchemaRoot,
        pathLabel: `${pathLabel}[${index}]`,
        errors
      });
    });
  }

  if (isPlainObject(data)) {
    validateObjectShape({ schema: resolvedSchema, data, schemaRoot: nextSchemaRoot, pathLabel, errors });
  }
}

function validateOneOf({ schema, data, schemaRoot, pathLabel, errors }) {
  let matchCount = 0;
  let lastErrors = [];
  for (const option of schema.oneOf) {
    const optionErrors = [];
    validateNode({ schema: option, data, schemaRoot, pathLabel, errors: optionErrors });
    if (optionErrors.length === 0) {
      matchCount += 1;
    } else {
      lastErrors = optionErrors;
    }
  }

  if (matchCount !== 1) {
    if (lastErrors.length > 0) {
      errors.push(...lastErrors);
    } else {
      errors.push(`${pathLabel} must match exactly one schema option`);
    }
  }
}

function validateObjectShape({ schema, data, schemaRoot, pathLabel, errors }) {
  const properties = schema.properties || {};
  const required = schema.required || [];

  for (const key of required) {
    if (!Object.prototype.hasOwnProperty.call(data, key)) {
      errors.push(`${pathLabel} requires property ${JSON.stringify(key)}`);
    }
  }

  const keys = Object.keys(data);
  if (typeof schema.minProperties === "number" && keys.length < schema.minProperties) {
    errors.push(`${pathLabel} must have at least ${schema.minProperties} propert${schema.minProperties === 1 ? "y" : "ies"}`);
  }

  if (schema.propertyNames) {
    for (const key of keys) {
      validateNode({
        schema: schema.propertyNames,
        data: key,
        schemaRoot,
        pathLabel: `${pathLabel} property name ${JSON.stringify(key)}`,
        errors
      });
    }
  }

  for (const [key, value] of Object.entries(data)) {
    if (Object.prototype.hasOwnProperty.call(properties, key)) {
      validateNode({
        schema: properties[key],
        data: value,
        schemaRoot,
        pathLabel: `${pathLabel}.${key}`,
        errors
      });
      continue;
    }

    if (schema.additionalProperties === false) {
      errors.push(`${pathLabel} has unknown property ${JSON.stringify(key)}`);
      continue;
    }

    if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
      validateNode({
        schema: schema.additionalProperties,
        data: value,
        schemaRoot,
        pathLabel: `${pathLabel}.${key}`,
        errors
      });
    }
  }
}

function resolveSchema(schema, schemaRoot) {
  if (!schema || typeof schema !== "object") {
    return {};
  }

  if (schema.$ref) {
    return resolveRef(schema.$ref, schemaRoot);
  }

  return { ...schema, __schemaRoot: schemaRoot };
}

function resolveRef(ref, schemaRoot) {
  if (ref.startsWith("#/")) {
    const target = ref
      .slice(2)
      .split("/")
      .reduce((current, segment) => {
        if (!current || typeof current !== "object") {
          throw new Error(`unsupported ref: ${ref}`);
        }
        return current[segment];
      }, schemaRoot);
    return { ...target, __schemaRoot: schemaRoot };
  }

  if (ref === "presets.schema.json#/$defs/binding") {
    const presetSchema = loadJson(path.join(SPEC_DIR, "presets.schema.json"));
    return resolveRef("#/$defs/binding", presetSchema);
  }

  throw new Error(`unsupported ref: ${ref}`);
}

function checkType(type, data) {
  switch (type) {
    case "object":
      return isPlainObject(data) ? "" : "must be an object";
    case "array":
      return Array.isArray(data) ? "" : "must be an array";
    case "string":
      return typeof data === "string" ? "" : "must be a string";
    case "integer":
      return Number.isInteger(data) ? "" : "must be an integer";
    default:
      throw new Error(`unsupported schema type: ${type}`);
  }
}

function isPlainObject(value) {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function writeExecutable(filePath) {
  fs.writeFileSync(filePath, "#!/usr/bin/env bash\nexit 0\n");
  fs.chmodSync(filePath, 0o755);
}

function relativeTo(root, target) {
  return path.relative(root, target) || ".";
}

main();
