import { DateTime } from "luxon";
import { SignJWT } from "jose/jwt/sign";
import { jwtVerify } from "jose/jwt/verify";
import { JWTExpired } from "jose/util/errors";
import { v4 as uuidv4 } from "uuid";
import { GetProviderRealUser, ProviderTokenContract, UserProviderContract } from "@ioc:Adonis/Addons/Auth";
import { BaseGuard } from "@adonisjs/auth/build/src/Guards/Base";
import { EmitterContract } from "@ioc:Adonis/Core/Event";
import { HttpContextContract } from "@ioc:Adonis/Core/HttpContext";
import { string } from "@poppinss/utils/build/helpers";
import { createHash, createPrivateKey, KeyObject } from "crypto";
import { ProviderToken } from "@adonisjs/auth/build/src/Tokens/ProviderToken";
import JwtAuthenticationException from "../Exceptions/JwtAuthenticationException";
import {
    JWTGuardConfig,
    JWTGuardContract,
    JWTLoginOptions,
    JWTCustomPayload,
    JWTCustomPayloadData,
    JWTTokenContract,
    RefreshTokenProviderContract,
    JwtProviderContract,
    JwtProviderTokenContract,
    JWTLogoutOptions,
} from "@ioc:Adonis/Addons/Jwt";
import { JwtProviderToken } from "../ProviderToken/JwtProviderToken";

/**
 * JWT token represents a persisted token generated for a given user.
 *
 * Calling `token.toJSON()` will give you an object, that you can send back
 * as response to share the token with the client.
 */
export class JWTToken implements JWTTokenContract<any> {
    /**
     * The type of the token. Always set to bearer
     */
    public type = "bearer" as const;

    /**
     * The datetime in which the token will expire
     */
    public expiresAt?: DateTime;

    /**
     * Time left until token gets expired
     */
    public expiresIn?: number;

    /**
     * Any meta data attached to the token
     */
    public meta: any;

    /**
     * Hash of the token saved inside the database. Make sure to never share
     * this with the client
     */
    public tokenHash: string;

    constructor(
        public name: string, // Name associated with the token
        public accessToken: string, // The raw token value. Only available for the first time
        public refreshToken: string, // The raw refresh token value. Only available for the first time
        public user: any // The user for which the token is generated
    ) {}

    /**
     * Shareable version of the token
     */
    public toJSON() {
        return {
            type: this.type,
            token: this.accessToken,
            refreshToken: this.refreshToken,
            ...(this.expiresAt ? { expires_at: this.expiresAt.toISO() || undefined } : {}),
            ...(this.expiresIn ? { expires_in: this.expiresIn } : {}),
        };
    }
}

/**
 * Exposes the API to generate and authenticate HTTP request using jwt tokens
 */
export class JWTGuard extends BaseGuard<"jwt"> implements JWTGuardContract<any, "jwt"> {
    private tokenTypes = {
        refreshToken: "jwt_refresh_token",
        jwtToken: "jwt_token",
    } as const;

    /**
     * The payload of the authenticated user
     */
    public payload?: JWTCustomPayloadData;

    /**
     * Reference to the parsed token
     */
    private tokenHash: string | undefined;

    /**
     * Token type for the persistance store
     */
    private tokenType;

    /**
     * constructor of class.
     */
    constructor(
        _name: string,
        public config: JWTGuardConfig<any>,
        private emitter: EmitterContract,
        provider: UserProviderContract<any>,
        private ctx: HttpContextContract,
        public tokenProvider: JwtProviderContract | RefreshTokenProviderContract
    ) {
        super("jwt", config, provider);

        if (this.config.persistJwt) {
            this.tokenType = this.config.tokenProvider.type || this.tokenTypes.jwtToken;
        } else {
            this.tokenType = this.tokenTypes.refreshToken;
        }
    }

    /**
     * Verify user credentials and perform login
     */
    public async attempt(uid: string, password: string, options?: JWTLoginOptions): Promise<any> {
        const user = await this.verifyCredentials(uid, password);
        return this.login(user, options);
    }

