import { getSchemaFromResult, Lookup } from '@nx/nx-dev/data-access-packages';
import { JsonSchema, JsonSchema1 } from '@nx/nx-dev/models-package';
import { shouldShowInStage, Stage } from './stage';
import { getOrInferType } from './types/type-inference';

export class Example {
  private _value: any;

  public static of(value: any) {
    return new Example(value);
  }

  constructor(value: any) {
    this._value = value;
  }

  get value() {
    return this._value;
  }
}

export type ErrorReason =
  | 'missing-schema'
  | 'schema-not-supported'
  | 'infinite-prop-loop'
  | 'all-of-mismatched-types'
  | 'example-of-nothing-is-impossible'
  | 'type-array-was-empty'
  | 'ran-out-of-memory';

export class Error {
  private _reason: ErrorReason;
  private _message: string;

  constructor(reason: ErrorReason, message: string) {
    this._reason = reason;
    this._message = message;
  }

  get reason() {
    return this._reason;
  }

  get message() {
    return this._message;
  }
}

export class Errors {
  private _errors: Error[];

  public static from(...manyErrors: Errors[]) {
    return new Errors(
      manyErrors
        .map((errs) => errs.errors)
        .reduce((prev, curr) => {
          prev.push(...curr);
          return prev;
        }, [])
    );
  }

  public static of(...errors: Error[]) {
    return new Errors(errors);
  }

  constructor(errors: Error[]) {
    this._errors = errors;
  }

  get errors() {
    return this._errors;
  }

  get length() {
    return this._errors.length;
  }
}

class ChainContext {
  constructor(
    private resolvedReferences: Set<string>,
    private internalLookup: Lookup,
    private internalDepth: number,
    private internalStage: Stage,
    private internalParent: JsonSchema | undefined
  ) {}

  get lookup(): Lookup {
    return this.internalLookup;
  }

  get depth(): number {
    return this.internalDepth;
  }

  get stage(): Stage {
    return this.internalStage;
  }

  get parent(): JsonSchema | undefined {
    return this.internalParent;
  }

  public registerReference(ref: NonNullable<JsonSchema1['$ref']>) {
    this.resolvedReferences.add(ref);
  }

  public seenBefore(ref: NonNullable<JsonSchema1['$ref']>): boolean {
    return this.resolvedReferences.has(ref);
  }

  public clone(currentParent: JsonSchema): ChainContext {
    return new ChainContext(
      new Set(this.resolvedReferences),
      this.internalLookup,
      this.internalDepth + 1,
      this.internalStage,
      currentParent
    );
  }
}

type NameAndExample = {
  name: string;
  example: Example | Errors;
};

function missingSchema(schemaOrRef: JsonSchema): Error {
  return new Error(
    'missing-schema',
    `Could not find a schema for: ${JSON.stringify(schemaOrRef)}`
  );
}

function notSupported(message: string): Error {
  return new Error('schema-not-supported', message);
}

function infinitePropLoopForObject(
  propName: string,
  ref: NonNullable<JsonSchema1['$ref']>,
  schema: JsonSchema1
): Error {
  return new Error(
    'infinite-prop-loop',
    `The reference to '${ref}' in the property '${propName}' in the schema '${
      schema.title || 'object'
    }'
        causes an infinite loop.`
  );
}

function allOfMismatchedTypes(allTypes: (string | null)[]): Error {
  return new Error(
    'all-of-mismatched-types',
    `There was an allOf that evaluated to examples of mismatched types: ${JSON.stringify(
      allTypes
    )}`
  );
}

function nothing(parentSchema: JsonSchema | undefined): Error {
  const renderedParent =
    parentSchema === undefined ? 'root' : JSON.stringify(parentSchema);
  return new Error(
    'example-of-nothing-is-impossible',
    `Can't generate an example of the 'nothing' type for a child of: ${renderedParent}`
  );
}

export function isExample(t: any): t is Example {
  return t instanceof Example;
}

export function isErrors(t: any): t is Errors {
  return t instanceof Errors;
}

function isError(t: any): t is Error {
  return t instanceof Error;
}

function isSchema(t: JsonSchema | Error): t is JsonSchema {
  return !isError(t);
}

type IgnoredProperty = {
  name: string;
};

function isNameAndExample(
  t: NameAndExample | IgnoredProperty
): t is NameAndExample {
  return 'example' in t;
}

