import { html, nothing, PropertyValues, TemplateResult } from 'lit'
import '@github/relative-time-element'
import { state } from 'lit/decorators.js'
import { live } from 'lit/directives/live.js'
import { generateState } from '@api-client/core/authorization/lib/Utils.js'
import * as KnownGrants from '@api-client/core/authorization/lib/KnownGrants.js'
import type {
  IOauth2GrantType,
  IOauth2ResponseType,
  IOidcAuthorization,
  IOidcTokenError,
  IOidcTokenInfo,
} from '@api-client/core/models/Authorization.js'
import { Events as CoreEvents } from '@api-client/core/events/Events.js'
import type { IOpenIdProviderMetadata } from '@api-client/core/authorization/types.js'
import OAuth2Authorization from './OAuth2Authorization.js'
import Input from '../../../md/input/Input.js'
import '../../../md/icons/ui-icon.js'
import '../../../md/button/ui-button.js'

export const GrantLabels: Record<string, string> = {
  [KnownGrants.implicit]: 'Access token',
  [KnownGrants.code]: 'Authorization code',
  refresh_token: 'Refresh token',
  [KnownGrants.password]: 'Password',
  [KnownGrants.clientCredentials]: 'Client credentials',
  [KnownGrants.deviceCode]: 'Device code',
  [KnownGrants.jwtBearer]: 'JWT Bearer',
}

export const ResponseTypeLabels: Record<string, string> = {
  token: 'Token',
  code: 'Code',
  id_token: 'ID token',
  id: 'ID token',
}

export const discoveryCache = new Map<string, IOpenIdProviderMetadata>()

/**
 * @returns The default grant types for OIDC
 */
export const defaultGrantTypes: IOauth2GrantType[] = [
  {
    type: 'implicit',
    label: 'Access token (browser flow)',
  },
  {
    type: 'authorization_code',
    label: 'Authorization code (server flow)',
  },
]

export default class OidcAuthorization extends OAuth2Authorization {
  @state() accessor discovered = false

  /**
   * The index of the response from the `supportedResponses`.
   * By default it selects the first one.
   */
  @state() accessor selectedResponse: number | undefined

  /**
   * @returns True when the current `grantType` can support redirect URI.
   */
  override get hasRedirectUri(): boolean {
    const { discovered } = this
    const { grantType } = this.config
    if (!discovered || !grantType) {
      return false
    }
    return [KnownGrants.implicit, KnownGrants.code].includes(grantType)
  }

  override get config(): IOidcAuthorization {
    const { authorization } = this
    if (!authorization) {
      return {}
    }
    if (!authorization.config) {
      authorization.config = {} as IOidcAuthorization
    }
    return authorization.config as IOidcAuthorization
  }

  protected override update(cp: PropertyValues<void>): void {
    if (cp.has('authorization') && this.authorization) {
      this.detectDiscovered()
      this.detectSelectedResponseType()
    }
    super.update(cp)
  }

  /**
   * Serialized input values
   * @returns An object with user input
   */
  override serialize(): IOidcAuthorization {
    const result = super.serialize() as IOidcAuthorization
    const { config } = this
    delete result.accessToken
    result.issuerUri = config.issuerUri
    result.tokens = config.tokens
    result.tokenInUse = config.tokenInUse
    result.supportedResponses = config.supportedResponses
    result.grantTypes = config.grantTypes
    result.serverScopes = config.serverScopes

    const { selectedResponse = 0 } = this
    const { supportedResponses = [], tokens, tokenInUse = 0 } = config
    const response = supportedResponses[selectedResponse]
    if (response) {
      result.responseType = response.map((i) => i.type).join(' ')
    }
    if (Array.isArray(tokens)) {
      result.accessToken = this.readTokenValue(tokens[tokenInUse] as IOidcTokenInfo)
    }
    if (result.responseType && !this.noPkce && result.responseType.includes('code')) {
      result.pkce = config.pkce
    }
    return result
  }