    /**
     * Same as [[authenticate]] but returns a boolean over raising exceptions
     */
    public async check(): Promise<boolean> {
        try {
            await this.authenticate();
        } catch (error) {
            /**
             * Throw error when it is not an instance of the authentication
             */
            if (!(error instanceof JwtAuthenticationException) && !(error instanceof JWTExpired)) {
                throw error;
            }

            this.ctx.logger.trace(error, "Authentication failure");
        }

        return this.isAuthenticated;
    }

    /**
     * Authenticates the current HTTP request by checking for the bearer token
     */
    public async authenticate(): Promise<GetProviderRealUser<any>> {
        /**
         * Return early when authentication has already attempted for
         * the current request
         */
        if (this.authenticationAttempted) {
            return this.user;
        }

        this.authenticationAttempted = true;

        /**
         * Ensure the "Authorization" header value exists, and it's a valid JWT
         */
        const token = this.getBearerToken();
        const payload = await this.verifyToken(token);

        let providerToken: JwtProviderToken;
        if (this.config.persistJwt) {
            /**
             * Query token and user if JWT is persisted.
             */
            providerToken = await this.getProviderToken(token);
        }

        const providerUser = await this.getUserById(payload.data!);

        /**
         * Marking user as logged in
         */
        this.markUserAsLoggedIn(providerUser.user, true);
        this.tokenHash = this.generateHash(token);
        this.payload = payload.data!;

        /**
         * Emit authenticate event. It can be used to track user logins.
         */
        this.emitter.emit("adonis:api:authenticate", this.getAuthenticateEventData(providerUser.user, providerToken!));

        return providerUser.user;
    }

    /**
     * Generate token for a user. It is merely an alias for `login`
     */
    public async generate(user: any, options?: JWTLoginOptions): Promise<JWTTokenContract<any>> {
        return this.login(user, options);
    }

    /**
     * Login user using their id
     */
    public async loginViaId(id: string | number, options?: JWTLoginOptions): Promise<any> {
        const providerUser = await this.findById(id);
        return this.login(providerUser.user, options);
    }

    /**
     * Login user using the provided refresh token
     */
    public async loginViaRefreshToken(refreshToken: string, options?: JWTLoginOptions) {
        const user = await this.getUserFromRefreshToken(refreshToken);

        /**
         * Invalidate old refresh token immediately
         */
        if (this.config.persistJwt) {
            await (this.tokenProvider as JwtProviderContract).destroyRefreshToken(
                refreshToken,
                this.tokenTypes.refreshToken
            );
        } else {
            await (this.tokenProvider as RefreshTokenProviderContract).destroyWithHash(refreshToken, this.tokenType);
        }

        return this.login(user, options);
    }

    /**
     * Get user related to provided refresh token
     */
    public async getUserFromRefreshToken(refreshToken: string) {
        let providerToken;
        if (this.config.persistJwt) {
            providerToken = await (this.tokenProvider as JwtProviderContract).readRefreshToken(
                refreshToken,
                this.tokenTypes.refreshToken
            );
        } else {
            providerToken = await this.tokenProvider.read("", refreshToken, this.tokenType);
        }

        if (!providerToken) {
            throw new JwtAuthenticationException("Invalid refresh token");
        }

        const providerUser = await this.findById(providerToken.userId);
        return providerUser.user;
    }