function inferExample<A>(
  schema: JsonSchema1,
  typeMatcher: (x: any) => boolean,
  defaultExample: () => Example
): Example {
  const match = (schema.examples || []).find(typeMatcher);
  if (match !== undefined) {
    return Example.of(match);
  }

  if (
    schema.enum !== undefined &&
    schema.enum.length > 0 &&
    typeMatcher(schema.enum[0])
  ) {
    return Example.of(schema.enum[0]);
  }
  return defaultExample();
}

function getSchemaNameForError(schemaOrRef: JsonSchema): string {
  if (typeof schemaOrRef === 'boolean') {
    return '<boolean schema with no name>';
  }

  if (schemaOrRef.$ref !== undefined) {
    return schemaOrRef.$ref;
  }

  return schemaOrRef.title === undefined ? 'object' : schemaOrRef.title;
}

function generateJsonExampleForHelper(
  context: ChainContext,
  schemaOrRef: JsonSchema
): Example | Errors {
  const { lookup } = context;
  const schema = getSchemaFromResult(lookup.getSchema(schemaOrRef));
  if (schema === undefined) {
    return Errors.of(missingSchema(schemaOrRef));
  }

  if (typeof schemaOrRef !== 'boolean' && schemaOrRef.$ref !== undefined) {
    context.registerReference(schemaOrRef.$ref);
  }

  if (typeof schema === 'boolean') {
    if (schema) {
      // We have no examples, so let's just return an empty object
      return Example.of({});
    } else {
      return Errors.of(nothing(context.parent));
    }
  }

  if (Object.keys(schema).length === 0) {
    // You accept anything in this slot, so let's just return an empty object.
    return Example.of({});
  }

  let type = getOrInferType(schema);

  if (Array.isArray(type)) {
    if (type.length >= 1) {
      type = type[0];
    } else {
      return Errors.of(
        new Error(
          'type-array-was-empty',
          `The type was an empty array for: ${JSON.stringify(schemaOrRef)}`
        )
      );
    }
  }

  if (type !== undefined) {
    if (type === 'boolean') {
      return inferExample(
        schema,
        (x) => typeof x === 'boolean',
        () => Example.of(true)
      );
    } else if (type === 'integer' || type === 'number') {
      return inferExample(
        schema,
        (x) => typeof x === 'number' || typeof x === 'bigint',
        () => Example.of(schema.description ? schema.description.length : 2154)
      );
    } else if (type === 'string') {
      return inferExample(
        schema,
        (x) => typeof x === 'string',
        () => Example.of('<string>')
      );
    } else if (type === 'array') {
      const match = (schema.examples || []).find(Array.isArray);
      if (match !== undefined) {
        return Example.of(match);
      }

      if (schema.items === undefined) {
        return Example.of([]);
      }

      const chosenItem = Array.isArray(schema.items)
        ? schema.items[0]
        : schema.items;
      const itemSchema =
        schema.items === undefined
          ? undefined
          : getSchemaFromResult(lookup.getSchema(chosenItem));

      if (itemSchema === undefined) {
        return Example.of([]);
      } else {
        // Setup the next context
        let nextContext = context;
        if (typeof chosenItem !== 'boolean' && chosenItem.$ref !== undefined) {
          if (context.seenBefore(chosenItem.$ref)) {
            // If it's an infinite loop then just return no elements. Magic!
            return Example.of([]);
          }
          nextContext = context.clone(chosenItem);
          nextContext.registerReference(chosenItem.$ref);
        }

        const itemExample = generateJsonExampleForHelper(
          nextContext,
          itemSchema
        );

        if (isErrors(itemExample)) {
          return Example.of([]);
        }

        const itemsToRender = schema.uniqueItems ? 1 : schema.minItems || 1;
        return Example.of(Array(itemsToRender).fill(itemExample.value));
      }
    } else {
      const match = (schema.examples || []).find((x) => typeof x === 'object');
      if (match !== undefined) {
        return Example.of(match);
      }

      const { properties, required } = schema;
      const requiredPropNames = new Set<string>(required || []);
      if (properties === undefined) {
        // Return an empty object because no properties are allowed
        return Example.of({});
      } else {
        const props = Object.keys(properties)
          .filter((name) => {
            const propSchema = getSchemaFromResult(
              context.lookup.getSchema(properties[name])
            );
            if (propSchema === undefined) {
              return true;
            }
            return shouldShowInStage(context.stage, propSchema);
          })
          .map<NameAndExample | IgnoredProperty>((name) => {
            const propOrRef = properties[name];

            if (context.depth >= 1 && !requiredPropNames.has(name)) {
              return { name };
            }

            if (typeof propOrRef === 'boolean') {
              if (propOrRef) {
                // We have no examples, so let's just return an empty object
                return { name, example: Example.of({}) };
              } else {
                return { name, example: Errors.of(nothing(schema)) };
              }
            }

            // Setup the next context
            let nextContext = context;
            if (propOrRef.$ref !== undefined) {
              if (context.seenBefore(propOrRef.$ref)) {
                return requiredPropNames.has(name)
                  ? {
                      name,
                      example: Errors.of(
                        infinitePropLoopForObject(name, propOrRef.$ref, schema)
                      ),
                    }
                  : { name };
              }
              nextContext = context.clone(propOrRef);
              nextContext.registerReference(propOrRef.$ref);
            }

            const prop = getSchemaFromResult(lookup.getSchema(propOrRef));
            if (prop === undefined) {
              return { name, example: Errors.of(missingSchema(propOrRef)) };
            }

            const generatedExample = generateJsonExampleForHelper(
              nextContext,
              prop
            );
            if (isErrors(generatedExample) && !requiredPropNames.has(name)) {
              return { name };
            }

            return {
              name,
              example: generatedExample,
            };
          });

        const nonIgnoredProps = props.filter(isNameAndExample);

        // If there were errors then just return the errors
        const e = Errors.from(
          ...nonIgnoredProps.map((p) => p.example).filter<Errors>(isErrors)
        );
        if (e.length > 0) {
          return e;
        }

        // Otherwise, just make the example
        let example: Record<string, Example['value']> = {};
        nonIgnoredProps.forEach((prop) => {
          if (isExample(prop.example)) {
            example[prop.name] = prop.example.value;
          }
        });
        return Example.of(example);
      }
    }
  } else {
    if (schema.anyOf !== undefined && schema.anyOf.length > 0) {
      return generateJsonExampleForHelper(context, schema.anyOf[0]);
    } else if (schema.oneOf !== undefined && schema.oneOf.length > 0) {
      return generateJsonExampleForHelper(context, schema.oneOf[0]);
    } else if (schema.allOf !== undefined && schema.allOf.length > 0) {
      let nextContext = context.clone(schema);
      const potentialSchemas = schema.allOf.map<JsonSchema | Error>((s) => {
        const ps = getSchemaFromResult(lookup.getSchema(s));
        if (typeof s !== 'boolean' && s.$ref !== undefined) {
          nextContext.registerReference(s.$ref);
        }
        return ps === undefined ? missingSchema(s) : ps;
      });

      let errors = potentialSchemas.filter(isError);
      if (errors.length > 0) {
        return new Errors(errors);
      }

      const exs = potentialSchemas
        .filter(isSchema)
        .map((s) => generateJsonExampleForHelper(nextContext, s));

      const errs = exs.filter(isErrors);
      if (errs.length > 0) {
        return Errors.from(...errs);
      }

      const examples = exs.filter(isExample).map((e) => e.value);
      const allExampleTypes = examples.map<string | null>((e) => typeof e);
      const matchedType = allExampleTypes.reduce((a, b) =>
        a === b ? a : null
      );
      if (matchedType === null) {
        return Errors.of(allOfMismatchedTypes(allExampleTypes));
      }

      if (matchedType === 'object') {
        const example = structuredClone(examples);
        return Example.of(example);
      } else if (
        matchedType === 'string' ||
        matchedType === 'number' ||
        matchedType === 'boolean'
      ) {
        return Example.of(examples[0]);
      }
    }

    const schemaName = getSchemaNameForError(schemaOrRef);

    return Errors.of(
      notSupported(
        `Support schemas without a "type" has not been written yet. Source: ${schemaName}. Parent ${JSON.stringify(
          context.parent
        )}`
      )
    );
  }
}

export function generateJsonExampleFor(
  schemaOrRef: JsonSchema,
  lookup: Lookup,
  stage: Stage
): Example | Errors {
  try {
    return generateJsonExampleForHelper(
      new ChainContext(new Set<string>(), lookup, 0, stage, undefined),
      schemaOrRef
    );
  } catch (e) {
    return Errors.of(new Error('ran-out-of-memory', `Ran out of memory: ${e}`));
  }
}
