import { copy } from "fast-copy";
import sanitizeErrors from "./utils/sanitizeErrors";
import settings from "./settings";
import type {
    JsonSchemaReducer,
    JsonSchemaResolver,
    JsonSchemaValidator,
    Keyword,
    Maybe,
    ValidationAnnotation,
    ValidationPath
} from "./Keyword";
import { createSchema } from "./methods/createSchema";
import { Draft } from "./Draft";
import { toSchemaNodes } from "./methods/toSchemaNodes";
import {
    isJsonError,
    isJsonSchema,
    JsonSchema,
    BooleanSchema,
    JsonError,
    AnnotationData,
    DefaultErrors,
    OptionalNodeOrError,
    NodeOrError,
    JsonAnnotation,
    isJsonAnnotation,
    isBooleanSchema
} from "./types";
import { isObject } from "./utils/isObject";
import { join } from "@sagold/json-pointer";
import { resolveUri } from "./utils/resolveUri";
import { mergeNode } from "./mergeNode";
import { omit } from "./utils/omit";
import { pick } from "./utils/pick";
import { render } from "./errors/render";
import { TemplateOptions } from "./methods/getData";
import { validateNode } from "./validateNode";
import { hasProperty } from "./utils/hasProperty";
import { getNode } from "./getNode";
import { getNodeChild } from "./getNodeChild";
import { DataNode } from "./methods/toDataNodes";

const { DYNAMIC_PROPERTIES, REGEX_FLAGS, DECLARATOR_ONEOF, VALID_ANNOTATION_KEYWORDS } = settings;

export function isSchemaNode(value: unknown): value is SchemaNode {
    return isObject(value) && Array.isArray(value?.reducers) && Array.isArray(value?.resolvers);
}

export function isReduceable(node: SchemaNode) {
    for (let i = 0, l = DYNAMIC_PROPERTIES.length; i < l; i += 1) {
        // @ts-expect-error interface to object conversion
        if (hasProperty(node, DYNAMIC_PROPERTIES[i])) {
            return true;
        }
    }
    return false;
}

function getDraft(drafts: Draft[], $schema: string) {
    if (!Array.isArray(drafts) || drafts.length === 0) {
        throw new Error(`Missing drafts in 'compileSchema({ $schema: "${$schema}" })'`);
    }
    if (drafts.length === 1) {
        return drafts[0];
    }
    return drafts.find((d) => new RegExp(d.$schemaRegEx, REGEX_FLAGS).test($schema)) ?? drafts[drafts.length - 1];
}

export type Context = {
    /** root node of this JSON Schema */
    rootNode: SchemaNode;
    /** Fallback _draft_ version in case no _draft_ is specified by `schema.$schema` */
    draft?: string;
    /** available draft configurations */
    drafts: Draft[];
    /** [SHARED ACROSS REMOTES] root nodes of registered remote JSON Schema, stored by id/url */
    remotes: Record<string, SchemaNode>;
    /** references stored by fully resolved schema-$id + local-pointer */
    refs: Record<string, SchemaNode>;
    /** anchors stored by fully resolved schema-$id + $anchor */
    anchors: Record<string, SchemaNode>;
    /** [SHARED ACROSS REMOTES] dynamicAnchors stored by fully resolved schema-$id + $anchor */
    dynamicAnchors: Record<string, SchemaNode>;
    /** JSON Schema parser, validator, reducer and resolver for this JSON Schema (root schema and its child nodes) */
    keywords: Draft["keywords"];
    /** JSON Schema draft dependend methods */
    methods: Draft["methods"];
    /** draft version */
    version: Draft["version"];
    /** draft errors & template-strings */
    errors: Draft["errors"];
    /** draft formats & validators */
    formats: Draft["formats"];
    /** [SHARED USING ADD REMOTE] getData default options */
    getDataDefaultOptions?: TemplateOptions;
    /** [SHARED USING ADD REMOTE] collect unknown keywords in schemaAnnotations */
    withSchemaAnnotations?: boolean;
    /** [SHARED USING ADD REMOTE] throw error on validation when ref cannot be resolved */
    throwOnInvalidRef?: boolean;
};

