import { URL, Url } from 'url'
import debugModule from 'debug'
import minimatch from 'minimatch'
import fs from 'fs-extra'
import { X509Certificate, createPrivateKey } from 'crypto'
import tls from 'tls'

const debug = debugModule('cypress:network:client-certificates')

export class ParsedUrl {
  constructor (url: string) {
    if (url === '*' || url === 'https://*') {
      this.host = '*'
      this.path = undefined
      this.port = undefined
    } else {
      let parsed = new URL(url)

      this.host = parsed.hostname
      this.port = !parsed.port ? undefined : parseInt(parsed.port)
      if (parsed.pathname.length === 0 || parsed.pathname === '/') {
        this.path = undefined
      } else if (
        parsed.pathname.length > 0 &&
        !parsed.pathname.endsWith('/') &&
        !parsed.pathname.endsWith('*')
      ) {
        this.path = `${parsed.pathname}/`
      } else {
        this.path = parsed.pathname
      }
    }

    this.hostMatcher = new minimatch.Minimatch(this.host)
    this.pathMatcher = new minimatch.Minimatch(this.path ?? '')
  }

  path: string | undefined
  host: string
  port: number | undefined
  hostMatcher: minimatch.IMinimatch
  pathMatcher: minimatch.IMinimatch
}

export class UrlMatcher {
  static buildMatcherRule (url: string): ParsedUrl {
    return new ParsedUrl(url)
  }

  static matchUrl (hostname: string | undefined | null, path: string | undefined | null, port: number | undefined | null, rule: ParsedUrl | undefined): boolean {
    if (!hostname || !rule) {
      return false
    }

    let ret = rule.hostMatcher.match(hostname)

    if (ret && rule.port) {
      ret = rule.port === port
    }

    if (ret && rule.path) {
      ret = rule.pathMatcher?.match(path ?? '')
    }

    return ret
  }
}

/**
 * Defines the certificates that should be used for the specified URL
 */
export class UrlClientCertificates {
  constructor (url: string) {
    this.url = url
    this.pathnameLength = new URL(url).pathname.length
    this.clientCertificates = new ClientCertificates()
  }
  clientCertificates: ClientCertificates
  url: string
  pathnameLength: number
  matchRule: ParsedUrl | undefined
}

/**
 * Client certificates; this is in a data structure that is compatible with the NodeJS TLS API described
 * at https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options
 */
export class ClientCertificates {
  ca: Buffer[] = []
  cert: Buffer[] = []
  key: PemKey[] = []
  pfx: PfxCertificate[] = []
}

export class PemKey {
  constructor (pem: Buffer, passphrase: string | undefined) {
    this.pem = pem
    this.passphrase = passphrase
  }

  pem: Buffer
  passphrase: string | undefined
}

export class PfxCertificate {
  constructor (buf: Buffer, passphrase: string | undefined) {
    this.buf = buf
    this.passphrase = passphrase
  }

  buf: Buffer
  passphrase: string | undefined
}

export class ClientCertificateStore {
  private _urlClientCertificates: UrlClientCertificates[] = []

  addClientCertificatesForUrl (cert: UrlClientCertificates) {
    debug(
      'ClientCertificateStore::addClientCertificatesForUrl: "%s"',
      cert.url,
    )

    const existing = this._urlClientCertificates.find((x) => x.url === cert.url)

    if (existing) {
      throw new Error(`ClientCertificateStore::addClientCertificatesForUrl: Url ${cert.url} already in store`)
    }

    cert.matchRule = UrlMatcher.buildMatcherRule(cert.url)
    this._urlClientCertificates.push(cert)
  }

  getClientCertificateAgentOptionsForUrl (requestUrl: Url): ClientCertificates | null {
    if (
      !this._urlClientCertificates ||
      this._urlClientCertificates.length === 0
    ) {
      return null
    }

    const port = !requestUrl.port ? undefined : parseInt(requestUrl.port)
    const matchingCerts = this._urlClientCertificates.filter((cert) => {
      return UrlMatcher.matchUrl(requestUrl.hostname, requestUrl.path, port, cert.matchRule)
    })

    switch (matchingCerts.length) {
      case 0:
        debug(`not using client certificate(s) for url '${requestUrl.href}'`)

        return null
      case 1:
        debug(`using client certificate(s) for url '${requestUrl.href}'`)

        return matchingCerts[0].clientCertificates
      default:
        matchingCerts.sort((a, b) => {
          return b.pathnameLength - a.pathnameLength
        })

        debug(`using client certificate(s) for url '${requestUrl.href}'`)

        return matchingCerts[0].clientCertificates
    }
  }

  getCertCount (): Number {
    return !this._urlClientCertificates ? 0 : this._urlClientCertificates.length
  }

  clear (): void {
    this._urlClientCertificates = []
  }
}

export const clientCertificateStoreSingleton = new ClientCertificateStore()

/**
 * Load and parse the client certificate configuration.  The structure and content of this
 * has already been validated; this function reads cert content from file and adds it to the
 * network ClientCertificateStore
 * @param config
 */

type Config = {
  projectRoot: string
  clientCertificates?: Array<{
    url: string
    ca: string[]
    certs: Array<{
      cert: string
      key: string
      passphrase: string
      pfx: string
    }>
  }>
}

