/*
 * Options for rules.
 */

import assert from "node:assert";
import Ajv from "ajv";
import ajvPackageJson from "ajv/package.json" with { type: "json" };
import metaSchema from "ajv/lib/refs/json-schema-draft-04.json" with { type: "json" };
import { setCwd } from "./context.ts";
import { registeredRules } from "./load.ts";
import { deepCloneJsonValue, deepFreezeJsonArray } from "./json.ts";
import { currentWorkspace, switchWorkspace } from "./workspace.ts";
import { debugAssert, debugAssertIsNonNull } from "../utils/asserts.ts";

import type { JSONSchema4 } from "json-schema";
import type { Writable } from "type-fest";
import type { JsonValue } from "./json.ts";
import type { RuleDetails } from "./load.ts";

// =================================================================================================
// Options types and constants
// =================================================================================================

/**
 * Options for a rule on a file.
 */
export type Options = JsonValue[];

/**
 * Schema describing valid options for a rule.
 * `schema` property of `RuleMeta`.
 *
 * Can be one of:
 * - `JSONSchema4` - Full JSON Schema object (must have `type: "array"` at root).
 * - `JSONSchema4[]` - Array shorthand where each element describes corresponding options element.
 * - `false` - Opts out of schema validation (not recommended).
 */
export type RuleOptionsSchema = JSONSchema4 | JSONSchema4[] | false;

/**
 * Schema validator function.
 * Does not return anything. Throws an error if validation fails.
 * Should be passed full name of the rule, in form `<plugin>/<rule>`. This is used in error messages.
 * `options` maybe be mutated if schema contains default values.
 */
export type SchemaValidator = (options: Options, ruleName: string) => void;

// Default rule options
export const DEFAULT_OPTIONS: Readonly<Options> = Object.freeze([]);

// All rule options.
// `lintFile` is called with an array of options IDs, which are indices into this array.
// First element is irrelevant - never accessed - because 0 index is a sentinel meaning default options.
// Set by `setOptions` at end of registering all plugins, and may also be changed when switching workspaces.
export let allOptions: Readonly<Options>[] | null = null;

/**
 * Set `allOptions`. Used when switching workspaces.
 * @param options - Array of options objects
 */
export function setAllOptions(options: Readonly<Options>[]) {
  allOptions = options;
}

// Index into `allOptions` for default options
export const DEFAULT_OPTIONS_ID = 0;

// =================================================================================================
// Schema compilation
// =================================================================================================

// ESLint uses AJV v6.
// AJV v7 removed support for JSON Schema draft-04, which ESLint rule schemas use.
// We must stay on v6 to match ESLint's behavior.
debugAssert(
  ajvPackageJson.version.startsWith("6."),
  `AJV must be v6.x for JSON Schema draft-04 support, got ${ajvPackageJson.version}`,
);

// AJV instance configured to match ESLint's behavior.
// `useDefaults: true` applies schema `default` values to options during validation.
//
// Based on ESLint's AJV configuration:
// https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L15-L16
// https://github.com/eslint/eslint/blob/v9.39.2/lib/shared/ajv.js#L18-L34
const AJV = new Ajv({
  meta: false,
  useDefaults: true,
  validateSchema: false,
  missingRefs: "ignore",
  verbose: true,
  schemaId: "auto",
});
AJV.addMetaSchema(metaSchema);
// Ajv internal API
(AJV._opts as { defaultMeta: string }).defaultMeta = metaSchema.id;

/**
 * Compile a rule's schema into a validator function.
 *
 * Returned validator function will throw if validation fails.
 *
 * This function should be called once when loading a rule, and the returned validator stored in `RuleDetails`.
 *
 * ESLint allows array shorthand: `schema: [item1, item2]` which means `options[0]` must match `item1`, etc.
 * This function converts that to a proper JSON Schema, before compiling it.
 *
 * Based on ESLint's `getRuleOptionsSchema`:
 * https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L177-L210
 *
 * @param schema - Rule's schema from `meta.schema`
 * @returns Compiled AJV validator, or `null` if no options accepted, or `false` if validation is disabled
 */
export function compileSchema(
  schema: RuleOptionsSchema | null | undefined,
): SchemaValidator | false | null {
  // `null` or `undefined` means no validator provided. Rule does not accept any options.
  // `false` means validation is disabled. Rule accepts any options.
  if (schema == null) return null;
  if (schema === false) return false;

  // TODO: Does AJV already do this validation?
  if (typeof schema !== "object") {
    throw new TypeError("`rule.meta.schema` must be an array, object, or `false` if provided");
  }

  if (Array.isArray(schema)) {
    // If schema is empty array, return `null`, indicating that rule does not accept any options
    if (schema.length === 0) return null;

    schema = {
      type: "array",
      items: schema,
      minItems: 0,
      maxItems: schema.length,
    };
  }

  const validate = AJV.compile(schema);
  return wrapSchemaValidator(validate);
}

