import { Syms } from "./resolve.ts";
import * as ast from "../ast.ts";
import { Ty } from "../ty.ts";
import { FileReporter, Loc } from "../diagnostics.ts";
import * as stringify from "../stringify.ts";

export class Checker {
    private checkedFns = new Map<ast.FnStmt, CheckedFn>();

    constructor(
        private syms: Syms,
        private reporter: FileReporter,
    ) {}

    checkFn(fn: ast.FnStmt): CheckedFn {
        const existing = this.checkedFns.get(fn);
        if (existing) {
            return existing;
        }

        const checkedFn = new TypeChecker(this, fn, this.syms, this.reporter)
            .check();

        checkedFn.checkForCheckerInternalTys(this.reporter);

        this.checkedFns.set(fn, checkedFn);
        return checkedFn;
    }
}

export class CheckedFn {
    constructor(
        private fnTy: Ty,
        private nodeTys: Map<ast.Node, Ty>,
    ) {}

    ty(): Ty {
        return this.fnTy;
    }

    exprTy(expr: ast.Node): Ty {
        const ty = this.nodeTys.get(expr);
        if (ty === undefined) {
            throw new Error(`no type for '${expr.kind.tag}'`);
        }
        return ty;
    }

    checkForCheckerInternalTys(reporter: FileReporter) {
        for (const [node, ty] of this.nodeTys) {
            if (ty.isCheckerInternal()) {
                reporter.error(
                    node.loc,
                    `concrete type must be known at this point, got temporary type '${ty.pretty()}'`,
                );
                reporter.abort();
            }
        }
    }
}

class TypeChecker {
    private nodeTys = new Map<ast.Node, Ty>();
    private generics?: Ty[];
    private params!: Ty[];

    constructor(
        private cx: Checker,
        private fn: ast.FnStmt,
        private syms: Syms,
        private reporter: FileReporter,
    ) {}

    check(): CheckedFn {
        const generics = this.fn.kind.genericParams
            ?.map((_node, idx) => {
                return Ty.Generic(idx);
            });
        this.generics = generics;

        const params = this.fn.kind.params
            .map((node) => {
                const param = node.as("Param");
                if (!param.kind.ty) {
                    this.reporter
                        .error(param.loc, `parameter must have a type`);
                    this.reporter.abort();
                }
                return this.ty(param.kind.ty);
            });
        this.params = params;

        const retTy = this.fn.kind.retTy
            ? this.ty(this.fn.kind.retTy)
            : Ty.Void;

        this.fn.kind.body.visit({
            visit: (node) => {
                const k = node.kind;
                switch (k.tag) {
                    case "ReturnStmt": {
                        const ty = k.expr ? this.expr(k.expr, retTy) : Ty.Void;
                        if (!ty.resolvableWith(retTy)) {
                            this.reporter.error(
                                node.loc,
                                `type '${ty.pretty()}' not compatible with return type '${retTy.pretty()}'`,
                            );
                            this.reporter.info(
                                this.fn.kind.retTy?.loc ?? this.fn.loc,
                                `return type '${retTy.pretty()}' defined here`,
                            );
                            this.reporter.abort();
                        }
                        break;
                    }
                    case "LetStmt": {
                        let ty: Ty;
                        const paramTy = k.param.as("Param").kind.ty;
                        if (paramTy) {
                            const explicitTy = this.ty(paramTy);
                            const exprTy = this.expr(k.expr, explicitTy);
                            const res = this.resolve(
                                exprTy,
                                explicitTy,
                                k.expr.loc,
                            );
                            if (res.rewriteSubtree) {
                                this.rewriteTree(k.expr, res.ty);
                            }
                            ty = res.ty;
                        } else {
                            ty = this.expr(k.expr, Ty.Any);
                            if (ty.is("AnyInt")) {
                                ty = Ty.I32;
                                this.rewriteTree(k.expr, ty);
                            }
                        }

                        this.nodeTys.set(node, ty);
                        break;
                    }
                    case "ExprStmt": {
                        this.expr(k.expr, Ty.Any);
                        break;
                    }
                    case "AssignStmt": {
                        const placeTy = this.place(k.place, Ty.Any);
                        const exprTy = this.expr(k.expr, placeTy);
                        if (!placeTy.resolvableWith(exprTy)) {
                            this.reporter.error(
                                k.expr.loc,
                                `type '${exprTy.pretty()}' not assignable to type '${placeTy.pretty()}'`,
                            );
                            this.reporter.abort();
                        }
                        break;
                    }
                    case "IfStmt": {
                        const condTy = this.expr(k.cond, Ty.Bool);
                        if (!condTy.is("Bool")) {
                            this.reporter.error(
                                k.cond.loc,
                                `expected condition to be 'bool', got '${condTy.pretty()}'`,
                            );
                            this.reporter.abort();
                        }
                        break;
                    }
                    case "WhileStmt": {
                        const condTy = this.expr(k.cond, Ty.Bool);
                        if (!condTy.is("Bool")) {
                            this.reporter.error(
                                k.cond.loc,
                                `expected condition to be 'bool', got '${condTy.pretty()}'`,
                            );
                            this.reporter.abort();
                        }
                        break;
                    }
                    case "BreakStmt": {
                        break;
                    }
                    case "FnStmt": {
                        break;
                    }
                    case "Error":
                    case "File":
                    case "Block":
                    case "Param":
                    case "IdentExpr":
                    case "IntExpr":
                    case "StrExpr":
                    case "ArrayExpr":
                    case "IndexExpr":
                    case "CallExpr":
                    case "UnaryExpr":
                    case "BinaryExpr":
                    case "RangeExpr":
                    case "IdentTy":
                    case "PtrTy":
                    case "PtrMutTy":
                    case "ArrayTy":
                    case "SliceTy":
                    case "Generic":
                        break;
                    default:
                        k satisfies never;
                }
            },
        });

        this.convertAnyIntToI32();

        const ty = Ty.create("FnStmt", {
            stmt: this.fn,
            ty: Ty.Fn(params, retTy, generics ?? null),
        });

        return new CheckedFn(ty, this.nodeTys);
    }