export interface SchemaNode extends SchemaNodeMethodsType {
    /** shared context across nodes of JSON schema and shared properties across all remotes */
    context: Context;
    /** JSON Schema of node */
    schema: JsonSchema;
    /**
     * Evaluation Path - The location of the keyword that produced the annotation or error.
     * The purpose of this data is to show the resolution path which resulted in the subschema
     * that contains the keyword.
     *
     * - relative to the root of the principal schema; should include (inline) any $ref segments in the path
     * - JSON pointer
     */
    evaluationPath: string;
    /**
     * Schema Location - The direct location to the keyword that produced the annotation
     * or error. This is provided as a convenience to the user so that they don't have to resolve
     * the keyword's subschema, which may not be trivial task. It is only provided if the relative
     * location contains $refs (otherwise, the two locations will be the same).
     *
     * - absolute URI
     * - may not have any association to the principal schema
     */
    schemaLocation: string;
    /** id created when combining subschemas */
    dynamicId: string;
    /** reference to parent node (node used to compile this node) */
    parent?: SchemaNode | undefined;
    /** JSON Pointer from last $id ~~to this location~~ to resolve $refs to $id#/idLocalPointer */
    lastIdPointer: string;
    /** when reduced schema containing `oneOf` schema, `oneOfIndex` stores `oneOf`-item used for merge */
    oneOfIndex?: number;

    reducers: JsonSchemaReducer[];
    resolvers: JsonSchemaResolver[];
    validators: JsonSchemaValidator[];
    schemaValidation?: ValidationAnnotation[];

    // parsed schema properties (registered by parsers)
    $id?: string;
    $defs?: Record<string, SchemaNode>;
    $ref?: string;
    additionalProperties?: SchemaNode;
    allOf?: SchemaNode[];
    anyOf?: SchemaNode[];
    contains?: SchemaNode;
    dependentRequired?: Record<string, string[]>;
    dependentSchemas?: Record<string, SchemaNode | boolean>;
    deprecated?: boolean;
    else?: SchemaNode;
    enum?: unknown[];
    if?: SchemaNode;
    /**
     * # Items-array schema - for all drafts
     *
     * - for drafts prior 2020-12 `schema.items[]`-schema stored as `node.prefixItems`
     *
     * Validation succeeds if each element of the instance validates against the schema at the
     * same position, if any.
     *
     * The `prefixItems` keyword restricts a number of items from the start of an array instance
     * to validate against the given sequence of subschemas, where the item at a given index in
     * the array instance is evaluated against the subschema at the given index in the `prefixItems`
     * array, if any. Array items outside the range described by the `prefixItems` keyword is
     * evaluated against the items keyword, if present.
     *
     * [Docs](https://www.learnjsonschema.com/2020-12/applicator/prefixitems/)
     * | [Examples](https://json-schema.org/understanding-json-schema/reference/array#tupleValidation)
     */
    prefixItems?: SchemaNode[];
    /**
     * # Items-object schema for additional array item - for all drafts
     *
     * - for drafts prior 2020-12 `schema.additionalItems` object-schema stored as `node.items`
     *
     * Validation succeeds if each element of the instance not covered by `prefixItems` validates
     * against this schema.
     *
     * The items keyword restricts array instance items not described by the sibling `prefixItems`
     * keyword (if any), to validate against the given subschema. Whetherthis keyword was evaluated
     * against any item of the array instance is reported using annotations.
     *
     * [Docs](https://www.learnjsonschema.com/2020-12/applicator/items/)
     * | [Examples](https://json-schema.org/understanding-json-schema/reference/array#items)
     * | [AdditionalItems Specification](https://json-schema.org/draft/2019-09/draft-handrews-json-schema-02#additionalItems)
     */
    items?: SchemaNode;
    maximum?: number;
    minimum?: number;
    maxItems?: number;
    maxLength?: number;
    maxProperties?: number;
    minItems?: number;
    minLength?: number;
    minProperties?: number;
    not?: SchemaNode;
    oneOf?: SchemaNode[];
    multipleOf?: number;
    pattern?: RegExp;
    patternProperties?: { name: string; pattern: RegExp; node: SchemaNode }[];
    propertyDependencies?: Record<string, Record<string, SchemaNode>>;
    properties?: Record<string, SchemaNode>;
    propertyNames?: SchemaNode;
    required?: string[];
    then?: SchemaNode;
    type?: string | string[];
    unevaluatedItems?: SchemaNode;
    unevaluatedProperties?: SchemaNode;
    uniqueItems?: true;
}