  override async authorize(): Promise<null> {
    this.lastErrorMessage = undefined
    const validationResult = this.checkValidity()
    if (!validationResult) {
      return null
    }
    this.authorizing = true
    const detail = this.serialize()
    const stateParam = generateState()
    detail.state = stateParam

    try {
      const tokens = await CoreEvents.Authorization.Oidc.authorize(detail, this)
      this.authorizing = false
      if (!Array.isArray(tokens) || !tokens.length) {
        return null
      }
      const { config } = this
      config.tokens = tokens
      config.accessToken = undefined
      config.tokenInUse = 0
      this.notifyChange()
      this.requestUpdate()
    } catch (e) {
      const { message = 'Unknown error' } = e as Error
      this.lastErrorMessage = message
      this.authorizing = false
      throw e
    }
    return null
  }

  readTokenLabel(token: IOidcTokenInfo | IOidcTokenError): string {
    const { responseType } = token
    switch (responseType) {
      case 'token':
        return 'Access token'
      case 'code':
        return 'Access token from code exchange'
      case 'id_token':
      case 'id':
        return 'ID token'
      default:
        return 'Unknown token'
    }
  }

  readTokenValue(token: IOidcTokenInfo): string {
    if (!token) {
      return ''
    }
    const { responseType } = token
    switch (responseType) {
      case 'token':
        return token.accessToken || ''
      case 'code':
        return token.accessToken || ''
      case 'id_token':
      case 'id':
        return token.idToken || ''
      default:
        return token.accessToken || token.refreshToken || token.idToken || ''
    }
  }

  updateIssuerUri(value?: string): void {
    const { config } = this
    if (config.issuerUri === value) {
      return
    }
    config.issuerUri = value
    this.requestUpdate()
    this.notifyChange()
  }

  /**
   * Downloads the OIDC info and pre-populates the form inputs.
   */
  async discover(): Promise<void> {
    const { issuerUri } = this.config
    if (!issuerUri) {
      const message = 'Set the issuer URI first.'
      this.lastErrorMessage = message
      this.discovered = false
      throw new Error(message)
    }
    this.lastErrorMessage = undefined
    let info: IOpenIdProviderMetadata | undefined
    const oidcUrl = this.buildIssuerUrl(issuerUri)
    if (discoveryCache.has(oidcUrl)) {
      info = discoveryCache.get(oidcUrl)
    } else {
      try {
        info = await this.transportDiscovery(oidcUrl)
        if (info) {
          discoveryCache.set(oidcUrl, info)
        }
      } catch {
        this.lastErrorMessage = `Unable to read the discovery information.`
      }
    }
    if (info) {
      this.propagateOidc(info)
      this.discovered = true
      this.notifyChange()
    } else {
      this.discovered = false
    }
  }

  /**
   * Constructs the OIDC discovery URL.
   * @param baseUri The issues URI.
   */
  protected buildIssuerUrl(baseUri: string): string {
    let url = baseUri
    if (!url.includes('.well-known')) {
      if (!url.endsWith('/')) {
        url += '/'
      }
      url += '.well-known/openid-configuration'
    }
    return url
  }

  /**
   * Requests the data from the discovery endpoint.
   */
  protected async transportDiscovery(url: string): Promise<IOpenIdProviderMetadata | undefined> {
    const rsp = await fetch(url)
    return rsp.json()
  }

  propagateOidc(meta: IOpenIdProviderMetadata): void {
    const { config } = this

    config.authorizationUri = meta.authorization_endpoint
    config.supportedResponses = this.translateResponseCodes(meta.response_types_supported)
    if (meta.token_endpoint) {
      config.accessTokenUri = meta.token_endpoint
    }
    if (Array.isArray(meta.grant_types_supported) && meta.grant_types_supported.length) {
      config.serverScopes = meta.grant_types_supported
      this.grantTypes = this.translateGrantTypesMeta(meta.grant_types_supported)
    } else {
      this.grantTypes = [...defaultGrantTypes]
      config.serverScopes = undefined
    }
    if (Array.isArray(meta.scopes_supported)) {
      config.scopes = meta.scopes_supported
    } else {
      config.scopes = ['openid']
    }
  }

  protected translateGrantTypesMeta(types: string[]): IOauth2GrantType[] {
    const result: IOauth2GrantType[] = []
    types.forEach((type) => {
      const item = {
        type,
        label: type,
      }
      if (GrantLabels[type]) {
        item.label = GrantLabels[type]
      }
      result.push(item)
    })
    return result
  }