    private convertAnyIntToI32() {
        for (const [node, ty] of this.nodeTys) {
            if (ty.is("AnyInt")) {
                this.rewriteTree(node, Ty.I64);
            }
        }
    }

    private place(expr: ast.Node, expected: Ty): Ty {
        return this.cachedCheck(expr, () => this.checkPlace(expr, expected));
    }

    private checkPlace(expr: ast.Node, expected: Ty): Ty {
        const k = expr.kind;
        switch (k.tag) {
            case "UnaryExpr": {
                switch (k.op) {
                    case "Deref": {
                        const innerTy = this.checkPlace(
                            k.expr,
                            Ty.AnyDerefable(expected),
                        );
                        if (innerTy.is("Ptr") || innerTy.is("PtrMut")) {
                            return innerTy.kind.ty;
                        }
                    }
                }
            }
        }
        return this.expr(expr, expected);
    }

    private expr(expr: ast.Node, expected: Ty): Ty {
        return this.cachedCheck(expr, () => this.checkExpr(expr, expected));
    }

    private checkExpr(expr: ast.Node, expected: Ty): Ty {
        const k = expr.kind;
        switch (k.tag) {
            case "IdentExpr": {
                const sym = this.syms.get(expr);
                if (sym.tag === "Fn") {
                    const fn = this.cx.checkFn(sym.stmt);
                    return fn.ty();
                }
                if (sym.tag === "Bool") {
                    return Ty.Bool;
                }
                if (sym.tag === "Builtin") {
                    this.reporter.error(
                        expr.loc,
                        `invalid use of builtin '${sym.id}'`,
                    );
                    this.reporter.abort();
                }
                if (sym.tag === "FnParam") {
                    return this.params[sym.idx];
                }
                if (sym.tag === "Let") {
                    const ty = this.nodeTys.get(sym.stmt);
                    if (!ty) {
                        throw new Error();
                    }
                    return ty;
                }
                throw new Error(`'${sym.tag}' not handled`);
            }
            case "IntExpr": {
                const intTy = (() => {
                    switch (k.intTy) {
                        case "u8":
                            return Ty.U8;
                        case "u16":
                            return Ty.U16;
                        case "u32":
                            return Ty.U32;
                        case "u64":
                            return Ty.U64;
                        case "usize":
                            return Ty.ISize;
                        case "i8":
                            return Ty.U8;
                        case "i16":
                            return Ty.U16;
                        case "i32":
                            return Ty.I32;
                        case "i64":
                            return Ty.U64;
                        case "isize":
                            return Ty.ISize;
                        case "any":
                            return Ty.AnyInt;
                        default:
                            throw new Error(
                                `intType '${k.intTy}' not handled`,
                            );
                    }
                })();
                return this.resolve(intTy, expected, expr.loc).ty;
            }
            case "StrExpr": {
                return this.resolve(
                    Ty.Ptr(Ty.Slice(Ty.U8)),
                    expected,
                    expr.loc,
                ).ty;
            }
            case "ArrayExpr": {
                if (k.values.length === 0) {
                    if (expected.is("Any")) {
                        return Ty.Array(Ty.Any, 0);
                    } else {
                        return this.resolve(
                            Ty.Array(Ty.Any, 0),
                            expected,
                            expr.loc,
                        ).ty;
                    }
                }

                const expectedInner = expected.isIndexable()
                    ? expected.indexableTy()!
                    : Ty.Any;

                let res = this.resolve(
                    this.expr(k.values[0], expectedInner),
                    expectedInner,
                    k.values[0].loc,
                );
                while (true) {
                    for (const val of k.values.slice(1)) {
                        res = this.resolve(
                            this.expr(val, expectedInner),
                            expectedInner,
                            k.values[0].loc,
                        );
                        if (res.rewriteSubtree) {
                            break;
                        }
                    }
                    if (!res.rewriteSubtree) {
                        break;
                    }

                    for (const val of k.values) {
                        this.rewriteTree(val, res.ty);
                    }
                }

                return Ty.Array(res.ty, k.values.length);
            }
            case "IndexExpr": {
                const innerTy = this.place(
                    k.value,
                    Ty.AnyIndexable(expected),
                );
                if (!innerTy.isIndexable()) {
                    this.reporter.error(
                        expr.loc,
                        `expected indexable type, got '${expected.pretty()}'`,
                    );
                    this.reporter.abort();
                }

                let argTy = this.expr(k.arg, Ty.Any);

                if (argTy.is("AnyInt")) {
                    argTy = Ty.USize;
                    this.rewriteTree(k.arg, argTy);
                    return innerTy.indexableTy()!;
                } else if (argTy.is("Range")) {
                    return Ty.Slice(innerTy.indexableTy()!);
                } else {
                    throw new Error();
                }
            }
            case "CallExpr": {
                const checkArgs = (
                    callableTy: Ty & { kind: { tag: "Fn" } },
                ) => {
                    const params = callableTy.kind.params;
                    if (k.args.length !== params.length) {
                        this.reporter.error(
                            expr.loc,
                            `incorrect amount of arguments. got ${k.args.length} expected ${params.length}`,
                        );
                        if (calleeTy?.is("FnStmt")) {
                            this.reporter.info(
                                calleeTy.kind.stmt.loc,
                                "function defined here",
                            );
                        }
                        this.reporter.abort();
                    }
                    const args = k.args
                        .map((arg, i) => this.expr(arg, params[i]));
                    for (const i of args.keys()) {
                        this.resolve(args[i], params[i], k.args[i].loc, () => {
                            if (calleeTy?.is("FnStmt")) {
                                this.reporter.info(
                                    calleeTy.kind.stmt.kind.params[i].loc,
                                    `parameter '${
                                        calleeTy.kind.stmt.kind.params[i]
                                            .as("Param").kind.ident
                                    }' defined here`,
                                );
                            }
                            this.reporter.error(
                                k.args[i].loc,
                                `type '${
                                    args[i].pretty()
                                }' not compatible with type '${
                                    params[i].pretty()
                                }', for argument ${i}`,
                            );
                        });
                    }
                };

                const sym = k.value.is("IdentExpr")
                    ? this.syms.get(k.value)
                    : null;
                if (sym?.tag === "Builtin") {
                    if (sym.id === "len") {
                        checkArgs(Ty.Fn([Ty.Any], Ty.USize, null).as("Fn"));
                        return Ty.USize;
                    }
                    if (sym.id === "print") {
                        for (const arg of k.args) {
                            const ty = this.expr(arg, Ty.Any);
                            if (ty.is("AnyInt")) {
                                this.rewriteTree(arg, Ty.I32);
                            }
                        }
                        return Ty.Void;
                    }
                    throw new Error(`builtin '${sym.id}' not handled`);
                }

                const calleeTy = this.expr(k.value, Ty.AnyCallable(expected));
                if (!calleeTy.isCallable()) {
                    this.reporter.error(
                        expr.loc,
                        `expected callable type, got '${expected.pretty()}'`,
                    );
                    this.reporter.abort();
                }

                const callableTy = calleeTy.callableTy();

                if (callableTy.kind.generics !== null) {
                    ((() => {
                        throw new Error("generics not implemented");
                    }) as () => void)();

                    let generics: Ty[];
                    if (k.generics) {
                        generics = k.generics.map((ty) => this.ty(ty));
                        if (
                            generics.length !== callableTy.kind.generics.length
                        ) {
                            this.reporter.error(
                                expr.loc,
                                `expected ${callableTy.kind.generics.length} generic type arguments, got ${generics.length}`,
                            );
                            this.reporter.abort();
                        }
                    } else {
                        generics = callableTy.kind.generics
                            .map((_ty) => Ty.Any);
                    }

                    const newCalleeTy = Ty.Fn(
                        callableTy.kind.params
                            .map((ty) =>
                                ty.is("Generic") ? generics[ty.kind.idx] : ty
                            ),
                        callableTy.kind.retTy.is("Generic")
                            ? generics[callableTy.kind.retTy.kind.idx]
                            : callableTy.kind.retTy,
                        null,
                    ).as("Fn");
                    this.rewriteTree(k.value, newCalleeTy);

                    checkArgs(
                        Ty.Fn(
                            callableTy.kind.params
                                .map((ty) =>
                                    ty.is("Generic")
                                        ? generics[ty.kind.idx]
                                        : ty
                                ),
                            callableTy.kind.retTy.is("Generic")
                                ? generics[callableTy.kind.retTy.kind.idx]
                                : callableTy.kind.retTy,
                            null,
                        ).as("Fn"),
                    );
                    return callableTy.kind.retTy;
                } else {
                    if (k.generics) {
                        this.reporter.error(expr.loc, "no generics expected");
                        this.reporter.abort();
                    }
                    checkArgs(callableTy);
                    return callableTy.kind.retTy;
                }
            }
            case "UnaryExpr": {
                switch (k.op) {
                    case "Not": {
                        const ty = this.expr(k.expr, Ty.Bool);
                        if (!ty.is("Bool")) {
                            this.reporter.error(
                                expr.loc,
                                `expected 'bool', got '${expected.pretty()}'`,
                            );
                            this.reporter.abort();
                        }
                        return ty;
                    }
                    case "Neg": {
                        const ty = this.expr(k.expr, expected);
                        if (!ty.is("Int")) {
                            this.reporter.error(
                                expr.loc,
                                `cannot apply ! to '${expected.pretty()}'`,
                            );
                            this.reporter.abort();
                        }
                        return ty;
                    }
                    case "Ref": {
                        const ty = this.expr(k.expr, expected);
                        return Ty.Ptr(ty);
                    }
                    case "RefMut": {
                        const ty = this.expr(k.expr, expected);
                        return Ty.PtrMut(ty);
                    }
                    case "Deref": {
                        const ty = this.expr(k.expr, expected);
                        if (!ty.is("Ptr") && !ty.is("PtrMut")) {
                            this.reporter.error(
                                expr.loc,
                                `cannot dereference type '${expected.pretty()}'`,
                            );
                            this.reporter.abort();
                        }
                        if (!ty.kind.ty.isSized()) {
                            this.reporter.error(
                                expr.loc,
                                `cannot dereference unsized type '${ty.kind.ty.pretty()}' in an expression`,
                            );
                            this.reporter.abort();
                        }
                        return ty.kind.ty;
                    }
                    default:
                        k satisfies never;
                        throw new Error();
                }
            }
            case "BinaryExpr": {
                const op = new BinaryOp(k.op);
                const expectedInner = op.isPropagating() ? expected : Ty.Any;
                const left = this.expr(k.left, expectedInner);
                const right = this.expr(k.right, expectedInner);

                const res = this.resolve(left, right, expr.loc);

                const result = binaryOpTests
                    .map((test) => test(k.op, left, right))
                    .filter((result) => result)
                    .at(0);
                if (!result) {
                    this.reporter.error(
                        expr.loc,
                        `operator '${k.tok}' cannot be applied to types '${left.pretty()}' and '${right.pretty()}'`,
                    );
                    this.reporter.abort();
                }
                this.rewriteTree(k.left, res.ty);
                this.rewriteTree(k.right, res.ty);
                return result;
            }
            case "RangeExpr": {
                for (const operandExpr of [k.begin, k.end]) {
                    const operandTy = operandExpr &&
                        this.expr(operandExpr, Ty.USize);
                    if (operandTy && !operandTy.resolvableWith(Ty.USize)) {
                        this.reporter.error(
                            operandExpr.loc,
                            `range operand must be '${Ty.USize.pretty()}', not '${operandTy.pretty()}'`,
                        );
                        this.reporter.abort();
                    }
                }
                return Ty.create("Range", {});
            }
            case "Error":
            case "File":
            case "Block":
            case "ExprStmt":
            case "AssignStmt":
            case "FnStmt":
            case "ReturnStmt":
            case "LetStmt":
            case "IfStmt":
            case "WhileStmt":
            case "BreakStmt":
            case "Param":
            case "IdentTy":
            case "PtrTy":
            case "PtrMutTy":
            case "ArrayTy":
            case "SliceTy":
            case "Generic":
                throw new Error(`node '${k.tag}' not an expression`);
            default:
                k satisfies never;
                throw new Error();
        }
    }