export function loadClientCertificateConfig (config: Config) {
  const { clientCertificates } = config

  let index = 0

  try {
    clientCertificateStoreSingleton.clear()

    // The basic validation of the certificate configuration has already been done by this point
    // within the 'isValidClientCertificatesSet' function within packages/config/src/validation.js
    if (clientCertificates) {
      clientCertificates.forEach((item) => {
        debug(`loading client cert at index ${index}`)

        const urlClientCertificates = new UrlClientCertificates(item.url)

        if (item.ca) {
          item.ca.forEach((ca: string) => {
            if (ca) {
              debug(`loading CA cert from '${ca}'`)
              const caRaw = loadBinaryFromFile(ca)

              try {
                // construct to validate; throws on malformed PEM
                // eslint-disable-next-line no-new
                new X509Certificate(caRaw)
              } catch (error: any) {
                throw new Error(`Cannot parse CA cert: ${error.message}`)
              }

              urlClientCertificates.clientCertificates.ca.push(caRaw)
            }
          })
        }

        if (!item.certs || item.certs.length === 0) {
          throw new Error('Either PEM or PFX must be supplied')
        }

        item.certs.forEach((cert) => {
          if (!cert || (!cert.cert && !cert.pfx)) {
            throw new Error('Either PEM or PFX must be supplied')
          }

          if (cert.cert) {
            if (!cert.key) {
              throw new Error(`No PEM key defined for cert: ${cert.cert}`)
            }

            debug(
              `loading PEM cert information from '${JSON.stringify(cert)}'`,
            )

            debug(`loading PEM cert from '${cert.cert}'`)
            const pemRaw = loadBinaryFromFile(cert.cert)

            try {
              // eslint-disable-next-line no-new
              new X509Certificate(pemRaw)
            } catch (error: any) {
              throw new Error(`Cannot parse PEM cert: ${error.message}`)
            }

            urlClientCertificates.clientCertificates.cert.push(pemRaw)

            let passphrase: string | undefined = undefined

            if (cert.passphrase) {
              debug(`loading PEM passphrase from '${cert.passphrase}'`)
              passphrase = loadTextFromFile(cert.passphrase)
            }

            debug(`loading PEM key from '${cert.key}'`)
            const pemKeyRaw = loadBinaryFromFile(cert.key)

            try {
              createPrivateKey({ key: pemKeyRaw, passphrase })
            } catch (error: any) {
              if (passphrase && error.code === 'ERR_OSSL_BAD_DECRYPT') {
                throw new Error(
                  `Cannot decrypt PEM key with supplied passphrase (check the passphrase file content and that it doesn't have unexpected whitespace at the end)`,
                )
              }

              throw new Error(`Cannot parse PEM key: ${error.message}`)
            }

            urlClientCertificates.clientCertificates.key.push(
              new PemKey(pemKeyRaw, passphrase),
            )

            debug(
              `loaded client PEM certificate for url: ${urlClientCertificates.url}`,
            )
          }

          if (cert.pfx) {
            debug(
              `loading PFX cert information from '${JSON.stringify(cert)}'`,
            )

            let passphrase: string | undefined = undefined

            if (cert.passphrase) {
              debug(`loading PFX passphrase from '${cert.passphrase}'`)
              passphrase = loadTextFromFile(cert.passphrase)
            }

            debug(`loading PFX cert from '${cert.pfx}'`)
            const pfxRaw = loadBinaryFromFile(cert.pfx)

            loadPfx(pfxRaw, passphrase)

            urlClientCertificates.clientCertificates.pfx.push(
              new PfxCertificate(pfxRaw, passphrase),
            )

            debug(
              `loaded client PFX certificate for url: ${urlClientCertificates.url}`,
            )
          }
        })

        clientCertificateStoreSingleton.addClientCertificatesForUrl(urlClientCertificates)
        index++
      })

      debug(
        `loaded client certificates for ${clientCertificateStoreSingleton.getCertCount()} URL(s)`,
      )
    }
  } catch (e: any) {
    debug(
      `Failed to load client certificate for clientCertificates[${index}]: ${e.message} ${e.stack}`,
    )

    throw new Error(
      `Failed to load client certificates for clientCertificates[${index}]: ${e.message}.  For more debug details run Cypress with DEBUG=cypress:server:client-certificates*`,
    )
  }
}

function loadBinaryFromFile (filepath: string): Buffer {
  debug(`loadCertificateFile: ${filepath}`)

  // TODO: update to async
  // eslint-disable-next-line no-restricted-syntax
  return fs.readFileSync(filepath)
}

function loadTextFromFile (filepath: string): string {
  debug(`loadPassphraseFile: ${filepath}`)

  // TODO: update to async
  // eslint-disable-next-line no-restricted-syntax
  return fs.readFileSync(filepath, 'utf8').toString()
}

function loadPfx (pfx: Buffer, passphrase: string | undefined): void {
  try {
    tls.createSecureContext({ pfx, passphrase })
  } catch (e: any) {
    debug(`loadPfx fail: ${e.message} ${e.stack}`)
    throw new Error(`Unable to load PFX file: ${e.message}`)
  }
}
