import type { OAuth2Token } from './token.ts';
import type {
  AuthorizationCodeRequest,
  ClientCredentialsRequest,
  IntrospectionRequest,
  IntrospectionResponse,
  PasswordRequest,
  OAuth2TokenTypeHint,
  RefreshRequest,
  RevocationRequest,
  ServerMetadataResponse,
  TokenResponse,
} from './messages.ts';
import { OAuth2HttpError } from './error.ts';
import { OAuth2AuthorizationCodeClient } from './client/authorization-code.ts';


type ClientCredentialsParams = {
  scope?: string[];
  extraParams?: Record<string, string>;

  /**
   * The resource  the client intends to access.
   *
   * @see https://datatracker.ietf.org/doc/html/rfc8707
   */
  resource?: string | string[];
}

type PasswordParams = {
  username: string;
  password: string;

  scope?: string[];

  /**
   * The resource  the client intends to access.
   *
   * @see https://datatracker.ietf.org/doc/html/rfc8707
   */
  resource?: string | string[];

}

/**
 * Extra options that may be passed to refresh()
 */
type RefreshParams = {
  scope?: string[];

  /**
   * The resource  the client intends to access.
   *
   * @see https://datatracker.ietf.org/doc/html/rfc8707
   */
  resource?: string | string[];

}

export interface ClientSettings {

  /**
   * The hostname of the OAuth2 server.
   * If provided, we'll attempt to discover all the other related endpoints.
   *
   * If this is not desired, just specify the other endpoints manually.
   *
   * This url will also be used as the base URL for all other urls. This lets
   * you specify all the other urls as relative.
   */
  server?: string;

  /**
   * OAuth2 clientId
   */
  clientId: string;

  /**
   * OAuth2 clientSecret
   *
   * This is required when using the 'client_secret_basic' authenticationMethod
   * for the client_credentials and password flows, but not authorization_code
   * or implicit.
   */
  clientSecret?: string;

  /**
   * The /authorize endpoint.
   *
   * Required only for the browser-portion of the authorization_code flow.
   */
  authorizationEndpoint?: string;

  /**
   * The token endpoint.
   *
   * Required for most grant types and refreshing tokens.
   */
  tokenEndpoint?: string;

  /**
   * Introspection endpoint.
   *
   * Required for, well, introspecting tokens.
   * If not provided we'll try to discover it, or otherwise default to /introspect
   */
  introspectionEndpoint?: string;

  /**
   * Revocation endpoint.
   *
   * Required for revoking tokens. Not supported by all servers.
   * If not provided we'll try to discover it, or otherwise default to /revoke
   */
  revocationEndpoint?: string;

  /**
   * OAuth 2.0 Authorization Server Metadata endpoint or OpenID
   * Connect Discovery 1.0 endpoint.
   *
   * If this endpoint is provided it can be used to automatically figure
   * out all the other endpoints.
   *
   * Usually the URL for this is: https://server/.well-known/oauth-authorization-server
   */
  discoveryEndpoint?: string;

  /**
   * Fetch implementation to use.
   *
   * Set this if you wish to explicitly set the fetch implementation, e.g. to
   * implement middlewares or set custom headers.
   */
  fetch?: typeof fetch;

  /**
   * Client authentication method that is used to authenticate
   * when using the token endpoint.
   *
   * When 'client_secret_basic' is used, the client_id and client_secret are
   * encoded in the Authorization header, as per RFC 6749 section 2.3.1. This
   * uses the official encoding, which also percent-encodes special characters.
   *
   * Many popular servers don't expect this, despite being the standard. So we
   * also support 'client_secret_basic_interop', which does not percent-encode
   * special characters except ":". This is 'interop' encoding is the default
   * for this library to maximize compatibility.
   *
   * In the future, we will switch this to 'client_secret_post', which has fewer
   * interopability issues. This setting causes the client to provide the
   * client_id and secret in the POST body.
   *
   * The default value is 'client_secret_basic' if not provided.
   */
  authenticationMethod?: 'client_secret_basic' | 'client_secret_post' | 'client_secret_basic_interop';

}