/**
 * Wrap AJV validator function to throw if validation fails.
 * @param validate - AJV validator function
 * @returns Wrapped validator function, which throws if validation fails
 */
function wrapSchemaValidator(validate: Ajv.ValidateFunction): SchemaValidator {
  return (options: Options, ruleName: string): void => {
    validate(options);

    if (!validate.errors) return;

    // Copied from https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L609-L634
    // The code doesn't match AJV's types, but we use the same version of AJV as ESLint, so assuming it's OK.
    // We cast `error` to `any` to avoid TS errors.
    throw new Error(
      `Options validation failed for rule '${ruleName}':\n` +
        `Options:\n${JSON.stringify(options, null, 2)}\n` +
        `Errors:\n` +
        validate.errors
          .map((error: any) => {
            if (
              error.keyword === "additionalProperties" &&
              error.schema === false &&
              typeof error.parentSchema?.properties === "object" &&
              typeof error.params?.additionalProperty === "string"
            ) {
              const expectedProperties = Object.keys(error.parentSchema.properties).map(
                (property) => `"${property}"`,
              );
              return `\tValue ${JSON.stringify(error.data)} ${error.message}.\n\t\tUnexpected property "${error.params.additionalProperty}". Expected properties: ${expectedProperties.join(", ")}.`;
            }

            return `\tValue ${JSON.stringify(error.data)} ${error.message}.`;
          })
          .join("\n"),
    );
  };
}

// =================================================================================================
// Options processing
// =================================================================================================

/**
 * Set all external rule options.
 * Called once from Rust after config building, before any linting occurs.
 * @param optionsJSON - Array of all rule options across all configurations, serialized as JSON
 * @throws `Error` if options fail validation
 */
export function setOptions(optionsJson: string): void {
  const details = JSON.parse(optionsJson);
  allOptions = details.options;
  debugAssertIsNonNull(allOptions, "`options` included in `optionsJSON` should not be null");

  const { ruleIds, cwd } = details;

  // Validate
  if (DEBUG) {
    assert(typeof cwd === "string", `cwd must be a string, got ${typeof cwd}`);
    assert(Array.isArray(allOptions), `options must be an array, got ${typeof allOptions}`);
    assert(Array.isArray(ruleIds), `ruleIds must be an array, got ${typeof ruleIds}`);
    assert.strictEqual(
      allOptions.length,
      ruleIds.length,
      "ruleIds and options arrays must be the same length",
    );

    for (const options of allOptions) {
      assert(Array.isArray(options), `Elements of options must be arrays, got ${typeof options}`);
    }

    for (const ruleId of ruleIds) {
      assert(
        typeof ruleId === "number" && ruleId >= 0 && ruleId === Math.floor(ruleId),
        `Elements of ruleIds must be non-negative integers, got ${ruleId}`,
      );
    }
  }

  // Switch to requested workspace.
  // In CLI, `workspaceUri` is `null`, and there's only 1 workspace, so no need to switch.
  // In LSP, there can be multiple workspaces, so we need to switch if we're not already in the right one.
  const { workspaceUri } = details;
  if (workspaceUri !== null) switchWorkspace(workspaceUri);

  // Set CWD
  setCwd(cwd);

  // Process each options array.
  // For each options array, merge with default options and apply schema defaults for the corresponding rule.
  // Skip the first, as index 0 is a sentinel value meaning default options. First element is never accessed.
  // `processOptions` also deep-freezes the options.
  for (let i = 1, len = allOptions.length; i < len; i++) {
    allOptions[i] = processOptions(
      // `allOptions`' type is `Readonly`, but the array is mutable at present
      allOptions[i] as Writable<(typeof allOptions)[number]>,
      registeredRules[ruleIds[i]],
    );
  }

  // Store `cwd` and `allOptions` in workspace
  if (workspaceUri !== null) {
    debugAssertIsNonNull(currentWorkspace, "`currentWorkspace` should be initialized");
    currentWorkspace.cwd = cwd;
    currentWorkspace.allOptions = allOptions;
  }
}

/**
 * Process user-provided options for a rule by applying the rule's defaults
 * - merging with default options and applying schema defaults for the rule.
 *
 * Order of operations (matching ESLint's behavior):
 * 1. Merge with `defaultOptions` (config options take precedence).
 * 2. Apply schema defaults via AJV (fills in remaining gaps).
 *
 * This order ensures precedence: config > defaultOptions > schema defaults.
 *
 * ESLint calls `#normalizeRulesConfig()` first (merges `defaultOptions`), then `validateRulesConfig()` (AJV):
 * https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L483-L484
 * https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L532-L637
 *
 * Returned options are deep frozen.
 * `defaultOptions` must already be deep frozen before calling this function.
 * `configOption` may be mutated.
 *
 * Default options merging follows the same logic as ESLint's `getRuleOptions`:
 * https://github.com/eslint/eslint/blob/0f5a94a84beee19f376025c74f703f275d52c94b/lib/linter/linter.js#L443-L454
 * https://github.com/eslint/eslint/blob/0f5a94a84beee19f376025c74f703f275d52c94b/lib/shared/deep-merge-arrays.js
 *
 * Notably, nested arrays are not merged - config options wins. e.g.:
 * - Config options:  [ [1] ]
 * - Default options: [ [2, 3], 4 ]
 * - Merged options:  [ [1], 4 ]
 *
 * @param configOptions - Options from config (may be mutated by AJV)
 * @param ruleDetails - Rule details
 * @returns Processed options (deep frozen)
 * @throws `Error` if options fail validation
 */