    private ty(ty: ast.Node): Ty {
        return this.cachedCheck(ty, () => this.checkTy(ty));
    }

    private checkTy(ty: ast.Node): Ty {
        const k = ty.kind;
        switch (k.tag) {
            case "IdentTy": {
                const sym = this.syms.get(ty);
                if (sym.tag === "BuiltinTy") {
                    switch (sym.ident) {
                        case "void":
                            return Ty.Void;
                        case "bool":
                            return Ty.Bool;
                        case "i8":
                            return Ty.I8;
                        case "i16":
                            return Ty.I16;
                        case "i32":
                            return Ty.I32;
                        case "i64":
                            return Ty.I64;
                        case "isize":
                            return Ty.ISize;
                        case "u8":
                            return Ty.U8;
                        case "u16":
                            return Ty.U16;
                        case "u32":
                            return Ty.U32;
                        case "u64":
                            return Ty.U64;
                        case "usize":
                            return Ty.USize;
                        default:
                            throw new Error(
                                `unknown type '${k.ident}'`,
                            );
                    }
                }
                if (sym.tag === "Generic") {
                    if (!this.generics) {
                        throw new Error();
                    }
                    return this.generics[sym.idx];
                }
                this.reporter.error(ty.loc, `symbol is not a type`);
                return this.reporter.abort();
            }
            case "PtrTy": {
                const ty = this.ty(k.ty);
                return Ty.create("Ptr", { ty });
            }
            case "PtrMutTy": {
                const ty = this.ty(k.ty);
                return Ty.create("PtrMut", { ty });
            }
            case "ArrayTy": {
                const ty = this.ty(k.ty);
                const lengthTy = this.expr(k.length, Ty.USize);
                if (!lengthTy.resolvableWith(Ty.USize)) {
                    this.reporter.error(
                        k.length.loc,
                        `for array length, expected 'int', got '${lengthTy.pretty()}'`,
                    );
                    this.reporter.abort();
                }
                if (!k.length.is("IntExpr")) {
                    this.reporter.error(
                        k.length.loc,
                        `array length must be an 'int' expression`,
                    );
                    this.reporter.abort();
                }
                const length = k.length.kind.value;
                return Ty.create("Array", { ty, length });
            }
            case "SliceTy": {
                const ty = this.ty(k.ty);
                return Ty.create("Slice", { ty });
            }
            case "Error":
            case "File":
            case "Block":
            case "ExprStmt":
            case "AssignStmt":
            case "FnStmt":
            case "ReturnStmt":
            case "LetStmt":
            case "IfStmt":
            case "WhileStmt":
            case "BreakStmt":
            case "Param":
            case "IdentExpr":
            case "IntExpr":
            case "StrExpr":
            case "ArrayExpr":
            case "IndexExpr":
            case "CallExpr":
            case "UnaryExpr":
            case "BinaryExpr":
            case "RangeExpr":
            case "Generic":
                throw new Error(`node '${k.tag}' not a type`);
        }
    }