type OAuth2Endpoint = 'tokenEndpoint' | 'authorizationEndpoint' | 'discoveryEndpoint' | 'introspectionEndpoint' | 'revocationEndpoint';

export class OAuth2Client {

  settings: ClientSettings;

  constructor(clientSettings: ClientSettings) {

    if (!clientSettings?.fetch) {
      clientSettings.fetch = fetch.bind(globalThis);
    }
    this.settings = clientSettings;

  }

  /**
   * Refreshes an existing token, and returns a new one.
   */
  async refreshToken(token: OAuth2Token, params?: RefreshParams): Promise<OAuth2Token> {

    if (!token.refreshToken) {
      throw new Error('This token didn\'t have a refreshToken. It\'s not possible to refresh this');
    }

    const body: RefreshRequest = {
      grant_type: 'refresh_token',
      refresh_token: token.refreshToken,
    };
    if (!this.settings.clientSecret) {
      // If there's no secret, send the clientId in the body.
      body.client_id = this.settings.clientId;
    }

    if (params?.scope) body.scope = params.scope.join(' ');
    if (params?.resource) body.resource = params.resource;

    const newToken = await this.tokenResponseToOAuth2Token(this.request('tokenEndpoint', body));
    if (!newToken.refreshToken && token.refreshToken) {
      // Reuse old refresh token if we didn't get a new one.
      newToken.refreshToken = token.refreshToken;
    }
    return newToken;

  }

  /**
   * Retrieves an OAuth2 token using the client_credentials grant.
   */
  async clientCredentials(params?: ClientCredentialsParams): Promise<OAuth2Token> {

    const disallowed = ['client_id', 'client_secret', 'grant_type', 'scope'];

    if (params?.extraParams && Object.keys(params.extraParams).filter((key) => disallowed.includes(key)).length > 0) {
      throw new Error(`The following extraParams are disallowed: '${disallowed.join("', '")}'`);
    }

    const body: ClientCredentialsRequest = {
      grant_type: 'client_credentials',
      scope: params?.scope?.join(' '),
      resource: params?.resource,
      ...params?.extraParams
    };

    if (!this.settings.clientSecret) {
      throw new Error('A clientSecret must be provided to use client_credentials');
    }

    return this.tokenResponseToOAuth2Token(this.request('tokenEndpoint', body));

  }

  /**
   * Retrieves an OAuth2 token using the 'password' grant'.
   */
  async password(params: PasswordParams): Promise<OAuth2Token> {

    const body: PasswordRequest = {
      grant_type: 'password',
      ...params,
      scope: params.scope?.join(' '),
    };
    return this.tokenResponseToOAuth2Token(this.request('tokenEndpoint', body));

  }

  /**
   * Returns the helper object for the `authorization_code` grant.
   */
  get authorizationCode(): OAuth2AuthorizationCodeClient {

    return new OAuth2AuthorizationCodeClient(
      this,
    );

  }

  /**
   * Introspect a token
   *
   * This will give information about the validity, owner, which client
   * created the token and more.
   *
   * @see https://datatracker.ietf.org/doc/html/rfc7662
   */
  async introspect(token: OAuth2Token): Promise<IntrospectionResponse> {

    const body: IntrospectionRequest = {
      token: token.accessToken,
      token_type_hint: 'access_token',
    };
    return this.request('introspectionEndpoint', body);

  }

  /**
   * Revoke a token
   *
   * This will revoke a token, provided that the server supports this feature.
   *
   * @see https://datatracker.ietf.org/doc/html/rfc7009
   */
  async revoke(token: OAuth2Token, tokenTypeHint: OAuth2TokenTypeHint = 'access_token'): Promise<void> {
    let tokenValue = token.accessToken;
    if (tokenTypeHint === 'refresh_token') {
      tokenValue = token.refreshToken!;
    }

    const body: RevocationRequest = {
      token: tokenValue,
      token_type_hint: tokenTypeHint,
    };
    return this.request('revocationEndpoint', body);

  }