  /**
   * This generates a 2-dimensional array with the response codes
   * supported by the authorization server. Next to the grant type
   * it describes how token is received by the
   */
  protected translateResponseCodes(codes: string[]): IOauth2ResponseType[][] {
    const result: IOauth2ResponseType[][] = []
    codes.forEach((value) => {
      const items = value.split(' ')
      const response: IOauth2ResponseType[] = []
      result.push(response)
      items.forEach((responseValue) => {
        const type = {
          type: responseValue,
          label: responseValue,
        }
        if (ResponseTypeLabels[responseValue]) {
          type.label = ResponseTypeLabels[responseValue]
        }
        response.push(type)
      })
    })
    return result
  }

  /**
   * Sets the `discovered` flag depending on the current configuration.
   */
  protected detectDiscovered(): void {
    const { grantTypes, config } = this
    const { issuerUri, authorizationUri, scopes } = config
    if (!issuerUri || !authorizationUri) {
      this.discovered = false
      return
    }
    if (!Array.isArray(grantTypes) || !grantTypes.length) {
      this.discovered = false
      return
    }
    if (!Array.isArray(scopes) || !scopes.length) {
      this.discovered = false
      return
    }
    this.discovered = true
  }

  /**
   * A function called from the auth element `updated` lifecycle method.
   * It tries to figure out the `selectedResponse` from the current list of
   * `supportedResponses` and the `responseType`.
   */
  protected detectSelectedResponseType(): void {
    const { selectedResponse, config } = this
    const { supportedResponses, responseType } = config
    if (!responseType || !Array.isArray(supportedResponses)) {
      return
    }
    const parts = responseType.split(' ')
    const index = supportedResponses.findIndex((i) => {
      const rspTypes = i.map((e) => e.type)
      const hasNotFound = parts.some((p) => !rspTypes.includes(p))
      return !hasNotFound
    })
    if (index >= 0 && selectedResponse !== index) {
      this.selectedResponse = index
    }
  }

  selectResponseType(index: number): void {
    if (this.selectedResponse === index) {
      return
    }
    this.selectedResponse = index
    // this.notifyChange(); ???
  }

  protected handleIssuerUri(e: Event): void {
    const input = e.target as Input
    const { value } = input
    this.updateIssuerUri(value)
  }

  protected handleIssuerRead(): void {
    this.discover()
  }

  protected handleResponseTypeSelect(e: CustomEvent): void {
    const target = e.detail.item as HTMLElement | undefined
    if (!target) {
      return
    }
    const index = Number(target.dataset.index)
    if (Number.isNaN(index)) {
      return
    }
    this.selectResponseType(index)
  }

  protected handleTokenInUse(e: Event): void {
    const input = e.target as HTMLInputElement
    const { value } = input
    if (!input.checked) {
      return
    }
    this.config.tokenInUse = Number(value)
    this.notifyChange()
  }

  protected override renderEditor(): TemplateResult | typeof nothing {
    const { authorization, discovered } = this
    if (!authorization) {
      return nothing
    }
    return html`
      <form autocomplete="on" class="auth-form oauth2-auth">
        ${this.renderIssuerInput()} ${discovered ? this.renderFormContent() : ''}
      </form>
      ${this.renderAuthAction()} ${this.renderError()} ${this.renderAuthInfo()}
    `
  }

  protected renderIssuerInput(): TemplateResult {
    const { config } = this
    const { issuerUri } = config
    return html`
      <div class="full-input base-level issuer-field">
        <ui-filled-text-field
          label="Issuer URI"
          name="issuerUri"
          .value="${live(issuerUri || '')}"
          type="url"
          required
          invalidText="Issuer URI is required"
          supportingText="The URI without the .well-known part."
          ?disabled="${this.disabled}"
          ?readOnly="${this.readOnly}"
          @change="${this.handleIssuerUri}"
        ></ui-filled-text-field>

        <ui-button
          color="tonal"
          title="Downloads and processes the discovery info"
          @click="${this.handleIssuerRead}"
          data-type="read-discovery"
          >Read</ui-button
        >
      </div>
    `
  }

  protected override renderFormContent(): (typeof nothing | TemplateResult)[] {
    const parts = super.renderFormContent()
    parts.unshift(this.renderResponses())
    return parts
  }