    private cachedCheck(node: ast.Node, func: () => Ty): Ty {
        const existing = this.nodeTys.get(node);
        if (existing !== undefined) {
            return existing;
        }
        const ty = func();
        this.nodeTys.set(node, ty);
        return ty;
    }

    private resolve(
        ty: Ty,
        expected: Ty,
        loc: Loc,
        inCaseOfError?: () => void,
    ): TyRes {
        if (ty == expected) {
            return { ty, rewriteSubtree: false };
        }
        if (expected.is("Any")) {
            return { ty, rewriteSubtree: false };
        }
        if (expected.is("Int") && ty.is("AnyInt")) {
            return { ty: expected, rewriteSubtree: true };
        }
        if (expected.is("AnyInt") && ty.is("Int")) {
            return { ty, rewriteSubtree: true };
        }
        if (!ty.resolvableWith(expected)) {
            if (inCaseOfError) {
                inCaseOfError();
            } else {
                this.reporter.error(
                    loc,
                    `expected type '${expected.pretty()}', got '${ty.pretty()}'`,
                );
            }
            this.reporter.abort();
        }
        throw new Error(
            `resolving '${ty.pretty()}' with '${expected.pretty()}' not implemented`,
        );
    }

    private rewriteTree(node: ast.Node, ty: Ty) {
        this.nodeTys.set(node, ty);
        const k = node.kind;
        switch (k.tag) {
            case "IdentExpr":
                break;
            case "IntExpr":
                break;
            case "BinaryExpr": {
                const op = new BinaryOp(k.op);
                if (op.isPropagating()) {
                    this.rewriteTree(k.left, ty);
                    this.rewriteTree(k.right, ty);
                }
                break;
            }
            default:
                throw new Error(`not implemented for '${k.tag}'`);
        }
    }
}

type TyRes = {
    ty: Ty;
    rewriteSubtree: boolean;
};

type BinaryOpTest = (op: ast.BinaryOp, left: Ty, right: Ty) => Ty | null;

const binaryOpTests: BinaryOpTest[] = [
    (op, left, right) => {
        const ops: ast.BinaryOp[] = ["Add", "Sub", "Mul", "Div", "Rem"];
        if (
            ops.includes(op) && (left.is("Int") || left.is("AnyInt")) &&
            left.resolvableWith(right)
        ) {
            return left;
        }
        return null;
    },
    (op, left, right) => {
        const ops: ast.BinaryOp[] = ["Eq", "Ne", "Lt", "Gt", "Lte", "Gte"];
        if (
            ops.includes(op) && (left.is("Int") || left.is("AnyInt")) &&
            left.resolvableWith(right)
        ) {
            return Ty.Bool;
        }
        return null;
    },
];

class BinaryOp {
    constructor(
        public op: ast.BinaryOp,
    ) {}

    isPropagating(): boolean {
        return (["Add", "Sub", "Mul", "Div", "Rem"] as ast.BinaryOp[])
            .includes(this.op);
    }
}