  /**
   * Returns a url for an OAuth2 endpoint.
   *
   * Potentially fetches a discovery document to get it.
   */
  async getEndpoint(endpoint: OAuth2Endpoint): Promise<string> {

    if (this.settings[endpoint] !== undefined) {
      return resolve(this.settings[endpoint] as string, this.settings.server);
    }

    if (endpoint !== 'discoveryEndpoint') {
      // This condition prevents infinite loops.
      await this.discover();
      if (this.settings[endpoint] !== undefined) {
        return resolve(this.settings[endpoint] as string, this.settings.server);
      }
    }

    // If we got here it means we need to 'guess' the endpoint.
    if (!this.settings.server) {
      throw new Error(`Could not determine the location of ${endpoint}. Either specify ${endpoint} in the settings, or the "server" endpoint to let the client discover it.`);
    }

    switch (endpoint) {
      case 'authorizationEndpoint':
        return resolve('/authorize', this.settings.server);
      case 'tokenEndpoint':
        return resolve('/token', this.settings.server);
      case 'discoveryEndpoint':
        return resolve('/.well-known/oauth-authorization-server', this.settings.server);
      case 'introspectionEndpoint':
        return resolve('/introspect', this.settings.server);
      case 'revocationEndpoint':
        return resolve('/revoke', this.settings.server);
    }

  }

  private discoveryDone = false;
  private serverMetadata: ServerMetadataResponse | null = null;


  /**
   * Fetches the OAuth2 discovery document
   */
  private async discover(): Promise<void> {

    // Never discover twice
    if (this.discoveryDone) return;
    this.discoveryDone = true;

    let discoverUrl;
    try {
      discoverUrl = await this.getEndpoint('discoveryEndpoint');
    } catch (_err) {
      console.warn('[oauth2] OAuth2 discovery endpoint could not be determined. Either specify the "server" or "discoveryEndpoint');
      return;
    }
    const resp = await this.settings.fetch!(discoverUrl, { headers: { Accept: 'application/json' }});

    if (!resp.ok) return;
    if (!resp.headers.get('Content-Type')?.startsWith('application/json')) {
      console.warn('[oauth2] OAuth2 discovery endpoint was not a JSON response. Response is ignored');
      return;
    }
    this.serverMetadata = await resp.json();

    const urlMap = [
      ['authorization_endpoint', 'authorizationEndpoint'],
      ['token_endpoint', 'tokenEndpoint'],
      ['introspection_endpoint', 'introspectionEndpoint'],
      ['revocation_endpoint', 'revocationEndpoint'],
    ] as const;

    if (this.serverMetadata === null) return;

    for (const [property, setting] of urlMap) {
      if (!this.serverMetadata[property]) continue;
      this.settings[setting] = resolve(this.serverMetadata[property]!, discoverUrl);
    }

    if (
      this.serverMetadata.token_endpoint_auth_methods_supported
      && !this.settings.authenticationMethod
    ) {
      for(const method of this.serverMetadata.token_endpoint_auth_methods_supported) {
        if (method === 'client_secret_basic' || method === 'client_secret_post') {
          this.settings.authenticationMethod = method;
          break;
        }
      }
    }

  }

