const optional = Symbol();
const GlobalObject = Object;
const GlobalNumber = Number;
const GlobalArray = Array;

export namespace Type {
    export function Any(args: { description?: string } = {}) {
        const result: TAny = {};
        if (args.description) {
            result.description = args.description;
        }
        return result;
    }

    export function String<S extends string = string>(args: { description?: string, enum?: S[] } = {}) {
        const result: TString<S> = {
            type: 'string',
        };
        if (args.enum) {
            result.enum = args.enum;
        }
        if (args.description) {
            result.description = args.description;
        }
        return result;
    }

    export function Number<N extends number = number>(args: { description?: string, enum?: N[] } = {}) {
        const result: TNumber<N> = {
            type: 'number',
        };
        if (args.enum) {
            result.enum = args.enum;
        }
        if (args.description) {
            result.description = args.description;
        }
        return result;
    }

    export function Integer<N extends number = number>(args: { description?: string, enum?: N[] } = {}) {
        const result: TNumber<N> = {
            type: 'integer',
        };
        if (args.enum) {
            result.enum = args.enum;
        }
        if (args.description) {
            result.description = args.description;
        }
        return result;
    }

    export function Boolean(args: { description?: string } = {}) {
        const result: TBoolean = {
            type: 'boolean',
        };
        if (args.description) {
            result.description = args.description;
        }
        return result;
    }

    export function Array<T extends TScheme = TScheme>(items: T, args: { description?: string } = {}) {
        const result: TArray<T> = {
            type: 'array',
            items,
        }
        if (args.description) {
            result.description = args.description;
        }
        return result;
    }

    export function Union<T extends TScheme[]>(anyOf: [...T], args: { description?: string } = {}): TUnion<T> {
        const result: TUnion<T> = { type: 'union', anyOf };
        if (args.description) {
            result.description = args.description;
        }
        return result;
    }

    export function Optional<T extends TScheme = TScheme>(scheme: T): TOptional<T> {
        const result = { ...scheme };
        GlobalObject.defineProperty(result, optional, { value: true, enumerable: false, writable: false, configurable: false });
        return result as unknown as TOptional<T>;
    }

    export function Object<T extends TProperties = TProperties>(properties: T, args: { description?: string } = {}) {
        const result: TObject<T> = {
            type: 'object',
            properties,
            required: GlobalObject.keys(properties)
                .filter(key => !(properties[key] as any)[optional]),
        }
        if (args.description) {
            result.description = args.description;
        }
        return result;
    }

    export function Enum<S extends string, T extends TString<S>>(items: S[], args?: { description?: string }): T;
    export function Enum<N extends number, T extends TNumber<N>>(items: N[], args?: { description?: string }): T;
    export function Enum(items: (number | string)[], args: { description?: string } = {}) {
        if (typeof items?.[0] === 'number') {
            return Number({ enum: items.map(item => +item!), ...args });
        }
        return String({ enum: items.map(item => item!.toString()), ...args });
    }

    export interface CheckError {
        path: string;
        message: string;
    }

    function check(scheme: TScheme, value: unknown, path: string): CheckError[] {
        if (!('type' in scheme)) return [];

        if (value == null) {
            if ((scheme as any)[optional]) return [];
            return [{ path, message: `Expected ${scheme.type} at ${path}, got ${value}` }];
        }

        switch (scheme.type) {
            case 'string': {
                if (typeof value !== 'string') {
                    return [{ path, message: `Expected string at ${path}, got ${typeof value}` }];
                }
                if (scheme.enum && !scheme.enum.includes(value)) {
                    return [{ path, message: `Expected one of [${scheme.enum.join(', ')}] at ${path}, got "${value}"` }];
                }
                return [];
            }
            case 'number':
            case 'integer': {
                if (typeof value !== 'number') {
                    return [{ path, message: `Expected number at ${path}, got ${typeof value}` }];
                }
                if (scheme.type === 'integer' && !GlobalNumber.isInteger(value)) {
                    return [{ path, message: `Expected integer at ${path}, got ${value}` }];
                }
                const s = scheme as TNumber;
                if (s.enum && !s.enum.includes(value)) {
                    return [{ path, message: `Expected one of [${s.enum.join(', ')}] at ${path}, got ${value}` }];
                }
                return [];
            }
            case 'boolean': {
                if (typeof value !== 'boolean') {
                    return [{ path, message: `Expected boolean at ${path}, got ${typeof value}` }];
                }
                return [];
            }
            case 'array': {
                if (!GlobalArray.isArray(value)) {
                    return [{ path, message: `Expected array at ${path}, got ${typeof value}` }];
                }
                const s = scheme as TArray;
                return value.flatMap((item, i) => check(s.items, item, `${path}[${i}]`));
            }
            case 'object': {
                if (typeof value !== 'object' || GlobalArray.isArray(value)) {
                    return [{ path, message: `Expected object at ${path}, got ${typeof value}` }];
                }
                const s = scheme as TObject;
                const errors: CheckError[] = [];
                for (const key of s.required || []) {
                    if (!(key in (value as Record<string, unknown>))) {
                        errors.push({ path: `${path}.${key}`, message: `Missing required property ${path}.${key}` });
                    }
                }
                for (const [key, propScheme] of GlobalObject.entries(s.properties)) {
                    const propValue = (value as Record<string, unknown>)[key];
                    if (propValue === undefined) continue;
                    errors.push(...check(propScheme, propValue, `${path}.${key}`));
                }
                return errors;
            }
            case 'union': {
                const s = scheme as TUnion;
                for (const branch of s.anyOf) {
                    if (check(branch, value, path).length === 0) return [];
                }
                return [{ path, message: `Expected union type at ${path}, got ${typeof value}` }];
            }
            default:
                return [];
        }
    }

