import autobind from 'autobind-decorator';
import { isLiteralValue } from './expr';
import { funcDefs } from './lib';
import { envVarsDef } from '.';
import type { Type, PageVar } from '.';
import type { Expr, Variable } from './expr';

type TypeError = {
	arg: number;
	expect: Type;
	actual: Type;
};

/**
 * Hpml type checker
 */
export class HpmlTypeChecker {
	public variables: Variable[];
	public pageVars: PageVar[];

	constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) {
		this.variables = variables;
		this.pageVars = pageVars;
	}

	@autobind
	public typeCheck(v: Expr): TypeError | null {
		if (isLiteralValue(v)) return null;

		const def = funcDefs[v.type || ''];
		if (def == null) {
			throw new Error('Unknown type: ' + v.type);
		}

		const generic: Type[] = [];

		for (let i = 0; i < def.in.length; i++) {
			const arg = def.in[i];
			const type = this.infer(v.args[i]);
			if (type === null) continue;

			if (typeof arg === 'number') {
				if (generic[arg] === undefined) {
					generic[arg] = type;
				} else if (type !== generic[arg]) {
					return {
						arg: i,
						expect: generic[arg],
						actual: type,
					};
				}
			} else if (type !== arg) {
				return {
					arg: i,
					expect: arg,
					actual: type,
				};
			}
		}

		return null;
	}

	@autobind
	public getExpectedType(v: Expr, slot: number): Type {
		const def = funcDefs[v.type ?? ''];
		if (def == null) {
			throw new Error('Unknown type: ' + v.type);
		}

		const generic: Type[] = [];

		for (let i = 0; i < def.in.length; i++) {
			const arg = def.in[i];
			const type = this.infer(v.args[i]);
			if (type === null) continue;

			if (typeof arg === 'number') {
				if (generic[arg] === undefined) {
					generic[arg] = type;
				}
			}
		}

		if (typeof def.in[slot] === 'number') {
			return generic[def.in[slot]] ?? null;
		} else {
			return def.in[slot];
		}
	}

	@autobind
	public infer(v: Expr): Type {
		if (v.type === null) return null;
		if (v.type === 'text') return 'string';
		if (v.type === 'multiLineText') return 'string';
		if (v.type === 'textList') return 'stringArray';
		if (v.type === 'number') return 'number';
		if (v.type === 'ref') {
			const variable = this.variables.find(va => va.name === v.value);
			if (variable) {
				return this.infer(variable);
			}

			const pageVar = this.pageVars.find(va => va.name === v.value);
			if (pageVar) {
				return pageVar.type;
			}

			const envVar = envVarsDef[v.value ?? ''];
			if (envVar !== undefined) {
				return envVar;
			}

			return null;
		}
		if (v.type === 'aiScriptVar') return null;
		if (v.type === 'fn') return null; // todo
		if (v.type.startsWith('fn:')) return null; // todo

		const generic: Type[] = [];

		const def = funcDefs[v.type];

		for (let i = 0; i < def.in.length; i++) {
			const arg = def.in[i];
			if (typeof arg === 'number') {
				const type = this.infer(v.args[i]);

				if (generic[arg] === undefined) {
					generic[arg] = type;
				} else {
					if (type !== generic[arg]) {
						generic[arg] = null;
					}
				}
			}
		}

		if (typeof def.out === 'number') {
			return generic[def.out];
		} else {
			return def.out;
		}
	}

	@autobind
	public getVarByName(name: string): Variable {
		const v = this.variables.find(x => x.name === name);
		if (v !== undefined) {
			return v;
		} else {
			throw new Error(`No such variable '${name}'`);
		}
	}

	@autobind
	public getVarsByType(type: Type): Variable[] {
		if (type == null) return this.variables;
		return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type));
	}

	@autobind
	public getEnvVarsByType(type: Type): string[] {
		if (type == null) return Object.keys(envVarsDef);
		return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k);
	}

	@autobind
	public getPageVarsByType(type: Type): string[] {
		if (type == null) return this.pageVars.map(v => v.name);
		return this.pageVars.filter(v => type === v.type).map(v => v.name);
	}

	@autobind
	public isUsedName(name: string) {
		if (this.variables.some(v => v.name === name)) {
			return true;
		}

		if (this.pageVars.some(v => v.name === name)) {
			return true;
		}

		if (envVarsDef[name]) {
			return true;
		}

		return false;
	}
}