/**
 * Fixed SchemaNode mixin methods
 */
interface SchemaNodeMethodsType {
    compileSchema(
        schema: JsonSchema | BooleanSchema,
        evaluationPath?: string,
        schemaLocation?: string,
        dynamicId?: string
    ): SchemaNode;
    createError<T extends string = DefaultErrors>(code: T, data: AnnotationData, message?: string): JsonError;
    createAnnotation<T extends string = DefaultErrors>(code: T, data: AnnotationData, message?: string): JsonAnnotation;
    createSchema(data?: unknown): JsonSchema;

    /**
     * Returns a node matching the given location (pointer) in data
     *
     * - the returned node will have a **reduced schema** based on given input data
     * - return returned node $ref is resolved
     *
     * To resolve dynamic schema where the type of JSON Schema is evaluated by
     * its value, a data object has to be passed in options.
     *
     * Per default this function will return `undefined` schema for valid properties
     * that do not have a defined schema. Use the option `withSchemaWarning: true` to
     * receive an error with `code: schema-warning` containing the location of its
     * last evaluated json-schema.
     *
     * @returns { node } or { error } where node can also be undefined (valid but undefined)
     */
    getNode(pointer: string, data: unknown, options: { withSchemaWarning: true } & GetNodeOptions): NodeOrError;
    getNode(pointer: string, data: unknown, options: { createSchema: true } & GetNodeOptions): NodeOrError;
    getNode(pointer: string, data?: unknown, options?: GetNodeOptions): OptionalNodeOrError;

    /**
     * Returns the child for the given property-name or array-index
     *
     * - the returned child node is **not reduced**
     * - a child node $ref is resolved
     *
     * @returns { node } or { error } where node can also be undefined (valid but undefined)
     */
    getNodeChild(
        key: string | number,
        data: unknown,
        options: { withSchemaWarning: true } & GetNodeOptions
    ): NodeOrError;
    getNodeChild(key: string | number, data: unknown, options: { createSchema: true } & GetNodeOptions): NodeOrError;
    getNodeChild(key: string | number, data?: unknown, options?: GetNodeOptions): OptionalNodeOrError;

    getChildSelection(property: string | number): JsonError | SchemaNode[];
    getNodeRef($ref: string): SchemaNode | undefined;
    getNodeRoot(): SchemaNode;
    getDraftVersion(): string;
    getData(data?: unknown, options?: TemplateOptions): any; // eslint-disable-line @typescript-eslint/no-explicit-any
    reduceNode(
        data: unknown,
        options?: { key?: string | number; pointer?: string; path?: ValidationPath }
    ): OptionalNodeOrError;
    resolveRef: (args?: { pointer?: string; path?: ValidationPath }) => SchemaNode;
    validate(data: unknown, pointer?: string, path?: ValidationPath): ValidateReturnType;
    addRemoteSchema(url: string, schema: JsonSchema | BooleanSchema): SchemaNode;
    toSchemaNodes(): SchemaNode[];
    toDataNodes(data: unknown, pointer?: string): DataNode[];
    toJSON(): unknown;
}