    export function Check(scheme: TScheme, value: unknown): CheckError[] {
        return check(scheme, value, '$');
    }

    export function Is<T extends TScheme = TScheme>(scheme: T, value: unknown): value is Static<T> {
        return Check(scheme, value).length === 0;
    }
}

export interface TAny {
    description?: string;
}

export interface TString<T extends string = string> {
    type: 'string';
    enum?: T[];
    description?: string;
}

export interface TNumber<T extends number = number> {
    type: 'number' | 'integer';
    enum?: T[];
    description?: string;
}

export interface TBoolean {
    type: 'boolean';
    description?: string;
}

export interface TArray<T extends TScheme = TScheme> {
    type: 'array';
    description?: string;
    items: T;
}

export type TProperties = Record<string, TScheme>;

export interface TObject<T extends TProperties = TProperties> {
    type: 'object';
    description?: string;
    properties: T;
    required?: string[];
}

export interface TOptionalAny extends TAny {
    [optional]: true;
}

export interface TOptionalString<T extends string = string> extends TString<T> {
    [optional]: true;
}

export interface TOptionalNumber<T extends number = number> extends TNumber<T> {
    [optional]: true;
}

export interface TOptionalBoolean extends TBoolean {
    [optional]: true;
}

export interface TOptionalArray<T extends TScheme = TScheme> extends TArray<T> {
    [optional]: true;
}

export interface TOptionalObject<T extends TProperties = TProperties> extends TObject<T> {
    [optional]: true;
}

export interface TUnion<T extends TScheme[] = TScheme[]> {
    type: 'union';
    anyOf: T;
    description?: string;
}

export interface TOptionalUnion<T extends TScheme[] = TScheme[]> extends TUnion<T> {
    [optional]: true;
}

export type TOptional<T extends TScheme = TScheme> =
    T extends TString<infer S> ? TOptionalString<S> :
    T extends TNumber<infer N> ? TOptionalNumber<N> :
    T extends TBoolean ? TOptionalBoolean :
    T extends TArray<infer I> ? TOptionalArray<I> :
    T extends TObject<infer P> ? TOptionalObject<P> :
    T extends TUnion<infer U> ? TOptionalUnion<U> :
    T extends TAny ? TOptionalAny :
    never;

export type IsOptional<T> = T extends { [optional]: true } ? true : false;

export type TScheme = TAny | TString | TNumber | TBoolean | TArray | TObject | TUnion | TOptionalString | TOptionalNumber | TOptionalBoolean | TOptionalArray | TOptionalObject | TOptionalUnion;

type Prettify<T> = { [K in keyof T]: T[K] } & {};

type RequiredKeys<T extends TProperties> = {
    [K in keyof T]: IsOptional<T[K]> extends true ? never : K
}[keyof T];

type OptionalKeys<T extends TProperties> = {
    [K in keyof T]: IsOptional<T[K]> extends true ? K : never
}[keyof T];

type StaticObject<T extends TProperties> = Prettify<
    { [K in RequiredKeys<T>]: Static<T[K]> } &
    { [K in OptionalKeys<T>]?: Static<T[K]> }
>;

type StaticUnion<T extends TScheme[]> =
    T extends [infer First extends TScheme, ...infer Rest extends TScheme[]]
    ? Static<First> | StaticUnion<Rest>
    : never;

type StaticAny = any;

export type Static<T extends TScheme> =
    T extends TString<infer S> ? S :
    T extends TNumber<infer N> ? N :
    T extends TBoolean ? boolean :
    T extends TArray<infer I> ? Static<I>[] :
    T extends TObject<infer P> ? StaticObject<P> :
    T extends TUnion<infer U> ? StaticUnion<U> :
    T extends TAny ? StaticAny :
    never;
