import { Option, some } from "@shared/utils/option.ts";
import db from "@lib/db/index.ts";
import { ok, Result } from "@shared/utils/result.ts";
import { AdminPasswordNotSetError, QueryExecutionError } from "@lib/errors.ts";
import { AdminRaw, AdminSessionRaw } from "@lib/db/types/index.ts";
import { generateRandomString, passwd } from "@lib/utils.ts";
import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts";
import log from "@shared/utils/logger.ts";

const TOKEN_LENGTH = 128;
const EXPIRED_TOKENS_DELETION_INTERVAL = 120 * 60 * 1000;
const DEFAULT_SESSION_EXPIRATION_OFFSET = 15; // minutes

class Admin {
    private passwordHash?: string;

    private readonly statements = {
        fetchAdmin: db.prepareFetch<AdminRaw, []>(
            "SELECT * FROM admin WHERE id = 1",
        ).get,
        updatePasswordHash: db.prepareExec<[hash: string]>(
            "UPDATE admin SET passwordHash = ? WHERE id = 1",
        ),
        insertPasswordHash: db.prepareExec<[hash: string]>(
            "INSERT INTO admin (id, passwordHash) VALUES (1, ?)",
        ),
        fetchPasswordHash: db.prepareFetch<{ passwordHash: string }, []>(
            "SELECT passwordHash FROM admin WHERE id=1",
        ).get,
    };

    private getPasswordHash(): Result<Option<string>, QueryExecutionError> {
        if (this.passwordHash) {
            return ok(some(this.passwordHash));
        }

        return this.statements
            .fetchPasswordHash()
            .mapOption(({ passwordHash }) => {
                this.passwordHash = passwordHash;
                return passwordHash;
            });
    }

    public isPasswordSet(): Result<boolean, QueryExecutionError> {
        return this.getPasswordHash().map((opt) => opt.toBoolean());
    }

    public verifyPassword(
        password: string,
    ): ResultAsync<boolean, QueryExecutionError | AdminPasswordNotSetError> {
        const result = this.getPasswordHash().flattenOption(
            () => {
                log.warn("Tried to verify password when it is not set");
                return new AdminPasswordNotSetError(
                    "Admin password is not set",
                );
            },
        );

        if (result.isErr()) {
            return errAsync(result.error);
        }

        const hash = result.value;
        return ResultAsync.fromSafePromise(passwd.verify(password, hash));
    }

    public setPassword(
        password: string,
    ): ResultAsync<void, QueryExecutionError> {
        return ResultAsync.fromSafePromise(passwd.hash(password)).andThen(
            (hash) =>
                this.getPasswordHash()
                    .matchOption(
                        () => {
                            this.statements.updatePasswordHash(hash);
                            this.passwordHash = hash;
                        },
                        () => {
                            this.statements.insertPasswordHash(hash);
                            this.passwordHash = hash;
                        },
                    ),
        );
    }

    public readonly sessions = new AdminSessions();
}

class AdminSessions {
    private cachedTokens: Map<string, Token> = new Map();

    private readonly statements = {
        insertSessionToken: db.prepareExec<[token: string]>(
            "INSERT INTO adminSessions (token) VALUES (?)",
        ),
        insertSessionTokenWithExpiry: db.prepareExec<
            [token: string, date: string]
        >("INSERT INTO adminSessions (token, expiresAt) VALUES (?, ?)"),
        fetchSessionByToken: db.prepareFetch<AdminSessionRaw, [token: string]>(
            "SELECT * FROM adminSessions WHERE token = ?",
        ).get,
        deleteSessionById: db.prepareExec<[id: number]>(
            "DELETE FROM adminSessions WHERE id = ?",
        ),
        deleteSessionByToken: db.prepareExec<[token: string]>(
            "DELETE FROM adminSessions WHERE token = ?",
        ),
        deleteAllSessions: db.prepareExec<[]>("DELETE FROM adminSessions"),
        deleteExpiredSessions: db.prepareExec<[]>(
            `DELETE FROM adminSessions 
            WHERE expiresAt < datetime('now')`,
        ),
    };

    constructor() {
        setInterval(() => {
            this.clearExpiredSessions().match(
                (clearedCount) => {
                    if (clearedCount > 0) {
                        console.info(
                            `cleared ${clearedCount} expired token${
                                clearedCount === 1 ? "" : "s"
                            }`,
                        );
                    }
                },
                () => log.error("Failed to clear expired tokens!"),
            );
        }, EXPIRED_TOKENS_DELETION_INTERVAL);
    }

    public create(expiresAt?: Date): Result<string, QueryExecutionError> {
        const token = generateRandomString(TOKEN_LENGTH);

        if (expiresAt) {
            return this.statements
                .insertSessionTokenWithExpiry(token, expiresAt.toISOString())
                .map(() => token);
        }

        const now = new Date();
        const expiresAtDefault = new Date();
        expiresAtDefault.setMinutes(
            now.getMinutes() + DEFAULT_SESSION_EXPIRATION_OFFSET,
        );

        return this.statements
            .insertSessionTokenWithExpiry(token, expiresAtDefault.toISOString())
            .map(() => token);
    }

    public verifyToken(token: string): Result<boolean, QueryExecutionError> {
        const cached = this.cachedTokens.get(token);

        if (cached) {
            if (cached.expiresAt > new Date()) {
                return ok(true);
            }
            this.cachedTokens.delete(token);
        }

        return this.getToken(token).andThen((opt) =>
            opt.match(
                (session) => {
                    if (session.expiresAt > new Date()) {
                        this.cachedTokens.set(token, session);
                        return ok(true);
                    }
                    return this.deleteSessionById(session.id).map(() => false);
                },
                () => ok(false),
            )
        );
    }

    private getToken(
        token: string,
    ): Result<Option<Token>, QueryExecutionError> {
        return this.statements
            .fetchSessionByToken(token)
            .mapOption(({ id, expiresAt }) => {
                return {
                    id,
                    expiresAt: new Date(expiresAt),
                };
            });
    }

    public deleteSessionById(id: number): Result<number, QueryExecutionError> {
        return this.statements.deleteSessionById(id);
    }

    public deleteSessionByToken(
        token: string,
    ): Result<number, QueryExecutionError> {
        return this.statements.deleteSessionByToken(token);
    }

    public deleteAllSessions(): Result<number, QueryExecutionError> {
        return this.statements.deleteAllSessions();
    }

    public clearExpiredSessions(): Result<number, QueryExecutionError> {
        const now = new Date();

        for (const token of this.cachedTokens.keys()) {
            const tokenMeta = this.cachedTokens.get(token);
            if (tokenMeta && tokenMeta.expiresAt < now) {
                this.cachedTokens.delete(token);
            }
        }

        return this.statements.deleteExpiredSessions();
    }
}

interface Token {
    id: number;
    expiresAt: Date;
}

const admin = new Admin();

export default admin;