export type GetNodeOptions = {
    /**
     *  Per default `undefined` is returned for valid data, but undefined schema.
     *
     * - Using `withSchemaWarning:true` will return an error instead: `{ type: "error", code: "schema-warning" }`
     */
    withSchemaWarning?: boolean;
    /**
     *  Per default `undefined` is returned for valid data, but undefined schema.
     *
     * - Using `createSchema:true` will create a schema instead
     */
    createSchema?: boolean;
    path?: ValidationPath;
    pointer?: string;
};

export type ValidateReturnType = {
    /**
     * True, if data is valid to the compiled schema.
     * Does not include async errors.
     */
    valid: boolean;
    /**
     * List of validation errors or empty
     */
    errors: JsonError[];
    /**
     * List of annotations from validators
     */
    annotations: JsonAnnotation[];
    /**
     * List of Promises resolving to `JsonError|undefined` or empty.
     */
    errorsAsync: Promise<Maybe<ValidationAnnotation>[]>[];
};

export function joinDynamicId(a?: string, b?: string) {
    if (a == b) {
        return a ?? "";
    }
    if (a == null || b == null) {
        return (a || b) ?? "";
    }
    if (a.startsWith(b)) {
        return a;
    }
    if (b.startsWith(a)) {
        return b;
    }
    return `${a}+${b}`;
}