    /**
     * Login a user
     */
    public async login(user: GetProviderRealUser<any>, options?: JWTLoginOptions): Promise<any> {
        /**
         * Normalize options with defaults
         */
        let { expiresIn, refreshTokenExpiresIn, name, payload, ...meta } = Object.assign(
            { name: "JWT Access Token" },
            options
        );

        /**
         * Since the login method is not exposed to the end user, we cannot expect
         * them to instantiate and pass an instance of provider user, so we
         * create one manually.
         */
        const providerUser = await this.getUserForLogin(user, this.config.provider.identifierKey);

        /**
         * "getUserForLogin" raises exception when id is missing, so we can
         * safely assume it is defined
         */
        const userId = providerUser.getId()!;

        if (payload) {
            payload.userId = userId;
        } else {
            payload = {
                userId: userId,
                user: {
                    name: user.name,
                    email: user.email,
                },
            };
        }

        /**
         * Generate a JWT and refresh token
         */
        const tokenInfo = await this.generateTokenForPersistance(expiresIn, refreshTokenExpiresIn, payload);

        let providerToken;
        if (!this.config.persistJwt) {
            /**
             * Persist refresh token ONLY to the database.
             */
            providerToken = new ProviderToken(name, tokenInfo.refreshTokenHash, userId, this.tokenType);
            providerToken.expiresAt = tokenInfo.refreshTokenExpiresAt;
            providerToken.meta = meta;

            await this.tokenProvider.write(providerToken);
        } else {
            /**
             * Persist JWT token and refresh token to the database
             */
            providerToken = new JwtProviderToken(name, tokenInfo.accessTokenHash, userId, this.tokenType);
            providerToken.expiresAt = tokenInfo.expiresAt;
            providerToken.refreshToken = tokenInfo.refreshTokenHash;
            providerToken.refreshTokenExpiresAt = tokenInfo.refreshTokenExpiresAt;
            providerToken.meta = meta;

            await this.tokenProvider.write(providerToken);
        }

        /**
         * Construct a new API Token instance
         */
        const apiToken = new JWTToken(name, tokenInfo.accessToken, tokenInfo.refreshTokenHash, providerUser.user);
        apiToken.tokenHash = tokenInfo.accessTokenHash;
        apiToken.expiresAt = tokenInfo.expiresAt;
        apiToken.meta = meta;

        /**
         * Marking user as logged in
         */
        this.markUserAsLoggedIn(providerUser.user);
        this.payload = payload.data;
        this.tokenHash = tokenInfo.accessTokenHash;

        /**
         * Emit login event. It can be used to track user logins.
         */
        this.emitter.emit("adonis:api:login", this.getLoginEventData(providerUser.user, apiToken));

        return apiToken;
    }

    /**
     * Logout by removing the token from the storage
     */
    public async logout(options?: JWTLogoutOptions): Promise<void> {
        if (!this.authenticationAttempted) {
            await this.check();
        }

        if (this.config.persistJwt) {
            /**
             * Remove JWT token from storage
             */
            await this.tokenProvider.destroyWithHash(this.tokenHash!, this.tokenType);
        } else {
            if (!options || !options.refreshToken) {
                throw new Error("Empty or no refresh token passed");
            }

            /**
             * Revoke/remove refresh token from storage
             */
            await this.tokenProvider.destroyWithHash(options.refreshToken, this.tokenType);
        }

        this.markUserAsLoggedOut();
        this.payload = undefined;
        this.tokenHash = undefined;
    }

    /**
     * Alias for the logout method
     */
    public revoke(options?: JWTLogoutOptions): Promise<void> {
        return this.logout(options);
    }

    /**
     * Serialize toJSON for JSON.stringify
     */
    public toJSON(): any {
        return {
            isLoggedIn: this.isLoggedIn,
            isGuest: this.isGuest,
            authenticationAttempted: this.authenticationAttempted,
            isAuthenticated: this.isAuthenticated,
            user: this.user,
        };
    }

    /**
     * Generates a new access token + refresh token + hash's for the persistance.
     */
    private async generateTokenForPersistance(
        expiresIn?: string | number,
        refreshTokenExpiresIn?: string | number,
        payload: any = {}
    ) {
        if (!expiresIn) {
            expiresIn = this.config.jwtDefaultExpire;
        }
        if (!refreshTokenExpiresIn) {
            refreshTokenExpiresIn = this.config.refreshTokenDefaultExpire;
        }

        let accessTokenBuilder = new SignJWT({ data: payload }).setProtectedHeader({ alg: "RS256" }).setIssuedAt();

        if (this.config.issuer) {
            accessTokenBuilder = accessTokenBuilder.setIssuer(this.config.issuer);
        }
        if (this.config.audience) {
            accessTokenBuilder = accessTokenBuilder.setAudience(this.config.audience);
        }
        if (expiresIn) {
            accessTokenBuilder = accessTokenBuilder.setExpirationTime(expiresIn);
        }

        const accessToken = await accessTokenBuilder.sign(this.generateKey(this.config.privateKey));
        const accessTokenHash = this.generateHash(accessToken);

        const refreshToken = uuidv4();
        const refreshTokenHash = this.generateHash(refreshToken);

        return {
            accessToken,
            accessTokenHash,
            refreshToken,
            refreshTokenHash,
            expiresAt: this.getExpiresAtDate(expiresIn),
            refreshTokenExpiresAt: this.getExpiresAtDate(refreshTokenExpiresIn),
        };
    }

