const { URL } = require('node:url')
const timers = require('node:timers/promises')
const fetch = require('npm-registry-fetch')
const { HttpErrorBase } = require('npm-registry-fetch/lib/errors')
const { log } = require('proc-log')

// try loginWeb, catch the "not supported" message and fall back to couch
const login = async (opener, prompter, opts = {}) => {
  try {
    return await loginWeb(opener, opts)
  } catch (er) {
    if (er instanceof WebLoginNotSupported) {
      log.verbose('web login', 'not supported, trying couch')
      const { username, password } = await prompter(opts.creds)
      return loginCouch(username, password, opts)
    }
    throw er
  }
}

const adduser = async (opener, prompter, opts = {}) => {
  try {
    return await adduserWeb(opener, opts)
  } catch (er) {
    if (er instanceof WebLoginNotSupported) {
      log.verbose('web adduser', 'not supported, trying couch')
      const { username, email, password } = await prompter(opts.creds)
      return adduserCouch(username, email, password, opts)
    }
    throw er
  }
}

const adduserWeb = (opener, opts = {}) => {
  log.verbose('web adduser', 'before first POST')
  return webAuth(opener, opts, { create: true })
}

const loginWeb = (opener, opts = {}) => {
  log.verbose('web login', 'before first POST')
  return webAuth(opener, opts, {})
}

const isValidUrl = u => {
  try {
    return /^https?:$/.test(new URL(u).protocol)
  } catch {
    return false
  }
}

const webAuth = async (opener, opts, body) => {
  try {
    const res = await fetch('/-/v1/login', {
      ...opts,
      method: 'POST',
      body,
    })

    const content = await res.json()
    log.verbose('web auth', 'got response', content)

    const { doneUrl, loginUrl } = content
    if (!isValidUrl(doneUrl) || !isValidUrl(loginUrl)) {
      throw new WebLoginInvalidResponse('POST', res, content)
    }

    return await webAuthOpener(opener, loginUrl, doneUrl, opts)
  } catch (er) {
    if ((er.statusCode >= 400 && er.statusCode <= 499) || er.statusCode === 500) {
      throw new WebLoginNotSupported('POST', {
        status: er.statusCode,
        headers: er.headers,
      }, er.body)
    }
    throw er
  }
}

const webAuthOpener = async (opener, loginUrl, doneUrl, opts) => {
  const abortController = new AbortController()
  const { signal } = abortController
  try {
    log.verbose('web auth', 'opening url pair')
    const [, authResult] = await Promise.all([
      opener(loginUrl, { signal }).catch((err) => {
        if (err.name === 'AbortError') {
          abortController.abort()
          return
        }
        throw err
      }),
      webAuthCheckLogin(doneUrl, { ...opts, cache: false }, { signal }).then((r) => {
        log.verbose('web auth', 'done-check finished')
        abortController.abort()
        return r
      }),
    ])
    return authResult
  } catch (er) {
    abortController.abort()
    throw er
  }
}

const webAuthCheckLogin = async (doneUrl, opts, { signal } = {}) => {
  signal?.throwIfAborted()

  const res = await fetch(doneUrl, opts)
  const content = await res.json()

  if (res.status === 200) {
    if (!content.token) {
      throw new WebLoginInvalidResponse('GET', res, content)
    }
    return content
  }

  if (res.status === 202) {
    const retry = +res.headers.get('retry-after') * 1000
    if (retry > 0) {
      await timers.setTimeout(retry, null, { signal })
    }
    return webAuthCheckLogin(doneUrl, opts, { signal })
  }

  throw new WebLoginInvalidResponse('GET', res, content)
}

const couchEndpoint = (username) => `/-/user/org.couchdb.user:${encodeURIComponent(username)}`

const putCouch = async (path, username, body, opts) => {
  const result = await fetch.json(`${couchEndpoint(username)}${path}`, {
    ...opts,
    method: 'PUT',
    body,
  })
  result.username = username
  return result
}

const adduserCouch = async (username, email, password, opts = {}) => {
  const body = {
    _id: `org.couchdb.user:${username}`,
    name: username,
    password: password,
    email: email,
    type: 'user',
    roles: [],
    date: new Date().toISOString(),
  }

  log.verbose('adduser', 'before first PUT', {
    ...body,
    password: 'XXXXX',
  })

  return putCouch('', username, body, opts)
}

const loginCouch = async (username, password, opts = {}) => {
  const body = {
    _id: `org.couchdb.user:${username}`,
    name: username,
    password: password,
    type: 'user',
    roles: [],
    date: new Date().toISOString(),
  }

  log.verbose('login', 'before first PUT', {
    ...body,
    password: 'XXXXX',
  })

  try {
    return await putCouch('', username, body, opts)
  } catch (err) {
    if (err.code === 'E400') {
      err.message = `There is no user with the username "${username}".`
      throw err
    }

    if (err.code !== 'E409') {
      throw err
    }
  }

  const result = await fetch.json(couchEndpoint(username), {
    ...opts,
    query: { write: true },
  })

  for (const k of Object.keys(result)) {
    if (!body[k] || k === 'roles') {
      body[k] = result[k]
    }
  }

  return putCouch(`/-rev/${body._rev}`, username, body, {
    ...opts,
    forceAuth: {
      username,
      password: Buffer.from(password, 'utf8').toString('base64'),
      otp: opts.otp,
    },
  })
}

const get = (opts = {}) => fetch.json('/-/npm/v1/user', opts)

const set = (profile, opts = {}) => fetch.json('/-/npm/v1/user', {
  ...opts,
  method: 'POST',
  // profile keys can't be empty strings, but they CAN be null
  body: Object.fromEntries(Object.entries(profile).map(([k, v]) => [k, v === '' ? null : v])),
})

const paginate = async (href, opts, items = []) => {
  const result = await fetch.json(href, opts)
  items = items.concat(result.objects)
  if (result.urls.next) {
    return paginate(result.urls.next, opts, items)
  }
  return items
}

const listTokens = (opts = {}) => paginate('/-/npm/v1/tokens', opts)

const removeToken = async (tokenKey, opts = {}) => {
  await fetch(`/-/npm/v1/tokens/token/${tokenKey}`, {
    ...opts,
    method: 'DELETE',
    ignoreBody: true,
  })
  return null
}

const createToken = (password, readonly, cidrs, opts = {}) => fetch.json('/-/npm/v1/tokens', {
  ...opts,
  method: 'POST',
  body: {
    password: password,
    readonly: readonly,
    cidr_whitelist: cidrs,
  },
})

class WebLoginInvalidResponse extends HttpErrorBase {
  constructor (method, res, body) {
    super(method, res, body)
    this.message = 'Invalid response from web login endpoint'
  }
}

class WebLoginNotSupported extends HttpErrorBase {
  constructor (method, res, body) {
    super(method, res, body)
    this.message = 'Web login not supported'
    this.code = 'ENYI'
  }
}

module.exports = {
  adduserCouch,
  loginCouch,
  adduserWeb,
  loginWeb,
  login,
  adduser,
  get,
  set,
  listTokens,
  removeToken,
  createToken,
  webAuthCheckLogin,
  webAuthOpener,
}