export const SchemaNodeMethods = {
    /**
     * Compiles a child-schema of this node to its context
     * @returns SchemaNode representing the passed JSON Schema
     */
    compileSchema(schema: JsonSchema, evaluationPath: string, schemaLocation?: string, dynamicId?: string): SchemaNode {
        const parentNode = this as SchemaNode;
        evaluationPath = evaluationPath ?? parentNode.evaluationPath;
        const nextFragment = evaluationPath.split("/$ref")[0];
        const node: SchemaNode = {
            lastIdPointer: parentNode.lastIdPointer, // ref helper
            context: parentNode.context,
            parent: parentNode,
            evaluationPath,
            dynamicId: joinDynamicId(parentNode.dynamicId, dynamicId),
            schemaLocation: schemaLocation ?? join(parentNode.schemaLocation, nextFragment),
            reducers: [],
            resolvers: [],
            validators: [],
            schema,
            ...SchemaNodeMethods
        };

        if (!isJsonSchema(schema) && !isBooleanSchema(schema)) {
            node.schemaValidation = [
                node.createError("schema-error", {
                    pointer: schemaLocation ?? evaluationPath,
                    schema,
                    value: undefined,
                    message: `JSON schema must be object or boolean - reveived: '${schema}'`
                })
            ];
            return node;
        }
        const schemaValidation = addKeywords(node).filter((err) => err != null);
        node.schemaValidation = sanitizeErrors(schemaValidation);

        return node;
    },

    createError<T extends string = DefaultErrors>(code: T, data: AnnotationData, message?: string): JsonError {
        const node = this as SchemaNode;
        let errorMessage = message;
        if (errorMessage === undefined) {
            const error = node.schema?.errorMessages?.[code] ?? node.context.errors[code];
            if (typeof error === "function") {
                return error(data);
            }
            errorMessage = render(error ?? name, data);
        }
        return { type: "error", code, message: errorMessage, data };
    },

    createAnnotation<T extends string = DefaultErrors>(
        code: T,
        data: AnnotationData,
        message?: string
    ): JsonAnnotation {
        const node = this as SchemaNode;
        let annotationMessage = message;
        if (annotationMessage === undefined) {
            const error = node.schema?.errorMessages?.[code] ?? node.context.errors[code];
            if (typeof error === "function") {
                return error(data);
            }
            annotationMessage = render(error ?? name, data);
        }
        return { type: "annotation", code, message: annotationMessage, data };
    },

    createSchema,

    getChildSelection(property: string | number): JsonError | SchemaNode[] {
        const node = this as SchemaNode;
        return node.context.methods.getChildSelection(node, property);
    },

    getNode,
    getNodeChild,

    /**
     * @returns for $ref, the corresponding SchemaNode or undefined
     */
    getNodeRef($ref: string): SchemaNode | undefined {
        const node = this as SchemaNode;
        return node.compileSchema({ $ref }, "$dynamic").resolveRef();
    },

    getNodeRoot() {
        const node = this as SchemaNode;
        return node.context.rootNode;
    },

    /**
     * @returns draft version this JSON Schema is evaluated by
     */
    getDraftVersion() {
        return (this as SchemaNode).context.version;
    },

    /**
     * @returns data that is valid to the schema of this node
     */
    getData(data?: unknown, options?: TemplateOptions) {
        const node = this as SchemaNode;
        const opts = {
            recursionLimit: 1,
            ...node.context.getDataDefaultOptions,
            cache: {},
            ...(options ?? {})
        };
        return node.context.methods.getData(node, data, opts);
    },

    /**
     * @returns SchemaNode with a reduced JSON Schema matching the given data
     */
    reduceNode(
        data: unknown,
        options: { key?: string | number; pointer?: string; path?: ValidationPath } = {}
    ): OptionalNodeOrError {
        const node = this as SchemaNode;
        const { key = "missing-key", pointer = node.evaluationPath, path } = options;

        // @ts-expect-error bool schema
        if (node.schema === false) {
            return { node, error: undefined };
            // @ts-expect-error bool schema
        } else if (node.schema === true) {
            const nextNode = node.compileSchema(createSchema(data), node.evaluationPath, node.schemaLocation);
            path?.push({ pointer, node });
            return { node: nextNode, error: undefined };
        }

        let schema;
        // we need to copy node to prevent modification of source
        // @todo does mergeNode break immutability?
        let workingNode = node.compileSchema(node.schema, node.evaluationPath, node.schemaLocation);
        const reducers = node.reducers;
        for (const reducer of reducers) {
            const result = reducer({ data, key, node, pointer, path: path ?? [] });
            if (isJsonError(result)) {
                return { node: undefined, error: result };
            }
            if (result) {
                // @ts-expect-error bool schema - for undefined & false schema return false schema
                if (result.schema === false) {
                    schema = false;
                    break;
                }
                // compilation result for data of current schemain order to merge results, we rebuild
                // node from schema alternatively we would need to merge by node-property
                workingNode = mergeNode(workingNode, result) as SchemaNode;
            }
        }

        if (schema === false) {
            // @ts-expect-error bool schema
            return { node: { ...node, schema: false, reducers: [] } as SchemaNode, error: undefined };
        }

        if (workingNode !== node) {
            path?.push({ pointer, node });
        }

        // remove dynamic properties of node
        workingNode.schema = omit(workingNode.schema, DECLARATOR_ONEOF, ...DYNAMIC_PROPERTIES);
        // @ts-expect-error string accessing schema props
        DYNAMIC_PROPERTIES.forEach((prop) => (workingNode[prop] = undefined));
        return { node: workingNode, error: undefined };
    },

    /**
     * @returns validation result of data validated by this node's JSON Schema
     */
    validate(data: unknown, pointer = "#", path: ValidationPath = []) {
        const node = this as SchemaNode;
        const errors = validateNode(node, data, pointer, path) ?? [];
        const syncErrors: JsonError[] = [];
        const annotations: JsonAnnotation[] = [];
        const flatErrorList = sanitizeErrors(Array.isArray(errors) ? errors : [errors]).filter(isJsonError);

        const errorsAsync: Promise<Maybe<ValidationAnnotation>[]>[] = [];
        sanitizeErrors(Array.isArray(errors) ? errors : [errors]).forEach((error) => {
            if (isJsonError(error)) {
                if (node.context.throwOnInvalidRef && error.code === "ref-error") {
                    const refError = new Error("Invalid $ref: " + error.message);
                    // @ts-expect-error unknown error-property
                    refError.data = syncErrors;
                    throw refError;
                }

                syncErrors.push(error);
            } else if (error instanceof Promise) {
                errorsAsync.push(error.then(sanitizeErrors));
            } else if (isJsonAnnotation(error)) {
                annotations.push(error);
            }
        });

        const result: ValidateReturnType = {
            valid: flatErrorList.length === 0,
            errors: syncErrors,
            annotations,
            errorsAsync
        };

        return result;
    },

    /**
     * Register a JSON Schema as a remote-schema to be resolved by $ref, $anchor, etc
     * @returns the current node (not the remote schema-node)
     */
    addRemoteSchema(url: string, schema: JsonSchema | BooleanSchema): SchemaNode {
        // @draft >= 6
        if (isJsonSchema(schema)) {
            schema.$id = resolveUri(schema.$id || url);
        }

        const node = this as SchemaNode;
        const { context } = node;
        const schemaId = isJsonSchema(schema) ? (node.context.draft ?? schema.$schema) : undefined;
        const draft = getDraft(context.drafts, schemaId ?? context.rootNode.schema?.$schema);

        const remoteNode: SchemaNode = {
            evaluationPath: "#",
            lastIdPointer: "#",
            schemaLocation: "#",
            dynamicId: "",
            reducers: [],
            resolvers: [],
            validators: [],
            schema,
            context: {
                ...context,
                refs: {},
                anchors: {},
                ...copy(pick(draft, "methods", "keywords", "version", "formats", "errors"))
            },
            ...SchemaNodeMethods
        } as SchemaNode;

        remoteNode.context.rootNode = remoteNode;
        remoteNode.context.remotes[resolveUri(url)] = remoteNode;
        addKeywords(remoteNode);

        return node;
    },

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    resolveRef(args?: { pointer?: string; path?: ValidationPath }) {
        throw new Error("method 'resolveRef' is not implemented");
        return this as SchemaNode;
    },

    /**
     * @returns a list of all sub-schema as SchemaNode
     */
    toSchemaNodes() {
        return toSchemaNodes(this);
    },

    /**
     * @returns a list of values (including objects and arrays) and their corresponding JSON Schema as SchemaNode
     */
    toDataNodes(data: unknown, pointer?: string): DataNode[] {
        const node = this as SchemaNode;
        return node.context.methods.toDataNodes(node, data, pointer);
    },

    toJSON() {
        const node = this as SchemaNode;
        return { ...node, context: undefined, errors: undefined, parent: node.parent?.evaluationPath };
    }
} as const;

