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
  }
}

// npm's web-login response names the canonical npmjs registry in `doneUrl`, which a proxy/mirror forwards verbatim.
// The poll would then hit npmjs.org instead of the proxy that holds the session, so rewrite only that npmjs host to the configured registry origin, preserving the path prefix and query string.
// Any other done host is left untouched, since a non-npmjs canonical host cannot be inferred here and may be served intentionally.
const CANONICAL_REGISTRY_HOST = 'registry.npmjs.org'

// doneUrl is already validated by isValidUrl and registry is the origin a prior
// POST /-/v1/login succeeded against, so both parse cleanly here.
const replaceDoneUrlOrigin = (doneUrl, registry) => {
  if (!registry) {
    return doneUrl
  }
  const done = new URL(doneUrl)
  if (done.hostname !== CANONICAL_REGISTRY_HOST) {
    return doneUrl
  }
  const reg = new URL(registry)
  done.protocol = reg.protocol
  done.host = reg.host
  const prefix = reg.pathname.replace(/\/$/, '')
  if (prefix && prefix !== '/' &&
      done.pathname !== prefix &&
      !done.pathname.startsWith(prefix + '/')) {
    done.pathname = prefix + done.pathname
  }
  return done.href
}

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, replaceDoneUrlOrigin(doneUrl, opts.registry), 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,
}
