import { html, fixture, assert } from '@open-wc/testing'
import sinon from 'sinon'
import {
  ContextEvent,
  EventTypes,
  IOidcAuthorization,
  IOpenIdProviderMetadata,
  IRequestAuthorization,
  RequestAuthorizationKind,
  OidcAuthorization as OidcAuthorizationFactory,
  IOidcTokenInfo,
  IOidcTokenError,
} from '@api-client/core/browser.js'
import env from '../../env.js'
import OidcAuthorization, {
  defaultGrantTypes,
  discoveryCache,
} from '../../../src/elements/authorization/ui/OidcAuthorization.js'
import type UiButton from '../../../src/md/button/internals/button.js'
import type Input from '../../../src/md/input/Input.js'
import '../../../src/define/authorization/oidc-authorization.js'

describe('Authorization', () => {
  describe('<oidc-authorization>', () => {
    const baseConfig: IOidcAuthorization = {
      redirectUri: `${window.location.origin}/demo/oauth-popup.html`,
    }

    async function basicFixture(opts: IOidcAuthorization): Promise<OidcAuthorization> {
      const auth: IRequestAuthorization = {
        config: opts,
        enabled: true,
        kind: RequestAuthorizationKind,
        type: 'open id',
        valid: true,
      }

      return fixture(html`<oidc-authorization .authorization="${auth}"></oidc-authorization>`)
    }

    const oidcConfig: Readonly<IOpenIdProviderMetadata> = Object.freeze({
      issuer: 'http://localhost:8004',
      token_endpoint: 'http://localhost:8004/token',
      authorization_endpoint: 'http://localhost:8004/authorize',
      userinfo_endpoint: 'http://localhost:8004/userinfo',
      token_endpoint_auth_methods_supported: ['none'],
      jwks_uri: 'http://localhost:8004/jwks',
      response_types_supported: ['code', 'id_token', 'code id_token'],
      grant_types_supported: ['authorization_code', 'custom'],
      token_endpoint_auth_signing_alg_values_supported: ['RS256'],
      response_modes_supported: ['query'],
      id_token_signing_alg_values_supported: ['RS256'],
      revocation_endpoint: 'http://localhost:8004/revoke',
      subject_types_supported: ['public'],
      scopes_supported: ['openid', 'profile'],
    })

    describe('base state', () => {
      it('renders base state component', async () => {
        const element = await basicFixture({ ...baseConfig })
        const issuer = element.shadowRoot!.querySelector('[name="issuerUri"]')
        assert.ok(issuer, 'has the issuer URI input')

        const allFields = element.shadowRoot!.querySelectorAll('[name]')
        assert.lengthOf(allFields, 1, 'has only one input field')
      })

      it('has the request tokens button disabled', async () => {
        const element = await basicFixture({ ...baseConfig })
        const button = element.shadowRoot!.querySelector('[data-type="get-token"]') as UiButton
        assert.ok(button, 'has the button')
        assert.isTrue(button.disabled, 'the button is disabled')
      })

      it('has the read button', async () => {
        const element = await basicFixture({ ...baseConfig })
        const button = element.shadowRoot!.querySelector('[data-type="read-discovery"]') as UiButton
        assert.ok(button, 'has the button')
        assert.notOk(button.disabled, 'the button is not disabled')
      })
    })

    describe('requesting the discovery data', () => {
      const fullDiscoveryUrl = `${env.oauth2.issuer}/.well-known/openid-configuration`
      let element: OidcAuthorization

      beforeEach(async () => {
        discoveryCache.clear()
        element = await basicFixture({
          ...baseConfig,
          issuerUri: env.oauth2.issuer,
        })
      })

      it('processes the data', async () => {
        await element.discover()
        assert.equal(element.config.authorizationUri, `${env.oauth2.issuer}/authorize`, 'sets the authorizationUri')
        assert.equal(element.config.accessTokenUri, `${env.oauth2.issuer}/token`, 'sets the accessTokenUri')
        // the rest is in unit tests
      })

      it('sets the discovered flag', async () => {
        await element.discover()
        assert.isTrue(element.discovered)
      })

      it('sets the message and flags when error', async () => {
        element.config.issuerUri = 'http://localhost:1234/notinhg'
        element.requestUpdate()
        await element.updateComplete
        await element.discover()
        assert.isFalse(element.discovered, 'discovered is false')
        assert.equal(element.lastErrorMessage, 'Unable to read the discovery information.', 'lastErrorMessage is set')
      })

      it('sets the cache data', async () => {
        await element.discover()
        assert.ok(discoveryCache.get(fullDiscoveryUrl))
      })

      it('reuses the cached data', async () => {
        const spy = sinon.spy(window, 'fetch')
        try {
          await element.discover()
          await element.discover()
        } finally {
          spy.restore()
        }
        assert.isTrue(spy.calledOnce)
      })

      it('throws when no issuerUrl', async () => {
        let message: string | undefined
        element.config.issuerUri = undefined
        element.requestUpdate()
        await element.updateComplete
        try {
          await element.discover()
        } catch (e) {
          message = (e as Error).message
        }
        assert.equal(message, 'Set the issuer URI first.', 'throws the error')
        assert.equal(element.lastErrorMessage, 'Set the issuer URI first.', 'sets the error message')
      })

      it('reads the data after the read button click', async () => {
        const spy = sinon.spy(element, 'discover')
        const button = element.shadowRoot!.querySelector('[data-type="read-discovery"]') as UiButton
        button.click()
        assert.isTrue(spy.calledOnce)
      })
    })

    describe('propagateOidc()', () => {
      let element: OidcAuthorization

      beforeEach(async () => {
        element = await basicFixture({
          ...baseConfig,
          issuerUri: env.oauth2.issuer,
        })
      })

      it('sets the authorizationUri', () => {
        element.propagateOidc({ ...oidcConfig })
        assert.equal(element.config.authorizationUri, oidcConfig.authorization_endpoint, 'authorizationUri is set')
      })

      it('sets the accessTokenUri', () => {
        element.propagateOidc({ ...oidcConfig })
        assert.equal(element.config.accessTokenUri, oidcConfig.token_endpoint, 'accessTokenUri is set')
      })

      it('ignores accessTokenUri when not set', () => {
        element.propagateOidc({ ...oidcConfig, token_endpoint: undefined })
        assert.isUndefined(element.config.accessTokenUri, 'accessTokenUri is not set')
      })

      it('sets the grantTypes', () => {
        element.propagateOidc({ ...oidcConfig })
        assert.typeOf(element.grantTypes, 'array', 'grantTypes is an array')
        assert.lengthOf(element.grantTypes!, oidcConfig.grant_types_supported!.length, 'has all types')
        const [g1, g2] = element.grantTypes!
        assert.equal(g1.type, 'authorization_code', 'grant #1 type is set')
        assert.equal(g1.label, 'Authorization code', 'grant #1 label is set')
        assert.equal(g2.type, 'custom', 'grant #2 type is set')
        assert.equal(g2.label, 'custom', 'grant #2 label is set')
      })

      it('sets the default grantTypes', () => {
        element.propagateOidc({ ...oidcConfig, grant_types_supported: undefined })
        assert.deepEqual(element.grantTypes, defaultGrantTypes)
      })

      it('sets the scopes', () => {
        element.propagateOidc({ ...oidcConfig })
        assert.typeOf(element.config.scopes, 'array', 'scopes is an array')
        assert.deepEqual(element.config.scopes, ['openid', 'profile'], 'has the scopes')
      })

      it('sets the default scopes', () => {
        element.propagateOidc({ ...oidcConfig, scopes_supported: undefined })
        assert.deepEqual(element.config.scopes, ['openid'])
      })

      it('sets the supportedResponses', () => {
        element.propagateOidc({ ...oidcConfig })
        assert.typeOf(element.config.supportedResponses, 'array', 'supportedResponses is an array')
        assert.lengthOf(element.config.supportedResponses!, 3, 'has all supportedResponses')
        const [r1, r2, r3] = element.config.supportedResponses!
        assert.isArray(r1, 'response #1 is an array')
        assert.lengthOf(r1, 1, 'response #1 has a single type')
        assert.equal(r1[0].type, 'code', 'response #1 has type type')
        assert.equal(r1[0].label, 'Code', 'response #1 has type label')
        assert.lengthOf(r2, 1, 'response #2 has a single type')
        assert.equal(r2[0].type, 'id_token', 'response #2 has type type')
        assert.equal(r2[0].label, 'ID token', 'response #2 has type label')
        assert.lengthOf(r3, 2, 'response #3 has a 2 types')
        assert.equal(r3[0].type, 'code', 'response #3,1 has type type')
        assert.equal(r3[0].label, 'Code', 'response #3,1 has type label')
        assert.equal(r3[1].type, 'id_token', 'response #3,2 has type type')
        assert.equal(r3[1].label, 'ID token', 'response #3,2 has type label')
      })
    })

    describe('Discovered UI', () => {
      let element: OidcAuthorization

      beforeEach(async () => {
        element = await basicFixture({
          ...baseConfig,
          issuerUri: env.oauth2.issuer,
          clientId: 'test-cid',
          clientSecret: 'test-secret',
          grantType: 'authorization_code',
        })
        await element.discover()
      })

      it('has the response type input', () => {
        const node = element.shadowRoot!.querySelector('[name="responseType"')
        assert.ok(node)
      })

      it('has the grant type input', () => {
        const node = element.shadowRoot!.querySelector('[name="grantType"')
        assert.ok(node)
      })

      it('has the clientId input', () => {
        const node = element.shadowRoot!.querySelector('[name="clientId"') as Input
        assert.ok(node, 'has the input')
        assert.isTrue(node.required, 'is required')
      })

      it('has the clientSecret input', () => {
        const node = element.shadowRoot!.querySelector('[name="clientSecret"') as Input
        assert.ok(node, 'has the input')
        assert.isTrue(node.required, 'is required')
      })
    })

    describe('Data change and propagation', () => {
      let element: OidcAuthorization
      beforeEach(async () => {
        element = await basicFixture({
          ...baseConfig,
          issuerUri: env.oauth2.issuer,
          clientId: 'test-cid',
          clientSecret: 'test-secret',
          grantType: 'authorization_code',
        })
        await element.discover()
      })

      it('propagates issuerUri change', async () => {
        const spy = sinon.spy()
        element.addEventListener('change', spy)
        const input = element.shadowRoot!.querySelector('[name="issuerUri"') as Input
        input.value = 'https://api.domain.com'
        input.dispatchEvent(new Event('change'))
        assert.equal(element.config.issuerUri, 'https://api.domain.com', 'propagates the change')
        assert.isTrue(spy.calledOnce, 'dispatched the change event')
      })
    })

    describe('readTokenValue()', () => {
      let element: OidcAuthorization

      beforeEach(async () => {
        element = await basicFixture({
          ...baseConfig,
          issuerUri: env.oauth2.issuer,
        })
      })

      it('returns the token for token response type', () => {
        const result = element.readTokenValue({
          responseType: 'token',
          state: '',
          time: 1,
          accessToken: 'test-token',
        })
        assert.equal(result, 'test-token')
      })

      it('returns the token for code response type', () => {
        const result = element.readTokenValue({
          responseType: 'code',
          state: '',
          time: 1,
          accessToken: 'test-token',
        })
        assert.equal(result, 'test-token')
      })

      it('returns the token for id_token response type', () => {
        const result = element.readTokenValue({
          responseType: 'id_token',
          state: '',
          time: 1,
          idToken: 'test-token',
        })
        assert.equal(result, 'test-token')
      })

      it('returns the token for id response type', () => {
        const result = element.readTokenValue({
          responseType: 'id',
          state: '',
          time: 1,
          idToken: 'test-token',
        })
        assert.equal(result, 'test-token')
      })

      it('returns the token from accessToken', () => {
        const result = element.readTokenValue({
          responseType: 'other',
          state: '',
          time: 1,
          accessToken: 'test-token',
        })
        assert.equal(result, 'test-token')
      })

      it('returns the token from refreshToken', () => {
        const result = element.readTokenValue({
          responseType: 'other',
          state: '',
          time: 1,
          refreshToken: 'test-token',
        })
        assert.equal(result, 'test-token')
      })

      it('returns the token from idToken', () => {
        const result = element.readTokenValue({
          responseType: 'other',
          state: '',
          time: 1,
          idToken: 'test-token',
        })
        assert.equal(result, 'test-token')
      })

      it('returns the empty string for default', () => {
        const result = element.readTokenValue({
          responseType: 'other',
          state: '',
          time: 1,
        })
        assert.equal(result, '')
      })

      it('returns the empty string when no token', () => {
        // @ts-expect-error for testing
        const result = element.readTokenValue(undefined)
        assert.equal(result, '')
      })
    })

    describe('readTokenLabel()', () => {
      let element: OidcAuthorization

      beforeEach(async () => {
        element = await basicFixture({
          ...baseConfig,
          issuerUri: env.oauth2.issuer,
        })
      })

      it('returns the label for token response type', () => {
        const result = element.readTokenLabel({
          responseType: 'token',
          state: '',
          time: 1,
          accessToken: 'test-token',
        })
        assert.equal(result, 'Access token')
      })

      it('returns the label for code response type', () => {
        const result = element.readTokenLabel({
          responseType: 'code',
          state: '',
          time: 1,
          accessToken: 'test-token',
        })
        assert.equal(result, 'Access token from code exchange')
      })

      it('returns the label for id_token response type', () => {
        const result = element.readTokenLabel({
          responseType: 'id_token',
          state: '',
          time: 1,
          idToken: 'test-token',
        })
        assert.equal(result, 'ID token')
      })

      it('returns the label for id response type', () => {
        const result = element.readTokenLabel({
          responseType: 'id',
          state: '',
          time: 1,
          idToken: 'test-token',
        })
        assert.equal(result, 'ID token')
      })

      it('returns the label from default', () => {
        const result = element.readTokenLabel({
          responseType: 'other',
          state: '',
          time: 1,
          accessToken: 'test-token',
        })
        assert.equal(result, 'Unknown token')
      })
    })

    describe('serialize()', () => {
      let element: OidcAuthorization

      beforeEach(async () => {
        element = await basicFixture({
          issuerUri: env.oauth2.issuer,
          clientId: 'test-cid',
          clientSecret: 'test-secret',
          grantType: 'authorization_code',
        })
        await element.discover()
      })

      it('sets the responseType', () => {
        const result = element.serialize()
        assert.equal(result.responseType, 'code')
      })

      it('sets the accessToken when has tokens', () => {
        element.config.tokens = [
          {
            responseType: 'code',
            state: 'abc',
            time: 1,
            accessToken: 'test-token',
          },
        ]
        const result = element.serialize()
        assert.equal(result.accessToken, 'test-token')
      })

      it('has no accessToken when no tokens', () => {
        element.config.tokens = undefined
        const result = element.serialize()
        assert.isUndefined(result.accessToken)
      })

      it('adds the issuerUri property', () => {
        const result = element.serialize()
        assert.equal(result.issuerUri, env.oauth2.issuer)
      })

      it('adds the tokens property', () => {
        const tokens = [
          {
            responseType: 'code',
            state: 'abc',
            time: 1,
            accessToken: 'test-token',
          },
        ]
        element.config.tokens = tokens
        const result = element.serialize()
        assert.deepEqual(result.tokens, tokens)
      })

      it('adds the tokenInUse property', () => {
        const tokens = [
          {
            responseType: 'code',
            state: 'abc',
            time: 1,
            accessToken: 'test-token',
          },
        ]
        element.config.tokens = tokens
        element.config.tokenInUse = 1
        const result = element.serialize()
        assert.equal(result.tokenInUse, 1)
      })

      it('adds the supportedResponses property', () => {
        const responses = [[{ label: 'a', type: 'b' }]]
        element.config.supportedResponses = responses
        const result = element.serialize()
        assert.deepEqual(result.supportedResponses, responses)
      })

      it('adds the grantTypes property', () => {
        const grantTypes = [{ label: 'a', type: 'b' }]
        element.config.grantTypes = grantTypes
        const result = element.serialize()
        assert.deepEqual(result.grantTypes, grantTypes)
      })

      it('adds the serverScopes property', () => {
        const serverScopes = ['a']
        element.config.serverScopes = serverScopes
        const result = element.serialize()
        assert.deepEqual(result.serverScopes, serverScopes)
      })
    })

    describe('authorize()', () => {
      let element: OidcAuthorization

      beforeEach(async () => {
        element = await basicFixture({
          ...baseConfig,
          issuerUri: env.oauth2.issuer,
          clientId: 'test-cid',
          clientSecret: 'test-secret',
          grantType: 'authorization_code',
        })
        await element.discover()
      })

      function handler(e: Event): void {
        const typed = e as ContextEvent<IOidcAuthorization, (IOidcTokenInfo | IOidcTokenError)[]>
        const config = { ...typed.detail }
        const auth = new OidcAuthorizationFactory(config)
        typed.detail.result = auth.authorize()
      }

      before(() => {
        document.body.addEventListener(EventTypes.Authorization.Oidc.authorize, handler)
      })

      after(() => {
        document.body.removeEventListener(EventTypes.Authorization.Oidc.authorize, handler)
      })

      it('sets the state before authorization', async () => {
        element.lastErrorMessage = 'test'
        element.authorizing = false
        const promise = element.authorize()
        assert.isUndefined(element.lastErrorMessage, 'clears the lastErrorMessage')
        assert.isTrue(element.authorizing, 'sets the authorizing')
        await promise
      })

      it('sets the state after authorization', async () => {
        element.config.accessToken = 'test'
        element.requestUpdate()
        await element.updateComplete
        await element.authorize()
        assert.typeOf(element.config.tokens, 'array', 'has the tokens set')
        const token = element.config.tokens![0] as IOidcTokenInfo
        assert.equal(token.responseType, 'code', 'has the responseType')
        assert.typeOf(token.accessToken, 'string', 'has the accessToken')
        assert.typeOf(token.idToken, 'string', 'has the idToken')
        assert.isFalse(element.authorizing, 're-sets authorizing flag')
        assert.isUndefined(element.config.accessToken, 'accessToken is removed')
        assert.equal(element.config.tokenInUse, 0, 'tokenInUse is set')
      })
    })
  })
})