  /**
   * Does a HTTP request on the 'token' endpoint.
   */
  async request(endpoint: 'tokenEndpoint', body: RefreshRequest | ClientCredentialsRequest | PasswordRequest | AuthorizationCodeRequest): Promise<TokenResponse>;
  async request(endpoint: 'introspectionEndpoint', body: IntrospectionRequest): Promise<IntrospectionResponse>;
  async request(endpoint: 'revocationEndpoint', body: RevocationRequest): Promise<void>;
  async request(endpoint: OAuth2Endpoint, body: Record<string, any>): Promise<unknown> {

    const uri = await this.getEndpoint(endpoint);

    const headers: Record<string, string> = {
      'Content-Type': 'application/x-www-form-urlencoded',
      // Although it shouldn't be needed, Github OAUth2 will return JSON
      // unless this is set.
      'Accept': 'application/json',
    };

    let authMethod = this.settings.authenticationMethod;

    if (!this.settings.clientSecret) {
      // Basic auth should only be used when there's a client_secret, for
      // non-confidential clients we may only have a client_id, which
      // always gets added to the body.
      authMethod = 'client_secret_post';
    }
    if (!authMethod) {
      // If we got here, it means no preference was provided by anything,
      // and we have a secret. In this case its preferred to embed
      // authentication in the Authorization header.
      authMethod = 'client_secret_basic_interop';
    }

    switch(authMethod) {
      case 'client_secret_basic' :
        // Per RFC 6749 section 2.3.1, the client_id and client_secret need
        // to be encoded using application/x-www-form-urlencoded for the
        // basic auth.
        headers.Authorization = 'Basic ' +
          btoa(legacyFormUrlEncode(this.settings.clientId) + ':' + legacyFormUrlEncode(this.settings.clientSecret!));
        break;
      case 'client_secret_basic_interop' :
        // A more relaxed encoding that's more compatible with popular servers.
        headers.Authorization = 'Basic ' +
          btoa(this.settings.clientId.replace(/:/g, '%3A') + ':' + this.settings.clientSecret!.replace(/:/g, '%3A'));
        break;
      case 'client_secret_post' :
        body.client_id = this.settings.clientId;
        if (this.settings.clientSecret) {
          body.client_secret = this.settings.clientSecret;
        }
        break;
      default:
        throw new Error('Authentication method not yet supported:' + authMethod + '. Open a feature request if you want this!');
    }

    const resp = await this.settings.fetch!(uri, {
      method: 'POST',
      body: generateQueryString(body),
      headers,
    });

    let responseBody;
    if (resp.status !== 204 && resp.headers.has('Content-Type') && resp.headers.get('Content-Type')!.match(/^application\/(.*\+)?json/)) {
      responseBody = await resp.json();
    }
    if (resp.ok) {
      return responseBody;
    }

    let errorMessage;
    let oauth2Code;

    if (responseBody?.error) {
      // This is likely an OAUth2-formatted error
      errorMessage = 'OAuth2 error ' + responseBody.error + '.';
      if (responseBody.error_description) {
        errorMessage += ' ' + responseBody.error_description;
      }
      oauth2Code = responseBody.error;

    } else {
      errorMessage = 'HTTP Error ' + resp.status + ' ' + resp.statusText;
      if (resp.status === 401 && this.settings.clientSecret) {
        errorMessage += '. It\'s likely that the clientId and/or clientSecret was incorrect';
      }
      oauth2Code = null;
    }
    throw new OAuth2HttpError(errorMessage, oauth2Code, resp, responseBody);
  }

  /**
   * Converts the JSON response body from the token endpoint to an OAuth2Token type.
   */
  async tokenResponseToOAuth2Token(resp: Promise<TokenResponse>): Promise<OAuth2Token> {

    const body = await resp;

    if (!body?.access_token) {
      console.warn('Invalid OAuth2 Token Response: ', body);
      throw new TypeError('We received an invalid token response from an OAuth2 server.');
    }

    const result: OAuth2Token = {
      accessToken: body.access_token,
      expiresAt: body.expires_in ? Date.now() + (body.expires_in * 1000) : null,
      refreshToken: body.refresh_token ?? null,
    };
    if (body.id_token) {
      result.idToken = body.id_token;
    }
    return result;

  }

}

function resolve(uri: string, base?: string): string {

  return new URL(uri, base).toString();

}

/**
 * Generates a query string.
 *
 * If a value is undefined, it will be ignored.
 * If a value is an array, it will add the parameter multiple times for each array value.
 */
export function generateQueryString(params: Record<string, undefined | number | string | string[]>): string {

  const query = new URLSearchParams();
  for (const [k, v] of Object.entries(params)) {
    if (Array.isArray(v)) {
      for(const vItem of v) query.append(k, vItem);
    } else if (v !== undefined) query.set(k, v.toString());
  }
  return query.toString();

}

/**
 * Encodes string according to the most strict interpretation of RFC 6749 Appendix B.
 *
 * All non-alphanumeric characters are percent encoded, with exception of space which
 * is replaced with '+'.
 */
export function legacyFormUrlEncode(value: string): string {
  return encodeURIComponent(value)
    .replace(/%20/g, '+')
    .replace(/[-_.!~*'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
}
