/*
 * Copyright (C) Sapphirecode - All Rights Reserved
 * This file is part of Auth-Server-Helper which is released under MIT.
 * See file 'LICENSE' for full license details.
 * Created by Timo Hocker <timo@scode.ovh>, December 2020
 */

import { IncomingMessage, ServerResponse } from 'http';
import authority from './Authority';
import { AuthRequest, AccessSettings } from './AuthHandler';
import { debug } from './debug';
import { extract_cookie, CookieSettings } from './cookie';
import blacklist from './Blacklist';

const logger = debug ('gateway');

type AnyFunc = (...args: unknown[]) => unknown;
type Gateway = (
  req: IncomingMessage,
  res: ServerResponse,
  next: AnyFunc
) => unknown;

interface RefreshSettings extends AccessSettings {
  leave_open?: never;
  redirect_to?: never;
  data?: never;
}

interface GatewayOptions {
  redirect_url?: string;
  cookie?: CookieSettings;
  refresh_cookie?: CookieSettings;
  refresh_settings?: RefreshSettings;
  require_permissions?: string[];
}

interface ConnectionInfo {
  token_id: string
  token_data: unknown
  permissions: string[]
}

class GatewayClass {
  private _options: GatewayOptions;

  public constructor (options: GatewayOptions = {}) {
    const log = logger.extend ('constructor');
    log ('creating new gateway');
    if (
      typeof options?.cookie !== 'undefined'
      && typeof options?.refresh_cookie !== 'undefined'
      && options.cookie.name === options.refresh_cookie.name
    )
      throw new Error ('access and refresh cookies cannot have the same name');

    this._options = options;
  }

  public deny (res: ServerResponse): void {
    logger.extend ('deny') ('denied http request');
    res.statusCode = 403;
    res.end ();
  }

  public redirect (res: ServerResponse): void {
    const log = logger.extend ('redirect');
    log ('redirecting http request to %s', this._options.redirect_url);
    if (typeof this._options.redirect_url !== 'string') {
      log ('no redirect url defined');
      this.deny (res);
      return;
    }
    res.statusCode = 302;
    res.setHeader ('Location', this._options.redirect_url);
    res.end ();
  }

  public get_header_auth (req: IncomingMessage): string | null {
    const log = logger.extend ('get_header_auth');
    log ('extracting authorization header');
    const auth_header = req.headers.authorization;
    const auth = (/(?<type>\w+) (?<data>.*)/u).exec (auth_header || '');
    if (auth === null)
      return null;
    if (auth.groups?.type !== 'Bearer')
      return null;
    log ('found bearer token');
    return auth.groups?.data;
  }

  public async try_access (req: IncomingMessage): Promise<boolean> {
    const log = logger.extend ('try_access');
    log ('authenticating incoming request');
    let auth = this.get_header_auth (req);
    if (auth === null)
      auth = extract_cookie (this._options.cookie?.name, req.headers.cookie);
    if (auth === null) {
      log ('found no auth token');
      return false;
    }

    const ver = await authority.verify (auth);

    log ('setting connection info');
    const con = req.connection as unknown as Record<string, unknown>;
    con.auth = {
      token_id:    ver.id,
      token_data:  ver.data,
      permissions: ver.permissions
    };

    log ('token valid: %s', ver.authorized);
    return ver.authorized;
  }

  public async try_refresh (
    req: IncomingMessage,
    res: ServerResponse
  ): Promise<boolean> {
    const log = logger.extend ('try_refresh');
    if (
      typeof this._options.refresh_cookie === 'undefined'
      || typeof this._options.refresh_settings === 'undefined'
    )
      return false;

    log ('trying to apply refresh token');

    const refresh = extract_cookie (
      this._options.refresh_cookie.name,
      req.headers.cookie
    );
    if (refresh === null) {
      log ('could not find refresh token');
      return false;
    }

    const ver = await authority.verify (refresh);
    if (ver.type === 'refresh_token' && ver.valid) {
      log ('refresh token valid, generating new tokens');
      const auth_request = new AuthRequest (
        req,
        res,
        '',
        this._options.cookie,
        this._options.refresh_cookie
      );
      const refresh_result = await auth_request.allow_access ({
        ...this._options.refresh_settings,
        data:       ver.data,
        leave_open: true
      });

      log ('setting connection info');
      const con = req.connection as unknown as Record<string, unknown>;
      con.auth = {
        token_id:    refresh_result.access_token_id,
        token_data:  ver.data,
        permissions: ver.permissions
      };

      log ('tokens refreshed');
      return true;
    }

    log ('refresh token invalid');
    return false;
  }

  public async authenticate (
    req: IncomingMessage,
    res: ServerResponse
  ): Promise<boolean> {
    const log = logger.extend ('authenticate');
    log ('trying to authenticate http request');
    if (await this.try_access (req)) {
      log ('authenticated via access_token');
      return true;
    }
    if (await this.try_refresh (req, res)) {
      log ('authenticated via refresh_token');
      return true;
    }

    log ('could not verify session');
    return false;
  }

  public check_permissions (
    req: IncomingMessage,
    permissions = this._options.require_permissions || []
  ): boolean {
    for (const perm of permissions) {
      if (!this.has_permission (req, perm))
        return false;
    }
    return true;
  }

  public has_permission (req: IncomingMessage, permission: string) {
    const info = this.get_info (req);
    return info.permissions.includes (permission);
  }

  public async process_request (
    req: IncomingMessage,
    res: ServerResponse,
    next: AnyFunc
  ): Promise<unknown> {
    const log = logger.extend ('process_request');
    log ('processing incoming http request');
    if (await this.authenticate (req, res)) {
      log ('authentification successful');
      log ('checking permissions');

      if (!this.check_permissions (req))
        return this.redirect (res);

      log ('authorization successful. calling next handler');
      return next ();
    }

    log ('failed to authenticate, redirecting client');
    return this.redirect (res);
  }

  public async logout (req: IncomingMessage): Promise<void> {
    const log = logger.extend ('logout');
    log ('invalidating all submitted tokens');
    const auth_strings = [
      this.get_header_auth (req),
      extract_cookie (this._options.cookie?.name, req.headers.cookie),
      extract_cookie (this._options.refresh_cookie?.name, req.headers.cookie)
    ];
    const tokens = (
      await Promise.all (
        auth_strings
          .filter ((v) => v !== null)
          .map ((v) => authority.verify (v as string))
      )
    ).filter ((v) => v.valid);

    log ('found %d tokens: %O', tokens.length, tokens);

    for (const token of tokens) {
      // eslint-disable-next-line no-await-in-loop
      await blacklist.add_signature (token.id);
    }

    log ('complete');
  }

  public get_info (req: IncomingMessage): ConnectionInfo {
    const conn = req.connection as unknown as Record<string, unknown>;
    const auth = conn.auth as Record<string, unknown>;
    return {
      token_id:    auth.token_id as string,
      token_data:  auth.token_data,
      permissions: (auth.permissions as string[]) || []
    };
  }
}

export default function create_gateway (options: GatewayOptions): Gateway {
  const g = new GatewayClass (options);
  return g.process_request.bind (g);
}

export { AnyFunc, Gateway, GatewayOptions, GatewayClass, RefreshSettings };