const whitelist = ["$ref", "if", "$defs"];
const noRefMergeDrafts = ["draft-04", "draft-06", "draft-07"];

export function addKeywords(node: SchemaNode) {
    if (node.schema.$ref && noRefMergeDrafts.includes(node.context.version)) {
        // for these draft versions only ref is validated
        return node.context.keywords
            .filter(({ keyword }) => whitelist.includes(keyword))
            .map((keyword) => execKeyword(keyword, node));
    }
    const keys = Object.keys(node.schema);
    const errors = node.context.keywords
        .filter(({ keyword }) => whitelist.includes(keyword) || keys.includes(keyword))
        .map((keyword) => execKeyword(keyword, node));

    keys.filter(
        (key) =>
            !key.startsWith("x-") &&
            !VALID_ANNOTATION_KEYWORDS.includes(key) &&
            node.context.keywords.find((keyword) => keyword.keyword === key) == null
    ).forEach((keyword) => {
        errors.push(
            node.createAnnotation("unknown-keyword-warning", {
                pointer: `${node.schemaLocation}/${keyword}`,
                schema: node.schema,
                value: keyword,
                draft: node.getDraftVersion()
            })
        );
    });

    return errors;
}

export function execKeyword(keyword: Keyword, node: SchemaNode) {
    // @todo consider first parsing all nodes
    const errors = keyword.parse?.(node);
    if (keyword.reduce && keyword.addReduce?.(node)) {
        node.reducers.push(keyword.reduce);
    }
    if (keyword.resolve && keyword.addResolve?.(node)) {
        node.resolvers.push(keyword.resolve);
    }
    if (keyword.validate && keyword.addValidate?.(node)) {
        node.validators.push(keyword.validate);
    }
    return errors;
}