    /**
     * Converts key string to Buffer
     */
    private generateKey(hash: string): KeyObject {
        return createPrivateKey(Buffer.from(hash));
    }

    /**
     * Converts value to a sha256 hash
     */
    private generateHash(token: string) {
        return createHash("sha256").update(token).digest("hex");
    }

    /**
     * Converts expiry duration to an absolute date/time value
     */
    private getExpiresAtDate(expiresIn?: string | number): DateTime | undefined {
        if (!expiresIn) {
            return undefined;
        }

        const milliseconds = typeof expiresIn === "string" ? string.toMs(expiresIn) : expiresIn;
        return DateTime.local().plus({ milliseconds });
    }

    /**
     * Returns the bearer token
     */
    private getBearerToken(): string {
        /**
         * Ensure the "Authorization" header value exists
         */
        const token = this.ctx.request.header("Authorization");
        if (!token) {
            throw new JwtAuthenticationException("No Authorization header passed");
        }

        /**
         * Ensure that token has minimum of two parts and the first
         * part is a constant string named `bearer`
         */
        const [type, value] = token.split(" ");
        if (!type || type.toLowerCase() !== "bearer" || !value) {
            throw new JwtAuthenticationException("Invalid Authorization header value: " + token);
        }

        return value;
    }

    /**
     * Verify the token received in the request.
     */
    private async verifyToken(token: string): Promise<JWTCustomPayload> {
        const secret = this.generateKey(this.config.privateKey);

        const { payload } = await jwtVerify(token, secret, {
            issuer: this.config.issuer,
            audience: this.config.audience,
        });

        const { data, exp }: JWTCustomPayload = payload;

        if (!data) {
            throw new JwtAuthenticationException("Invalid JWT payload");
        }
        if (!data.userId) {
            throw new JwtAuthenticationException("Invalid JWT payload: missing userId");
        }
        if (exp && exp < Math.floor(DateTime.now().toSeconds())) {
            throw new JwtAuthenticationException("Expired JWT token");
        }

        return payload;
    }

    /**
     * Returns the token by reading it from the token provider
     */
    private async getProviderToken(value: string): Promise<JwtProviderTokenContract> {
        const providerToken = await this.tokenProvider.read("", this.generateHash(value), this.tokenType);

        if (!providerToken) {
            throw new JwtAuthenticationException("Invalid JWT token");
        }

        return providerToken as JwtProviderTokenContract;
    }

    /**
     * Returns user from the user session id
     */
    private async getUserById(payloadData: JWTCustomPayloadData) {
        const authenticatable = await this.provider.findById(payloadData.userId);

        if (!authenticatable.user) {
            throw new JwtAuthenticationException("No user found from payload");
        }

        return authenticatable;
    }

    /**
     * Returns data packet for the login event. Arguments are
     *
     * - The mapping identifier
     * - Logged in user
     * - HTTP context
     * - API token
     */
    private getLoginEventData(user: any, token: JWTTokenContract<any>): any {
        return {
            name: this.name,
            ctx: this.ctx,
            user,
            token,
        };
    }

    /**
     * Returns data packet for the authenticate event. Arguments are
     *
     * - The mapping identifier
     * - Logged in user
     * - HTTP context
     * - A boolean to tell if logged in viaRemember or not
     */
    private getAuthenticateEventData(user: any, token?: ProviderTokenContract): any {
        return {
            name: this.name,
            ctx: this.ctx,
            user,
            token,
        };
    }
}