import { errAsync, okAsync, ResultAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context";
import { hashString, verifyHash } from "@/core/hash.utils";
import { twoFactor, twofaSessions } from "@pkg/db/schema";
import { TwoFactor, type TwoFaSession } from "./data";
import { crypto } from "@otplib/plugin-crypto-noble";
import { base32 } from "@otplib/plugin-base32-scure";
import { and, Database, eq, gt, lt } from "@pkg/db";
import { generate, verify } from "@otplib/totp";
import { settings } from "@core/settings";
import type { Err } from "@pkg/result";
import { twofaErrors } from "./errors";
import { Redis } from "@pkg/keystore";
import { logDomainEvent, logger } from "@pkg/logger";
import { nanoid } from "nanoid";

type TwoFaSetup = {
    secret: string;
    lastUsedCode: string;
    tries: number;
};

export class TwofaRepository {
    private PENDING_KEY_PREFIX = "pending_enabling_2fa:";
    private EXPIRY_TIME = 60 * 20; // 20 mins
    private DEFAULT_BACKUP_CODES_AMT = 8;
    private MAX_SETUP_ATTEMPTS = 3;

    constructor(
        private db: Database,
        private store: Redis,
    ) {}

    checkTotp(secret: string, code: string) {
        const checked = verify({ secret, token: code, crypto, base32 });
        logger.debug("TOTP check result", { checked });
        return checked;
    }

    async checkBackupCode(hash: string, code: string) {
        return verifyHash({ hash, target: code });
    }

    private getKey(userId: string) {
        if (userId.includes(this.PENDING_KEY_PREFIX)) {
            return userId;
        }
        return `${this.PENDING_KEY_PREFIX}${userId}`;
    }

    getUsers2FAInfo(
        fctx: FlowExecCtx,
        userId: string,
        returnUndefined?: boolean,
    ): ResultAsync<TwoFactor | undefined, Err> {
        const startedAt = Date.now();
        logDomainEvent({
            event: "security.twofa.get_info.started",
            fctx,
            meta: { userId },
        });

        return ResultAsync.fromPromise(
            this.db.query.twoFactor.findFirst({
                where: eq(twoFactor.userId, userId),
            }),
            (error) => {
                logDomainEvent({
                    level: "error",
                    event: "security.twofa.get_info.failed",
                    fctx,
                    durationMs: Date.now() - startedAt,
                    error,
                    meta: { userId },
                });
                return twofaErrors.dbError(fctx, "Failed to query 2FA info");
            },
        ).andThen((found) => {
            if (!found) {
                logDomainEvent({
                    level: "warn",
                    event: "security.twofa.get_info.failed",
                    fctx,
                    durationMs: Date.now() - startedAt,
                    error: { code: "NOT_FOUND", message: "2FA info not found" },
                    meta: { userId },
                });
                if (returnUndefined) {
                    return okAsync(undefined);
                }
                return errAsync(twofaErrors.notFound(fctx));
            }
            logDomainEvent({
                event: "security.twofa.get_info.succeeded",
                fctx,
                durationMs: Date.now() - startedAt,
                meta: { userId },
            });
            return okAsync(found as TwoFactor);
        });
    }

    isSetupPending(
        fctx: FlowExecCtx,
        userId: string,
    ): ResultAsync<boolean, Err> {
        logger.debug("Checking if 2FA setup is pending", { ...fctx, userId });

        return ResultAsync.fromPromise(
            this.store.get(this.getKey(userId)),
            () =>
                twofaErrors.dbError(
                    fctx,
                    "Failed to check setup pending status",
                ),
        ).map((found) => {
            const isPending = !!found;
            logger.debug("Setup pending status checked", {
                ...fctx,
                userId,
                isPending,
            });
            return isPending;
        });
    }

    setup(
        fctx: FlowExecCtx,
        userId: string,
        secret: string,
    ): ResultAsync<string, Err> {
        const startedAt = Date.now();
        logDomainEvent({
            event: "security.twofa.setup.started",
            fctx,
            meta: { userId },
        });

        return ResultAsync.fromSafePromise(
            (async () => {
                const token = await generate({
                    secret,
                    crypto,
                    base32,
                });
                const payload = {
                    secret: token,
                    lastUsedCode: "",
                    tries: 0,
                } as TwoFaSetup;
                await this.store.setex(
                    this.getKey(userId),
                    this.EXPIRY_TIME,
                    JSON.stringify(payload),
                );
                logDomainEvent({
                    event: "security.twofa.setup.succeeded",
                    fctx,
                    durationMs: Date.now() - startedAt,
                    meta: { userId, expiresInSec: this.EXPIRY_TIME },
                });
                return secret;
            })(),
        ).mapErr((error) => {
            logDomainEvent({
                level: "error",
                event: "security.twofa.setup.failed",
                fctx,
                durationMs: Date.now() - startedAt,
                error,
                meta: { userId },
            });
            return twofaErrors.dbError(fctx, "Setting to data store failed");
        });
    }

    verifyAndEnable2FA(
        fctx: FlowExecCtx,
        userId: string,
        code: string,
    ): ResultAsync<boolean, Err> {
        const startedAt = Date.now();
        logDomainEvent({
            event: "security.twofa.verify_enable.started",
            fctx,
            meta: { userId },
        });

        return ResultAsync.fromPromise(
            this.store.get(this.getKey(userId)),
            () => twofaErrors.dbError(fctx, "Failed to get setup session"),
        )
            .andThen((payload) => {
                if (!payload) {
                    logDomainEvent({
                        level: "warn",
                        event: "security.twofa.verify_enable.failed",
                        fctx,
                        durationMs: Date.now() - startedAt,
                        error: {
                            code: "SETUP_NOT_FOUND",
                            message: "2FA setup session not found",
                        },
                        meta: { userId },
                    });
                    return errAsync(twofaErrors.setupNotFound(fctx));
                }
                return okAsync(JSON.parse(payload) as TwoFaSetup);
            })
            .andThen((payloadObj) => {
                const key = this.getKey(userId);

                if (payloadObj.tries >= this.MAX_SETUP_ATTEMPTS) {
                    logDomainEvent({
                        level: "warn",
                        event: "security.twofa.verify_enable.failed",
                        fctx,
                        durationMs: Date.now() - startedAt,
                        error: {
                            code: "MAX_ATTEMPTS_REACHED",
                            message: "Max setup attempts reached",
                        },
                        meta: { userId, attempts: payloadObj.tries },
                    });
                    return ResultAsync.fromPromise(this.store.del(key), () =>
                        twofaErrors.dbError(
                            fctx,
                            "Failed to delete setup session",
                        ),
                    ).andThen(() =>
                        errAsync(twofaErrors.maxAttemptsReached(fctx)),
                    );
                }

                if (
                    !this.checkTotp(payloadObj.secret, code) ||
                    code === payloadObj.lastUsedCode
                ) {
                    logDomainEvent({
                        level: "warn",
                        event: "security.twofa.verify_enable.failed",
                        fctx,
                        durationMs: Date.now() - startedAt,
                        error: {
                            code: "INVALID_CODE",
                            message: "Invalid or replayed setup code",
                        },
                        meta: {
                            userId,
                            attempts: payloadObj.tries + 1,
                            codeReused: code === payloadObj.lastUsedCode,
                        },
                    });
                    return ResultAsync.fromPromise(
                        this.store.setex(
                            key,
                            this.EXPIRY_TIME,
                            JSON.stringify({
                                secret: payloadObj.secret,
                                lastUsedCode: code,
                                tries: payloadObj.tries + 1,
                            }),
                        ),
                        () =>
                            twofaErrors.dbError(
                                fctx,
                                "Failed to update setup session",
                            ),
                    ).map(() => false);
                }

                logger.info("2FA code verified successfully, enabling 2FA", {
                    ...fctx,
                    userId,
                });

                return ResultAsync.fromPromise(this.store.del(key), () =>
                    twofaErrors.dbError(fctx, "Failed to delete setup session"),
                )
                    .andThen(() =>
                        ResultAsync.fromPromise(
                            this.db
                                .insert(twoFactor)
                                .values({
                                    id: nanoid(),
                                    secret: payloadObj.secret,
                                    userId: userId,
                                    createdAt: new Date(),
                                    updatedAt: new Date(),
                                })
                                .execute(),
                            () =>
                                twofaErrors.dbError(
                                    fctx,
                                    "Failed to insert 2FA record",
                                ),
                        ),
                    )
                    .map(() => {
                        logDomainEvent({
                            event: "security.twofa.verify_enable.succeeded",
                            fctx,
                            durationMs: Date.now() - startedAt,
                            meta: { userId },
                        });
                        return true;
                    });
            });
    }

    disable(fctx: FlowExecCtx, userId: string): ResultAsync<boolean, Err> {
        logger.info("Disabling 2FA", { ...fctx, userId });

        return ResultAsync.fromPromise(
            this.db
                .delete(twoFactor)
                .where(eq(twoFactor.userId, userId))
                .execute(),
            () => twofaErrors.dbError(fctx, "Failed to delete 2FA record"),
        ).map((result) => {
            logger.info("2FA disabled successfully", { ...fctx, userId });
            return true;
        });
    }

    generateBackupCodes(
        fctx: FlowExecCtx,
        userId: string,
    ): ResultAsync<string[], Err> {
        logger.info("Generating backup codes", { ...fctx, userId });

        return ResultAsync.fromPromise(
            this.db.query.twoFactor.findFirst({
                where: eq(twoFactor.userId, userId),
            }),
            () => twofaErrors.dbError(fctx, "Failed to query 2FA info"),
        )
            .andThen((found) => {
                if (!found) {
                    logger.error("2FA not enabled for user", {
                        ...fctx,
                        userId,
                    });
                    return errAsync(twofaErrors.backupCodesNotFound(fctx));
                }
                if (found.backupCodes && found.backupCodes.length) {
                    logger.warn("Backup codes already generated", {
                        ...fctx,
                        userId,
                    });
                    return errAsync(
                        twofaErrors.backupCodesAlreadyGenerated(fctx),
                    );
                }
                return okAsync(found);
            })
            .andThen(() => {
                const codes = Array.from(
                    { length: this.DEFAULT_BACKUP_CODES_AMT },
                    () => nanoid(12),
                );

                logger.debug("Backup codes generated, hashing", {
                    ...fctx,
                    userId,
                    count: codes.length,
                });

                return ResultAsync.fromPromise(
                    (async () => {
                        const hashed = [];
                        for (const code of codes) {
                            const hash = await hashString(code);
                            hashed.push(hash);
                        }
                        return { codes, hashed };
                    })(),
                    () =>
                        twofaErrors.dbError(
                            fctx,
                            "Failed to hash backup codes",
                        ),
                ).andThen(({ codes, hashed }) =>
                    ResultAsync.fromPromise(
                        this.db
                            .update(twoFactor)
                            .set({ backupCodes: hashed })
                            .where(eq(twoFactor.userId, userId))
                            .returning(),
                        () =>
                            twofaErrors.dbError(
                                fctx,
                                "Failed to update backup codes",
                            ),
                    ).map(() => {
                        logger.info("Backup codes generated successfully", {
                            ...fctx,
                            userId,
                        });
                        return codes;
                    }),
                );
            });
    }

    get2FASecret(
        fctx: FlowExecCtx,
        userId: string,
    ): ResultAsync<string | null, Err> {
        logger.debug("Getting 2FA secret", { ...fctx, userId });

        return ResultAsync.fromPromise(
            this.db
                .select()
                .from(twoFactor)
                .where(eq(twoFactor.userId, userId))
                .limit(1),
            () => twofaErrors.dbError(fctx, "Failed to query 2FA secret"),
        ).map((result) => {
            if (!result.length) {
                logger.debug("No 2FA secret found", { ...fctx, userId });
                return null;
            }
            logger.debug("2FA secret retrieved", { ...fctx, userId });
            return result[0].secret;
        });
    }

    createSession(
        fctx: FlowExecCtx,
        params: {
            userId: string;
            sessionId: string;
            ipAddress?: string;
            userAgent?: string;
        },
    ): ResultAsync<TwoFaSession, Err> {
        const startedAt = Date.now();
        logDomainEvent({
            event: "security.twofa.create_session.started",
            fctx,
            meta: { userId: params.userId, sessionId: params.sessionId },
        });

        return ResultAsync.fromSafePromise(
            (async () => {
                const expiryMinutes = settings.twofaSessionExpiryMinutes || 10;
                const now = new Date();
                const expiresAt = new Date(
                    now.getTime() + expiryMinutes * 60 * 1000,
                );

                return { expiresAt, now, params };
            })(),
        ).andThen(({ expiresAt, now, params }) =>
            ResultAsync.fromPromise(
                this.db
                    .insert(twofaSessions)
                    .values({
                        id: nanoid(),
                        userId: params.userId,
                        sessionId: params.sessionId,
                        verificationToken: nanoid(32),
                        status: "pending",
                        attempts: 0,
                        maxAttempts: 5,
                        expiresAt,
                        createdAt: now,
                        ipAddress: params.ipAddress,
                        userAgent: params.userAgent,
                    })
                    .returning(),
                (error) => {
                    logDomainEvent({
                        level: "error",
                        event: "security.twofa.create_session.failed",
                        fctx,
                        durationMs: Date.now() - startedAt,
                        error,
                        meta: { userId: params.userId },
                    });
                    return twofaErrors.dbError(
                        fctx,
                        "Failed to create 2FA session",
                    );
                },
            ).map(([session]) => {
                logDomainEvent({
                    event: "security.twofa.create_session.succeeded",
                    fctx,
                    durationMs: Date.now() - startedAt,
                    meta: {
                        twofaSessionId: session.id,
                        userId: params.userId,
                    },
                });
                return session as TwoFaSession;
            }),
        );
    }

    getSessionByToken(
        fctx: FlowExecCtx,
        token: string,
    ): ResultAsync<TwoFaSession | null, Err> {
        const startedAt = Date.now();
        logDomainEvent({
            level: "debug",
            event: "security.twofa.get_session.started",
            fctx,
        });

        return ResultAsync.fromPromise(
            this.db
                .select()
                .from(twofaSessions)
                .where(
                    and(
                        eq(twofaSessions.verificationToken, token),
                        gt(twofaSessions.expiresAt, new Date()),
                    ),
                )
                .limit(1),
            (error) => {
                logDomainEvent({
                    level: "error",
                    event: "security.twofa.get_session.failed",
                    fctx,
                    durationMs: Date.now() - startedAt,
                    error,
                });
                return twofaErrors.dbError(fctx, "Failed to query 2FA session");
            },
        ).map((result) => {
            if (!result.length) {
                logDomainEvent({
                    level: "warn",
                    event: "security.twofa.get_session.failed",
                    fctx,
                    durationMs: Date.now() - startedAt,
                    error: {
                        code: "SESSION_NOT_FOUND",
                        message: "2FA session not found or expired",
                    },
                });
                return null;
            }
            logDomainEvent({
                level: "debug",
                event: "security.twofa.get_session.succeeded",
                fctx,
                durationMs: Date.now() - startedAt,
                meta: { twofaSessionId: result[0].id },
            });
            return result[0] as TwoFaSession;
        });
    }

    updateSession(
        fctx: FlowExecCtx,
        id: string,
        updates: Partial<
            Pick<
                TwoFaSession,
                "status" | "attempts" | "verifiedAt" | "codeUsed"
            >
        >,
    ): ResultAsync<TwoFaSession, Err> {
        logger.debug("Updating 2FA session", {
            ...fctx,
            sessionId: id,
            updates,
        });

        return ResultAsync.fromPromise(
            this.db
                .update(twofaSessions)
                .set(updates)
                .where(eq(twofaSessions.id, id))
                .returning(),
            () => twofaErrors.dbError(fctx, "Failed to update 2FA session"),
        ).andThen(([session]) => {
            if (!session) {
                logger.error("2FA session not found for update", {
                    ...fctx,
                    sessionId: id,
                });
                return errAsync(twofaErrors.sessionNotFoundById(fctx));
            }
            logger.debug("2FA session updated successfully", {
                ...fctx,
                sessionId: id,
            });
            return okAsync(session as TwoFaSession);
        });
    }

    incrementAttempts(
        fctx: FlowExecCtx,
        id: string,
    ): ResultAsync<TwoFaSession, Err> {
        logger.debug("Incrementing session attempts", {
            ...fctx,
            sessionId: id,
        });

        return ResultAsync.fromPromise(
            this.db.query.twofaSessions.findFirst({
                where: eq(twofaSessions.id, id),
                columns: { id: true, attempts: true },
            }),
            () =>
                twofaErrors.dbError(
                    fctx,
                    "Failed to query session for increment",
                ),
        )
            .andThen((s) => {
                if (!s) {
                    logger.error("Session not found for increment", {
                        ...fctx,
                        sessionId: id,
                    });
                    return errAsync(twofaErrors.sessionNotFoundById(fctx));
                }
                return okAsync(s);
            })
            .andThen((s) =>
                ResultAsync.fromPromise(
                    this.db
                        .update(twofaSessions)
                        .set({ attempts: s.attempts + 1 })
                        .where(eq(twofaSessions.id, id))
                        .returning(),
                    () =>
                        twofaErrors.dbError(
                            fctx,
                            "Failed to increment attempts",
                        ),
                ).andThen(([session]) => {
                    if (!session) {
                        logger.error("Session not found after increment", {
                            ...fctx,
                            sessionId: id,
                        });
                        return errAsync(twofaErrors.sessionNotFoundById(fctx));
                    }

                    logger.warn("Failed verification attempt", {
                        ...fctx,
                        sessionId: session.id,
                        attempts: session.attempts,
                    });

                    return okAsync(session as TwoFaSession);
                }),
            );
    }

    cleanupExpiredSessions(fctx: FlowExecCtx): ResultAsync<number, Err> {
        const startedAt = Date.now();
        logDomainEvent({
            event: "security.twofa.cleanup_expired.started",
            fctx,
        });

        return ResultAsync.fromPromise(
            this.db
                .delete(twofaSessions)
                .where(lt(twofaSessions.expiresAt, new Date())),
            (error) => {
                logDomainEvent({
                    level: "error",
                    event: "security.twofa.cleanup_expired.failed",
                    fctx,
                    durationMs: Date.now() - startedAt,
                    error,
                });
                return twofaErrors.dbError(
                    fctx,
                    "Failed to cleanup expired sessions",
                );
            },
        ).map((result) => {
            const count = result.length || 0;
            logDomainEvent({
                event: "security.twofa.cleanup_expired.succeeded",
                fctx,
                durationMs: Date.now() - startedAt,
                meta: { count },
            });
            return count;
        });
    }
}
