/**
 * Validates test-spec.json files against test-spec.schema.json.
 *
 * Implements the subset of JSON Schema (draft 2020-12) keywords used by our schema:
 *   $ref, type, enum, minLength, minimum, pattern, required, additionalProperties,
 *   properties, items, allOf, oneOf, anyOf.
 *
 * No external dependencies — intentional, to keep the test harness self-contained.
 */

import * as fs from "fs";
import * as path from "path";

import { fileURLToPath } from "url";
import { dirname } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const SCHEMA_PATH = path.join(__dirname, "syntax-tests", "test-spec.schema.json");

let _schema = null;

function loadSchema() {
    if (_schema === null) {
        _schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, "utf8"));
    }
    return _schema;
}

// ---------------------------------------------------------------------------
// Type helpers
// ---------------------------------------------------------------------------

function getJsonType(value) {
    if (value === null)         return "null";
    if (Array.isArray(value))  return "array";
    if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
    return typeof value; // "string" | "boolean" | "object"
}

function satisfiesType(actualType, declared) {
    if (actualType === declared) return true;
    // JSON Schema: "integer" is a subtype of "number"
    if (actualType === "integer" && declared === "number") return true;
    return false;
}

// ---------------------------------------------------------------------------
// Core validator
// ---------------------------------------------------------------------------

/**
 * Validate `value` against `schemaNode`. Returns an array of error strings.
 * `defs` is the $defs map from the root schema.
 * `atPath` is a human-readable path string used in error messages.
 */
function validate(value, schemaNode, defs, atPath) {
    const errors = [];

    if (!schemaNode || typeof schemaNode !== "object") return errors;

    // $ref — resolve and delegate entirely
    if ("$ref" in schemaNode) {
        const defName = schemaNode.$ref.replace(/^#\/\$defs\//, "");
        const refSchema = defs[defName];
        if (!refSchema) {
            errors.push(`${atPath}: Unresolved $ref "${schemaNode.$ref}"`);
            return errors;
        }
        return validate(value, refSchema, defs, atPath);
    }

    const actualType = getJsonType(value);

    // type
    if (schemaNode.type !== undefined) {
        const allowed = Array.isArray(schemaNode.type) ? schemaNode.type : [schemaNode.type];
        if (!allowed.some((t) => satisfiesType(actualType, t))) {
            errors.push(`${atPath}: Expected type [${allowed.join(", ")}], got ${actualType}`);
            // Most further checks are type-specific; bail out to avoid noisy errors.
            return errors;
        }
    }

    // enum
    if (schemaNode.enum !== undefined && !schemaNode.enum.includes(value)) {
        errors.push(`${atPath}: ${JSON.stringify(value)} must be one of ${JSON.stringify(schemaNode.enum)}`);
    }

    // string keywords
    if (typeof value === "string") {
        if (schemaNode.minLength !== undefined && value.length < schemaNode.minLength) {
            errors.push(`${atPath}: String length ${value.length} is below minLength ${schemaNode.minLength}`);
        }
        if (schemaNode.pattern !== undefined) {
            try {
                if (!new RegExp(schemaNode.pattern).test(value)) {
                    errors.push(`${atPath}: ${JSON.stringify(value)} does not match pattern /${schemaNode.pattern}/`);
                }
            } catch (e) {
                errors.push(`${atPath}: schema pattern /${schemaNode.pattern}/ is not a valid regular expression`);
            }
        }
    }

    // number / integer keywords
    if (typeof value === "number" && schemaNode.minimum !== undefined && value < schemaNode.minimum) {
        errors.push(`${atPath}: ${value} is below minimum ${schemaNode.minimum}`);
    }

    // object keywords
    if (actualType === "object") {
        // required
        if (schemaNode.required) {
            for (const key of schemaNode.required) {
                if (!(key in value)) {
                    errors.push(`${atPath}: Missing required property "${key}"`);
                }
            }
        }

        const knownProps = schemaNode.properties ? Object.keys(schemaNode.properties) : [];

        // additionalProperties: false
        if (schemaNode.additionalProperties === false) {
            for (const key of Object.keys(value)) {
                if (!knownProps.includes(key)) {
                    errors.push(`${atPath}: Property "${key}" is not allowed`);
                }
            }
        }

        // properties — validate each present property
        if (schemaNode.properties) {
            for (const [key, propSchema] of Object.entries(schemaNode.properties)) {
                if (key in value) {
                    errors.push(...validate(value[key], propSchema, defs, `${atPath}.${key}`));
                }
            }
        }

        // additionalProperties as a schema (not false)
        if (
            schemaNode.additionalProperties !== null &&
            schemaNode.additionalProperties !== false &&
            typeof schemaNode.additionalProperties === "object"
        ) {
            for (const [key, val] of Object.entries(value)) {
                if (!knownProps.includes(key)) {
                    errors.push(...validate(val, schemaNode.additionalProperties, defs, `${atPath}.${key}`));
                }
            }
        }
    }

    // array keywords
    if (actualType === "array" && schemaNode.items) {
        for (let i = 0; i < value.length; i++) {
            errors.push(...validate(value[i], schemaNode.items, defs, `${atPath}[${i}]`));
        }
    }

    // allOf — all sub-schemas must pass
    if (schemaNode.allOf) {
        for (let i = 0; i < schemaNode.allOf.length; i++) {
            errors.push(...validate(value, schemaNode.allOf[i], defs, atPath));
        }
    }

    // oneOf — exactly one sub-schema must pass
    if (schemaNode.oneOf) {
        const results = schemaNode.oneOf.map((sub) => validate(value, sub, defs, atPath));
        const passing = results.filter((r) => r.length === 0);
        if (passing.length !== 1) {
            if (passing.length === 0) {
                const detail = results
                    .map((r, i) => r.map((e) => `    [branch ${i + 1}] ${e}`).join("\n"))
                    .join("\n");
                errors.push(
                    `${atPath}: Must match exactly one sub-schema (matched 0 of ${schemaNode.oneOf.length}):\n${detail}`
                );
            } else {
                errors.push(
                    `${atPath}: Must match exactly one sub-schema (matched ${passing.length} of ${schemaNode.oneOf.length})`
                );
            }
        }
    }

    // anyOf — at least one sub-schema must pass
    if (schemaNode.anyOf) {
        const results = schemaNode.anyOf.map((sub) => validate(value, sub, defs, atPath));
        const passing = results.filter((r) => r.length === 0);
        if (passing.length === 0) {
            const detail = results
                .map((r, i) => r.map((e) => `    [option ${i + 1}] ${e}`).join("\n"))
                .join("\n");
            errors.push(
                `${atPath}: Must match at least one sub-schema (matched 0 of ${schemaNode.anyOf.length}):\n${detail}`
            );
        }
    }

    return errors;
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
 * Validates a parsed spec object against test-spec.schema.json.
 * Throws an Error listing all violations if the spec is invalid.
 *
 * @param {object} spec      - Parsed JSON spec object
 * @param {string} specPath  - Absolute path (used in error messages only)
 */
function validateSpecAgainstSchema(spec, specPath) {
    const schema = loadSchema();
    const defs   = schema.$defs || {};
    const errors = validate(spec, schema, defs, "spec");

    if (errors.length > 0) {
        throw new Error(
            `${path.basename(specPath)} failed schema validation:\n  ${errors.join("\n  ")}`
        );
    }
}

export { validateSpecAgainstSchema };
