/*
 * 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>, January 2021
 */

import { IncomingMessage, ServerResponse } from 'http';
import { to_utf8 } from '@sapphirecode/encoding-helper';
import auth from './Authority';
import { debug } from './debug';
import { build_cookie, CookieSettings } from './cookie';

const logger = debug ('auth');

interface AccessSettings {
  access_token_expires_in: number
  include_refresh_token?: boolean
  refresh_token_expires_in?: number
  redirect_to?: string
  data?: unknown,
  leave_open?: boolean
}

interface AccessResult {
  access_token_id: string;
  refresh_token_id?: string;
}

interface AccessResponse {
  token_type: string;
  access_token: string;
  expires_in: number;
  refresh_token?: string;
  refresh_expires_in?: number;
}

type AuthHandler =
  (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;

class AuthRequest {
  public request: IncomingMessage;
  public response: ServerResponse;

  public is_basic: boolean;
  public user: string;
  public password: string;

  public is_bearer: boolean;
  public token?: string;
  public token_data?: unknown;
  public token_id?: string;

  public body: string;

  private _cookie?: CookieSettings;
  private _refresh_cookie?: CookieSettings;
  private _is_successful: boolean;

  public get is_successful (): boolean {
    return this._is_successful;
  }

  public constructor (
    req: IncomingMessage,
    res: ServerResponse,
    body: string,
    cookie?: CookieSettings,
    refresh_cookie?: CookieSettings
  ) {
    this.request = req;
    this.response = res;
    this.body = body;
    this.is_basic = false;
    this.is_bearer = false;
    this.user = '';
    this.password = '';
    this._cookie = cookie;
    this._refresh_cookie = refresh_cookie;
    this._is_successful = false;
    logger ('started processing new auth request');
  }

  private default_header (set_content = true) {
    this.response.setHeader ('Cache-Control', 'no-store');
    this.response.setHeader ('Pragma', 'no-cache');
    if (set_content)
      this.response.setHeader ('Content-Type', 'application/json');
  }

  // eslint-disable-next-line max-statements, max-lines-per-function
  public async allow_access ({
    access_token_expires_in,
    include_refresh_token,
    refresh_token_expires_in,
    redirect_to,
    data,
    leave_open
  }: AccessSettings): Promise<AccessResult> {
    logger ('allowed access');
    this.default_header (typeof redirect_to !== 'string' && !leave_open);

    const at = await auth.sign (
      'access_token',
      access_token_expires_in,
      { data }
    );
    const result: AccessResult = { access_token_id: at.id };

    const res: AccessResponse = {
      token_type:   'bearer',
      access_token: at.signature,
      expires_in:   access_token_expires_in
    };

    const cookies = [];

    if (typeof this._cookie !== 'undefined')
      cookies.push (build_cookie (this._cookie, at.signature));

    if (include_refresh_token) {
      logger ('including refresh token');
      if (typeof refresh_token_expires_in !== 'number')
        throw new Error ('no expiry time defined for refresh tokens');
      const rt = await auth.sign (
        'refresh_token',
        refresh_token_expires_in,
        { data }
      );
      res.refresh_token = rt.signature;
      res.refresh_expires_in = refresh_token_expires_in;
      result.refresh_token_id = rt.id;

      if (typeof this._refresh_cookie !== 'undefined')
        cookies.push (build_cookie (this._refresh_cookie, rt.signature));
    }

    if (cookies.length > 0) {
      logger ('sending %d cookies', cookies.length);
      this.response.setHeader (
        'Set-Cookie',
        cookies
      );
    }

    this._is_successful = true;

    if (typeof redirect_to === 'string') {
      logger ('redirecting to %s', redirect_to);
      this.response.setHeader ('Location', redirect_to);
      this.response.statusCode = 302;
      if (!leave_open)
        this.response.end ();
      return result;
    }

    if (!leave_open) {
      logger ('finishing http request');
      this.response.writeHead (200);
      this.response.end (JSON.stringify (res));
    }

    return result;
  }

  public async allow_part (
    part_token_expires_in: number,
    next_module: string,
    data?: Record<string, unknown>,
    leave_open = false
  ): Promise<string> {
    logger ('allowed part token');
    this.default_header ();

    const pt = await auth.sign (
      'part_token',
      part_token_expires_in,
      { next_module, data }
    );

    const res = {
      token_type: 'bearer',
      part_token: pt.signature,
      expires_in: part_token_expires_in
    };

    if (!leave_open) {
      logger ('finishing http request');
      this.response.writeHead (200);
      this.response.end (JSON.stringify (res));
    }

    this._is_successful = true;
    return pt.id;
  }

  public invalid (error_description?: string, leave_open = false): void {
    logger ('rejecting invalid request');
    this.default_header ();
    this.response.statusCode = 400;
    if (!leave_open) {
      logger ('finishing http request');
      this.response.end (JSON.stringify ({
        error: 'invalid_request',
        error_description
      }));
    }
  }

  public deny (leave_open = false): void {
    logger ('denied access');
    this.default_header ();
    this.response.statusCode = 401;
    if (!leave_open) {
      logger ('finishing http request');
      this.response.end (JSON.stringify ({ error: 'invalid_client' }));
    }
  }
}

type AuthRequestHandler = (req: AuthRequest) => Promise<void> | void;

interface CreateHandlerOptions {
  refresh?: AccessSettings;
  modules?: Record<string, AuthRequestHandler>;
  cookie?: CookieSettings;
  refresh_cookie?: CookieSettings;
  parse_body?: boolean;
}

type ProcessRequestOptions = Omit<CreateHandlerOptions, 'parse_body'>

// eslint-disable-next-line max-lines-per-function
async function process_request (
  request: AuthRequest,
  token: RegExpExecArray | null,
  default_handler: AuthRequestHandler,
  options?: ProcessRequestOptions
): Promise<void> {
  if (token === null)
    return default_handler (request);

  if ((/Basic/ui).test (token?.groups?.type as string)) {
    logger ('found basic login data');
    request.is_basic = true;

    let login = token?.groups?.token as string;
    if (!login.includes (':'))
      login = to_utf8 (login, 'base64');
    const login_data = login.split (':');
    request.user = login_data[0];
    request.password = login_data[1];

    return default_handler (request);
  }

  if ((/Bearer/ui).test (token?.groups?.type as string)) {
    logger ('found bearer login data');
    request.is_bearer = true;
    request.token = token?.groups?.token;

    const token_data = await auth.verify (request.token as string);

    if (!token_data.valid)
      return default_handler (request);

    logger ('bearer token is valid');

    request.token_data = token_data.data;
    request.token_id = token_data.id;

    if (
      typeof options !== 'undefined'
      && typeof options.refresh !== 'undefined'
      && token_data.type === 'refresh_token'
    ) {
      logger ('found refresh token, emitting new access token');
      request.allow_access (options.refresh);
      return Promise.resolve ();
    }

    if (
      typeof options !== 'undefined'
      && typeof options.modules !== 'undefined'
      && token_data.type === 'part_token'
      && typeof token_data.next_module !== 'undefined'
      && Object.keys (options.modules)
        .includes (token_data.next_module)
    ) {
      logger ('processing module %s', token_data.next_module);
      return options.modules[token_data.next_module] (request);
    }

    request.invalid ('invalid bearer type');
    return Promise.resolve ();
  }

  logger ('no matching login method, triggering default handler');
  return default_handler (request);
}

// eslint-disable-next-line max-lines-per-function
export default function create_auth_handler (
  default_handler: AuthRequestHandler,
  options?: CreateHandlerOptions
): AuthHandler {
  logger ('creating new auth handler');
  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');

  return async (
    req: IncomingMessage,
    res: ServerResponse
  ): Promise<boolean> => {
    const body: string = options?.parse_body
      ? await new Promise ((resolve) => {
        let data = '';
        req.on ('data', (c) => {
          data += c;
        });
        req.on ('end', () => {
          resolve (data);
        });
      })
      : '';

    const request = new AuthRequest (
      req,
      res,
      body,
      options?.cookie,
      options?.refresh_cookie
    );
    const token = (/(?<type>\S+) (?<token>.+)/ui)
      .exec (req.headers.authorization as string);

    await process_request (request, token, default_handler, options);

    return request.is_successful;
  };
}

export {
  AccessSettings,
  AccessResult,
  AccessResponse,
  AuthRequest,
  AuthRequestHandler,
  CreateHandlerOptions,
  AuthHandler
};