  /**
   * @returns The template for the response types drop down.
   */
  protected renderResponses(): TemplateResult | typeof nothing {
    const { supportedResponses } = this.config
    if (!Array.isArray(supportedResponses) || !supportedResponses.length) {
      return nothing
    }
    const { selectedResponse = 0, readOnly, disabled } = this

    const selected = supportedResponses[selectedResponse]
    const label = selected ? selected.map((i) => i.label).join(', ') : ''

    return html` <div class="full-input">
      <ui-dropdown-list verticalAlign="top" @select="${this.handleResponseTypeSelect}" closeOnOutsideClick>
        <ui-filled-text-field
          readOnly
          name="responseType"
          .value="${label}"
          label="Response type"
          ?disabled="${disabled || readOnly}"
        >
          <ui-icon slot="suffix" icon="arrowDropDown" role="presentation"></ui-icon>
        </ui-filled-text-field>
        <ui-list slot="dropdown" role="menu">
          ${supportedResponses.map((item, index) => this.renderResponseItem(item, index))}
        </ui-list>
      </ui-dropdown-list>
    </div>`
  }

  /**
   * @param item The responses list to render as a single item.
   * @returns The template for the response types drop down item.
   */
  protected renderResponseItem(item: IOauth2ResponseType[], index: number): TemplateResult | string {
    const label = item.map((i) => i.label).join(', ')
    return html` <ui-list-item role="menuitem" data-index="${index}">${label}</ui-list-item> `
  }

  protected override renderAuthAction(): TemplateResult | typeof nothing {
    const { config } = this
    const { tokens = [] } = config
    return tokens.length ? this.renderOAuth2Token() : this.renderOAth2Authorize()
  }

  protected override renderOAuth2Token(): TemplateResult | typeof nothing {
    const { config, authorizing } = this
    const { tokens = [] } = config
    return html` <div class="current-tokens">
      <p class="tokens-title">Tokens</p>
      ${tokens.map((info, index) => this.renderToken(info, index))}
      <div class="authorize-actions">
        <ui-button
          color="filled"
          ?disabled="${authorizing}"
          class="auth-button"
          data-type="refresh-token"
          @click="${this.authorize}"
          >Refresh tokens</ui-button
        >
      </div>
    </div>`
  }

  protected renderToken(token: IOidcTokenInfo | IOidcTokenError, index: number): TemplateResult {
    const typedError = token as IOidcTokenError
    if (typedError.error) {
      return this.renderErrorToken(typedError)
    }
    return this.renderInfoToken(token as IOidcTokenInfo, index)
  }

  protected renderErrorToken(token: IOidcTokenError): TemplateResult {
    const { error, errorDescription } = token
    const label = this.readTokenLabel(token)
    return html` <div class="current-token">
      <label class="token-label label-large">${label}</label>
      <p class="token-value body-small">
        <span>${error}: ${errorDescription}</span>
      </p>
    </div>`
  }

  protected renderInfoToken(token: IOidcTokenInfo, index: number): TemplateResult {
    const { config } = this
    const { responseType } = token
    const label = this.readTokenLabel(token)
    const value = this.readTokenValue(token)
    return html`
      <div class="token-option">
        <input
          type="radio"
          id="${responseType}"
          name="tokenInUse"
          .value="${String(index)}"
          ?checked="${config.tokenInUse === index}"
          @change="${this.handleTokenInUse}"
        />
        <div class="token-info">
          <label for="${responseType}" class="token-label label-large"> ${label} </label>
          ${this.renderTokenExpiration(token)}
          <div
            class="token-value body-small"
            title="${value}"
            @click="${this.handleClickCopy}"
            @keydown="${this.handlerCopyKeydown}"
          >
            ${value.trim()}
          </div>
        </div>
      </div>
    `
  }

  protected renderTokenExpiration(token: IOidcTokenInfo): TemplateResult | string {
    const { time, expiresIn } = token
    if (!time || !expiresIn) {
      return ''
    }
    const d = new Date(time + expiresIn * 1000)
    const expTime = d.toISOString()
    const expired = Date.now() > d.getTime()
    const label = expired ? 'Expired' : 'Expires'
    return html`
      <div class="token-expires label-small">${label} <relative-time datetime="${expTime}"></relative-time></div>
    `
  }

  protected override renderOAth2Authorize(): TemplateResult | typeof nothing {
    const { authorizing } = this
    return html` <div class="authorize-actions base-level">
      <ui-button
        color="filled"
        ?disabled="${authorizing || !this.discovered}"
        class="auth-button"
        data-type="get-token"
        @click="${this.authorize}"
        >Request tokens</ui-button
      >
    </div>`
  }
}