function processOptions(configOptions: Options, ruleDetails: RuleDetails): Readonly<Options> {
  // Throw if no schema validator provided
  const validator = ruleDetails.optionsSchemaValidator;
  if (validator === null) {
    throw new Error(`Rule '${ruleDetails.context.id}' does not accept options`);
  }

  // Merge with `defaultOptions` first
  const { defaultOptions } = ruleDetails;

  const options =
    defaultOptions === DEFAULT_OPTIONS
      ? configOptions
      : mergeOptions(configOptions, defaultOptions);

  // Apply schema defaults (mutates `options` in place).
  //
  // AJV validation with `useDefaults: true` fills in default values from the schema.
  // `mergeOptions` cloned `defaultOptions`, so mutations made by AJV validation won't affect `defaultOptions`
  // (and `defaultOptions` is frozen anyway, so it can't be mutated).
  // `configOptions` may be mutated, but that's OK, because we only use it once.
  if (validator !== false) validator(options, ruleDetails.context.id);

  deepFreezeJsonArray(options);
  return options;
}

/**
 * Merge user-provided options from config with rule's default options.
 *
 * Config options take precedence over default options.
 *
 * Options returned are entirely mutable. No parts are frozen, even parts which come from default options.
 * Any parts of `defaultOptions` which are included are deep cloned.
 * Any parts of `configOptions` which are included in return value are *not* cloned.
 *
 * @param configOptions - Options from config
 * @param defaultOptions - Default options from `rule.meta.defaultOptions`
 * @returns Merged options (mutable, not frozen)
 */
function mergeOptions(configOptions: Options, defaultOptions: Readonly<Options>): Options {
  const merged: Options = [];

  const defaultOptionsLength = defaultOptions.length,
    configOptionsLength = configOptions.length,
    bothLength = Math.min(defaultOptionsLength, configOptionsLength);

  // Merge elements shared by both arrays
  let i = 0;
  for (; i < bothLength; i++) {
    merged.push(mergeValues(configOptions[i], defaultOptions[i]));
  }

  // Take remaining elements from whichever array is longer
  if (defaultOptionsLength > configOptionsLength) {
    // `defaultOptions` has more elements - deep clone remaining elements
    for (; i < defaultOptionsLength; i++) {
      merged.push(deepCloneJsonValue(defaultOptions[i]));
    }
  } else {
    // `configOptions` has more elements - just copy references (will be frozen later)
    for (; i < configOptionsLength; i++) {
      merged.push(configOptions[i]);
    }
  }

  return merged;
}

/**
 * Merge value from user-provided options with value from default options.
 *
 * Config value takes precedence over default value.
 * Returns a mutable value (not frozen) - caller is responsible for freezing.
 * `configValue` is mutated in place.
 *
 * @param configValue - Value from config
 * @param defaultValue - Value from default options
 * @returns Merged value (mutable)
 */
function mergeValues(configValue: JsonValue, defaultValue: JsonValue): JsonValue {
  // If config value is a primitive or array, it wins
  if (configValue === null || typeof configValue !== "object" || Array.isArray(configValue)) {
    return configValue;
  }

  // If default value is a primitive or an array, config value wins (it's an object)
  if (defaultValue === null || typeof defaultValue !== "object" || Array.isArray(defaultValue)) {
    return configValue;
  }

  // Both are objects (not arrays) - merge `defaultValue` into `configValue`.
  //
  // Symbol properties and circular references are not possible in JSON, so no need to handle them here.
  // `defaultValue` is from JSON, so we can use a simple `for..in` loop over `defaultValue`.
  for (const key in defaultValue) {
    // `Object.hasOwn` not `in`, in case `key` is `"__proto__"`
    if (Object.hasOwn(configValue, key)) {
      // Both have this key - recursively merge.
      // `key` is an own property of both `configValue` and `defaultValue`.
      // Therefore, we don't need special handling for if `key` is `"__proto__"`.
      // All the property reads and writes here will affect only the owned properties of these objects,
      // (including if those properties are named `"__proto__"`).
      configValue[key] = mergeValues(configValue[key], defaultValue[key]);
    } else {
      // Only `defaultValue` has this key - deep clone and add to `configValue`.
      // `Object.defineProperty` in case `key` is `"__proto__"`.
      Object.defineProperty(configValue, key, {
        value: deepCloneJsonValue(defaultValue[key]),
        writable: true,
        enumerable: true,
        configurable: true,
      });
    }
  }

  return configValue;
}
