/* eslint-disable @typescript-eslint/naming-convention */
import {
    AuthCallback,
    Config,
    IBasicAuth,
    IPluginAuth,
    IPluginMiddleware,
    JWTSignOptions,
    Logger,
    PluginOptions,
} from '@verdaccio/types';
import axios from 'axios';
import crypto from 'crypto';
import { Express, Request } from 'express';
import Cache from 'node-cache';
import { v4 as uuid } from 'uuid';
import { Secrets } from './secrets.js';

declare module '@verdaccio/types' {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    export interface IBasicAuth<T> {
        jwtEncrypt(user: RemoteUser, signOptions: JWTSignOptions): Promise<string>;
    }
}

interface UserInfo {
    sub: string;
    name: string;
    locale: string;
    email: string;
    preferred_username: string;
    given_name: string;
    family_name: string;
    zoneinfo: string;
    updated_at: number;
    email_verified: boolean;
}

interface OktaOAuthConfig extends Config {
    issuer?: string;
    client_id?: string;
    ttl?: number;
}

// eslint-disable-next-line import/no-default-export
export default class OktaOAuth
    implements IPluginAuth<OktaOAuthConfig>, IPluginMiddleware<OktaOAuthConfig>
{
    private issuer: string;
    private clientId: string;

    private cache: Cache;
    private logger: Logger;
    private sign?: JWTSignOptions;
    private storage: string;

    constructor(
        { issuer, client_id, ttl, security, storage }: OktaOAuthConfig,
        { logger }: PluginOptions<OktaOAuthConfig>
    ) {
        if (!issuer) {
            throw new Error('"issuer" should be defined!');
        }

        if (!client_id) {
            throw new Error('"client_id" should be defined!');
        }

        this.issuer = issuer;
        this.clientId = client_id;

        this.cache = new Cache({ stdTTL: ttl ?? 60 * 60 * 24 });
        this.logger = logger;
        this.sign = { expiresIn: '7d', ...(security?.web?.sign ?? {}) };
        this.storage = storage ?? './storage';
    }

    async authenticate(user: string, refreshToken: string, cb: AuthCallback) {
        if (this.cache.has(user)) {
            cb(null, [user]);
            return;
        }

        try {
            const accessToken = await this.getAccessToken(refreshToken);
            const userInfo = await this.getUserInfo(accessToken);
            this.cache.set(user, userInfo);
            cb(null, [user]);
        } catch {
            cb(null, false);
        }
    }

    register_middlewares(app: Express, auth: IBasicAuth<OktaOAuthConfig>) {
        const { clientId, issuer } = this;

        const getBaseUrl = (req: Request) => {
            return `${req.protocol}://${req.get('host')}`;
        };

        const getCallbackUrl = (req: Request) => {
            return `${getBaseUrl(req)}/oauth/callback`;
        };

        const secrets = new Secrets({ cwd: this.storage });

        app.enable('trust proxy');

        app.use((req, res, next) => {
            const originalSend = res.send;
            res.send = body => {
                let html = String(body);

                if (html.includes('__VERDACCIO_BASENAME_UI_OPTIONS')) {
                    const script = [
                        `<script>`,
                        `const keys = ['username', 'token'];`,
                        `const searchParams = new URLSearchParams(window.location.search);`,
                        `if (keys.every(key => searchParams.has(key))) {`,
                        `    for (const key of keys) {`,
                        `        window.localStorage.setItem(key, searchParams.get(key));`,
                        `    }`,
                        `    window.location.href = '${getBaseUrl(req)}';`,
                        `}`,
                        `</script>`,
                    ];
                    html = html.replace(/<\/body>/, script.concat('</body>').join('\n'));
                }

                return originalSend.call(res, html);
            };

            next();
        });

        app.use('/oauth/authorize', (req, res) => {
            const state = uuid();
            const codeVerifier = uuid() + uuid();

            secrets.set(state, codeVerifier);

            res.redirect(
                `${issuer}/oauth2/v1/authorize?${new URLSearchParams({
                    state,
                    client_id: clientId,
                    response_type: 'code',
                    scope: 'openid profile email offline_access',
                    redirect_uri: getCallbackUrl(req),
                    code_challenge_method: 'S256',
                    code_challenge: crypto
                        .createHash('sha256')
                        .update(codeVerifier)
                        .digest('base64')
                        .replace(/\+/g, '-')
                        .replace(/\//g, '_')
                        .replace(/=+$/, ''),
                }).toString()}`
            );
        });

        app.use('/oauth/callback', async (req, res) => {
            const { code, state } = req.query;

            if (typeof code !== 'string') {
                this.logger.error(
                    'OktaOAuth Middleware: "/oauth/authorize" returned invalid "code"!'
                );
                res.status(400).end();
                return;
            }

            if (typeof state !== 'string') {
                this.logger.error(
                    'OktaOAuth Middleware: "/oauth/authorize" returned invalid "state"!'
                );
                res.status(400).end();
                return;
            }

            const codeVerifier = secrets.get(state);

            if (typeof codeVerifier !== 'string') {
                this.logger.error('OktaOAuth Middleware: your "code_verifier" has expired!');
                res.status(440).end();
                return;
            }

            let accessToken: string, refreshToken: string;
            try {
                ({ access_token: accessToken, refresh_token: refreshToken } = (
                    await axios.post(
                        `${issuer}/oauth2/v1/token`,
                        new URLSearchParams({
                            client_id: clientId,
                            grant_type: 'authorization_code',
                            redirect_uri: getCallbackUrl(req),
                            code_verifier: codeVerifier,
                            code,
                        }).toString()
                    )
                ).data);
            } catch {
                this.logger.error('OktaOAuth Middleware: "token" request failed!');
                res.status(500).end();
                return;
            }

            let userInfo: UserInfo;
            try {
                userInfo = await this.getUserInfo(accessToken);
            } catch {
                this.logger.error('OktaOAuth Middleware: "userinfo" request failed!');
                res.status(500).end();
                return;
            }

            const username = userInfo.name;
            const groups = [username];
            const defaultLoggedUserRoles = [
                '$all',
                '$authenticated',
                '@all',
                '@authenticated',
                'all',
            ];

            res.redirect(
                `http://localhost:8239?${new URLSearchParams({
                    username,
                    jwt_token: await auth.jwtEncrypt(
                        {
                            name: username,
                            groups: [...groups, ...defaultLoggedUserRoles],
                            real_groups: groups,
                        },
                        this.sign ?? {}
                    ),
                    npm_token: auth
                        .aesEncrypt(Buffer.from(`${username}:${refreshToken}`))
                        .toString('base64'),
                    redirect_uri: getBaseUrl(req),
                }).toString()}`
            );
        });
    }

    private getUserInfo = async (accessToken: string) => {
        const { issuer } = this;

        return (
            await axios.get<UserInfo>(`${issuer}/oauth2/v1/userinfo`, {
                headers: { Authorization: `Bearer ${accessToken}` },
            })
        ).data;
    };

    private getAccessToken = async (refreshToken: string) => {
        const { clientId, issuer } = this;

        return (
            await axios.post(
                `${issuer}/oauth2/v1/token`,
                new URLSearchParams({
                    client_id: clientId,
                    grant_type: 'refresh_token',
                    refresh_token: refreshToken,
                }).toString()
            )
        ).data.access_token;
    };
}
