import { asMaybe, asObject } from 'cleaners'
import {



  pickMethod,
  pickPath,

} from 'serverlet'

import {


  wasEdgeRepoDump
} from '../../types/fake-types'
import {
  asChangeOtpPayload,
  asChangePasswordPayload,
  asChangePin2IdPayload,
  asChangePin2Payload,
  asChangeRecovery2IdPayload,
  asChangeRecovery2Payload,
  asChangeSecretPayload,
  asChangeUsernamePayload,
  asChangeVouchersPayload,
  asCreateKeysPayload,
  asCreateLoginPayload,
  asEdgeBox,
  asEdgeLobbyReply,
  asEdgeLobbyRequest,
  asLoginRequestBody,
  wasLobbyPayload,
  wasLoginPayload,
  wasMessagesPayload,
  wasOtpResetPayload,
  wasRecovery2InfoPayload,
  wasUsernameInfoPayload
} from '../../types/server-cleaners'

import { checkTotp } from '../../util/crypto/hotp'
import { verifyData } from '../../util/crypto/verify'
import { utf8 } from '../../util/encoding'
import { addHiddenProperties, softCat } from '../../util/util'
import { userIdSnrp } from '../scrypt/scrypt-selectors'
import {



  makeLoginPayload,
  makePendingVouchers
} from './fake-db'
import {
  cleanRequest,
  jsonResponse,
  otpErrorResponse,
  passwordErrorResponse,
  payloadResponse,
  statusCodes,
  statusResponse
} from './fake-responses'



















// Authentication middleware: ----------------------------------------------

const withApiKey =
  (server) =>
  async request => {
    const { json } = request
    const [body, bodyError] = cleanRequest(asLoginRequestBody, json)
    if (body == null) return bodyError
    return await server({ ...request, body, payload: body.data })
  }

const withValidOtp

 = server => async request => {
  const { body, login } = request
  const { otp, voucherAuth, voucherId } = body

  // Deactivated OTP is fine:
  const { otpKey } = login
  if (otpKey == null) return await server(request)

  // A valid OTP is good:
  if (otp != null && checkTotp(otpKey, otp, { spread: 2 })) {
    return await server(request)
  }

  // An approved voucher is good:
  if (voucherAuth != null && voucherId != null) {
    const voucher = login.vouchers.find(
      voucher => voucher.voucherId === voucherId
    )
    if (
      voucher != null &&
      voucher.status === 'approved' &&
      verifyData(voucherAuth, voucher.voucherAuth)
    ) {
      return await server(request)
    }
  }

  login.otpResetAuth = 'Super secret reset token'
  const voucher = {
    activates: new Date('2020-01-01T00:00:00Z'),
    created: new Date('2020-01-08T00:00:00Z'),
    deviceDescription: 'A phone',
    ip: 'localhost',
    ipDescription: 'here',
    loginId: login.loginId,
    status: 'pending',
    voucherAuth: Uint8Array.from([0xaa, 0xbb]),
    voucherId: `voucher-${login.vouchers.length}`
  }
  login.vouchers.push(voucher)
  return otpErrorResponse(login, { voucher })
}

const handleMissingCredentials = request =>
  statusResponse(statusCodes.invalidRequest)

/**
 * Verifies that the request contains valid v2 authentication.
 */
const withLogin2 =
  (
    server,
    fallback = handleMissingCredentials
  ) =>
  request => {
    const { db, body } = request
    const {
      loginAuth,
      loginId,
      passwordAuth,
      pin2Auth,
      pin2Id,
      recovery2Auth,
      recovery2Id,
      userId
    } = body

    // Token login:
    if (loginId != null && loginAuth != null) {
      const login = db.getLoginById(loginId)
      if (login == null) {
        return statusResponse(statusCodes.noAccount)
      }
      if (login.loginAuth == null || !verifyData(loginAuth, login.loginAuth)) {
        return passwordErrorResponse(0)
      }
      return withValidOtp(server)({ ...request, login })
    }

    // Password login:
    if (userId != null && passwordAuth != null) {
      const login = db.getLoginByUserId(userId)
      if (login == null) {
        return statusResponse(statusCodes.noAccount)
      }
      if (
        login.passwordAuth == null ||
        !verifyData(passwordAuth, login.passwordAuth)
      ) {
        return passwordErrorResponse(0)
      }
      return withValidOtp(server)({ ...request, login })
    }

    // PIN2 login:
    if (pin2Id != null && pin2Auth != null) {
      const login = db.getLoginByPin2Id(pin2Id)
      if (login == null) {
        return statusResponse(statusCodes.noAccount)
      }
      if (login.pin2Auth == null || !verifyData(pin2Auth, login.pin2Auth)) {
        return passwordErrorResponse(0)
      }
      return withValidOtp(server)({ ...request, login })
    }

    // Recovery2 login:
    if (recovery2Id != null && recovery2Auth != null) {
      const login = db.getLoginByRecovery2Id(recovery2Id)
      if (login == null) {
        return statusResponse(statusCodes.noAccount)
      }
      const serverAuth = login.recovery2Auth
      const clientAuth = recovery2Auth
      if (serverAuth == null || clientAuth.length !== serverAuth.length) {
        return passwordErrorResponse(0)
      }
      for (let i = 0; i < clientAuth.length; ++i) {
        if (!verifyData(clientAuth[i], serverAuth[i])) {
          return passwordErrorResponse(0)
        }
      }
      return withValidOtp(server)({ ...request, login })
    }

    return fallback(request)
  }

// login v2: ---------------------------------------------------------------

const loginRoute = withLogin2(
  // Authenticated version:
  request => {
    const { db, login } = request
    return payloadResponse(wasLoginPayload(makeLoginPayload(db, login)))
  },
  // Fallback version:
  request => {
    const { db, json } = request
    const clean = asLoginRequestBody(json)
    const { userId, passwordAuth, recovery2Id, recovery2Auth } = clean

    if (userId != null && passwordAuth == null) {
      const login = db.getLoginByUserId(userId)
      if (login == null) {
        return statusResponse(statusCodes.noAccount)
      }
      const { loginId, passwordAuthSnrp = userIdSnrp } = login
      return payloadResponse(
        wasUsernameInfoPayload({ loginId, passwordAuthSnrp })
      )
    }
    if (recovery2Id != null && recovery2Auth == null) {
      const login = db.getLoginByRecovery2Id(recovery2Id)
      if (login == null) {
        return statusResponse(statusCodes.noAccount)
      }
      const { question2Box } = login
      if (question2Box == null) {
        return statusResponse(statusCodes.noAccount)
      }
      return payloadResponse(wasRecovery2InfoPayload({ question2Box }))
    }
    return statusResponse(statusCodes.invalidRequest)
  }
)

function createLogin(request, login) {
  const { db, json } = request
  const date = new Date()

  const [body, bodyError] = cleanRequest(asLoginRequestBody, json)
  if (body == null) return bodyError
  const [clean, cleanError] = cleanRequest(asCreateLoginPayload, body.data)
  if (clean == null) return cleanError
  const [secret, secretError] = cleanRequest(asChangeSecretPayload, body.data)
  if (secret == null) return secretError

  // Do not re-create accounts:
  if (db.getLoginById(clean.loginId) != null) {
    return statusResponse(statusCodes.accountExists)
  }

  // Set up repos:
  const keys = asMaybe(asCreateKeysPayload, () => ({
    newSyncKeys: [],
    keyBoxes: []
  }))(body.data)
  for (const syncKey of keys.newSyncKeys) {
    db.repos.set(syncKey, {})
  }

  // Start building the new database row:
  const row = {
    // Required fields:
    ...clean,
    ...secret,
    created: date,
    keyBoxes: keys.keyBoxes.map(box => ({ ...box, created: date })),
    vouchers: [],

    // Optional fields:
    ...asMaybe(asChangeOtpPayload)(body.data),
    ...asMaybe(asChangePasswordPayload)(body.data),
    ...asMaybe(asChangePin2Payload)(body.data),
    ...asMaybe(asChangeRecovery2Payload)(body.data),
    ...asMaybe(asChangeUsernamePayload)(body.data)
  }

  // Set up the parent/child relationship:
  if (login != null) {
    const children = db.getLoginsByParent(login)
    const appIdExists =
      children.find(child => child.appId === clean.appId) != null
    if (appIdExists) {
      return statusResponse(statusCodes.invalidAppId)
    }
    row.parentId = login.loginId
  }
  db.insertLogin(row)

  return statusResponse(statusCodes.created, 'Account created')
}

const createLoginRoute = withLogin2(
  request => createLogin(request, request.login),
  request => createLogin(request)
)

const addKeysRoute = withLogin2(request => {
  const { db, login, payload } = request
  const date = new Date()

  const [clean, cleanError] = cleanRequest(asCreateKeysPayload, payload)
  if (clean == null) return cleanError

  // Set up repos:
  for (const syncKey of clean.newSyncKeys) {
    db.repos.set(syncKey, {})
  }
  login.keyBoxes = softCat(
    login.keyBoxes,
    clean.keyBoxes.map(box => ({ ...box, created: date }))
  )

  return statusResponse()
})

const changeOtpRoute = withLogin2(request => {
  const { login, payload } = request
  const [clean, cleanError] = cleanRequest(asChangeOtpPayload, payload)
  if (clean == null) return cleanError

  login.otpKey = clean.otpKey
  login.otpTimeout = clean.otpTimeout
  login.otpResetDate = undefined

  return statusResponse()
})

const deleteOtpRoute = withLogin2(
  // Authenticated version:
  request => {
    const { login } = request
    login.otpKey = undefined
    login.otpResetAuth = undefined
    login.otpResetDate = undefined
    login.otpTimeout = undefined

    return statusResponse()
  },
  // Fallback version:
  request => {
    const { db, json } = request
    const clean = asLoginRequestBody(json)
    if (clean.userId == null || clean.otpResetAuth == null) {
      return statusResponse(statusCodes.invalidRequest)
    }
    const login = db.getLoginByUserId(clean.userId)
    if (login == null) {
      return statusResponse(statusCodes.noAccount)
    }
    if (clean.otpResetAuth !== login.otpResetAuth) {
      return passwordErrorResponse(0)
    }
    const { otpKey, otpTimeout } = login
    if (otpKey == null || otpTimeout == null) {
      return statusResponse(
        statusCodes.invalidRequest,
        'OTP not setup for this account.'
      )
    }
    if (login.otpResetDate == null) {
      login.otpResetDate = new Date(Date.now() + 1000 * otpTimeout)
    }
    return payloadResponse(
      wasOtpResetPayload({ otpResetDate: login.otpResetDate })
    )
  }
)

const deletePasswordRoute = withLogin2(request => {
  const { login } = request
  login.passwordAuth = undefined
  login.passwordAuthBox = undefined
  login.passwordAuthSnrp = undefined
  login.passwordBox = undefined
  login.passwordKeySnrp = undefined

  return statusResponse()
})

const changePasswordRoute = withLogin2(request => {
  const { login, payload } = request
  const [clean, cleanError] = cleanRequest(asChangePasswordPayload, payload)
  if (clean == null) return cleanError

  login.passwordAuth = clean.passwordAuth
  login.passwordAuthBox = clean.passwordAuthBox
  login.passwordAuthSnrp = clean.passwordAuthSnrp
  login.passwordBox = clean.passwordBox
  login.passwordKeySnrp = clean.passwordKeySnrp

  return statusResponse()
})

const deletePin2Route = withLogin2(request => {
  const { login } = request
  login.pin2Auth = undefined
  login.pin2Box = undefined
  login.pin2Id = undefined
  login.pin2KeyBox = undefined
  login.pin2TextBox = undefined

  return statusResponse()
})

const changePin2Route = withLogin2(request => {
  const { login, payload } = request
  const [clean, cleanError] = cleanRequest(asChangePin2Payload, payload)
  if (clean == null) return cleanError

  login.pin2Auth = clean.pin2Auth
  login.pin2Box = clean.pin2Box
  login.pin2Id = clean.pin2Id
  login.pin2KeyBox = clean.pin2KeyBox
  login.pin2TextBox = clean.pin2TextBox

  return statusResponse()
})

const deleteRecovery2Route = withLogin2(request => {
  const { login } = request
  login.question2Box = undefined
  login.recovery2Auth = undefined
  login.recovery2Box = undefined
  login.recovery2Id = undefined
  login.recovery2KeyBox = undefined

  return statusResponse()
})

const changeRecovery2Route = withLogin2(request => {
  const { login, payload } = request
  const [clean, cleanError] = cleanRequest(asChangeRecovery2Payload, payload)
  if (clean == null) return cleanError

  login.question2Box = clean.question2Box
  login.recovery2Auth = clean.recovery2Auth
  login.recovery2Box = clean.recovery2Box
  login.recovery2Id = clean.recovery2Id
  login.recovery2KeyBox = clean.recovery2KeyBox

  return statusResponse()
})

const secretRoute = withLogin2(request => {
  const { db, login, payload } = request
  const [clean, cleanError] = cleanRequest(asChangeSecretPayload, payload)
  if (clean == null) return cleanError

  // Do a quick sanity check:
  if (login.loginAuth != null) {
    return statusResponse(
      statusCodes.conflict,
      'The secret-key login is already configured'
    )
  }

  login.loginAuth = clean.loginAuth
  login.loginAuthBox = clean.loginAuthBox

  return payloadResponse(wasLoginPayload(makeLoginPayload(db, login)))
})

const usernameRoute = withLogin2(async request => {
  const { db, login, payload } = request

  const cleanPassword = asMaybe(asChangePasswordPayload)(payload)
  const cleanPin2 = asMaybe(asChangePin2IdPayload)(payload)
  const cleanRecovery2 = asMaybe(asChangeRecovery2IdPayload)(payload)
  const cleanUsername = asMaybe(asChangeUsernamePayload)(payload)

  // Validate the payload selection:
  if (login.passwordAuth != null && cleanPassword == null) {
    return statusResponse(
      statusCodes.invalidRequest,
      'Missing password payload'
    )
  }
  if (login.pin2Auth != null && cleanPin2 == null) {
    return statusResponse(statusCodes.invalidRequest, 'Missing pin2Id payload')
  }
  if (login.recovery2Auth != null && cleanRecovery2 == null) {
    return statusResponse(
      statusCodes.invalidRequest,
      'Missing recovery2Id payload'
    )
  }
  if (login.parentBox == null && cleanUsername == null) {
    return statusResponse(
      statusCodes.invalidRequest,
      'Missing username payload'
    )
  }

  // Do we have a password?
  if (cleanPassword != null) {
    login.passwordAuth = cleanPassword.passwordAuth
    login.passwordAuthBox = cleanPassword.passwordAuthBox
    login.passwordAuthSnrp = cleanPassword.passwordAuthSnrp
    login.passwordBox = cleanPassword.passwordBox
    login.passwordKeySnrp = cleanPassword.passwordKeySnrp
  }

  // Do we have a PIN?
  if (cleanPin2 != null) {
    if (login.pin2Auth == null) {
      return statusResponse(statusCodes.invalidRequest, 'Login lacks pin2')
    }
    const existing = db.getLoginByPin2Id(cleanPin2.pin2Id)
    if (existing != null) {
      return statusResponse(statusCodes.conflict)
    }
    login.pin2Id = cleanPin2.pin2Id
  }

  // Do we have recovery?
  if (cleanRecovery2 != null) {
    if (login.recovery2Auth == null) {
      return statusResponse(statusCodes.invalidRequest, 'Login lacks recovery2')
    }
    const existing = db.getLoginByRecovery2Id(cleanRecovery2.recovery2Id)
    if (existing != null) {
      return statusResponse(statusCodes.conflict)
    }
    login.recovery2Id = cleanRecovery2.recovery2Id
  }

  // Are we the root login?
  if (cleanUsername != null) {
    if (login.parentBox != null) {
      return statusResponse(
        statusCodes.invalidRequest,
        'Only top-level logins can have usernames'
      )
    }
    const existing = db.getLoginByUserId(cleanUsername.userId)
    if (existing != null) {
      return statusResponse(statusCodes.conflict)
    }
    login.userId = cleanUsername.userId
    login.userTextBox = cleanUsername.userTextBox
  }

  return payloadResponse(wasLoginPayload(makeLoginPayload(db, login)))
})

const vouchersRoute = withLogin2(async request => {
  const { db, login, payload } = request
  const [clean, cleanError] = cleanRequest(asChangeVouchersPayload, payload)
  if (clean == null) return cleanError
  const { approvedVouchers = [], rejectedVouchers = [] } = clean

  // Let's get our tasks organized:
  const table = {}
  for (const id of approvedVouchers) table[id] = 'approved'
  for (const id of rejectedVouchers) table[id] = 'rejected'

  // Grab all the rows:
  for (const voucher of login.vouchers) {
    if (table[voucher.voucherId] == null) continue
    voucher.status = table[voucher.voucherId]
  }

  return payloadResponse(wasLoginPayload(makeLoginPayload(db, login)))
})

// lobby: ------------------------------------------------------------------

const handleMissingLobby = request =>
  statusResponse(statusCodes.noLobby, `Cannot find lobby ${request.lobbyId}`)

const withLobby =
  (
    server,
    fallback = handleMissingLobby
  ) =>
  request => {
    const { db, path } = request
    const lobbyId = path.split('/')[4]
    const lobby = db.lobbies.get(lobbyId)
    return lobby != null
      ? server({ ...request, lobby, lobbyId })
      : fallback({ ...request, lobbyId })
  }

const createLobbyRoute = withLobby(
  request =>
    statusResponse(
      statusCodes.accountExists,
      `Lobby ${request.lobbyId} already exists.`
    ),
  request => {
    const { db, json, lobbyId } = request

    const [body, bodyError] = cleanRequest(asLoginRequestBody, json)
    if (body == null) return bodyError
    const [clean, cleanError] = cleanRequest(asEdgeLobbyRequest, body.data)
    if (clean == null) return cleanError

    const { timeout = 600 } = clean
    const expires = new Date(Date.now() + 1000 * timeout).toISOString()

    db.lobbies.set(lobbyId, { request: clean, replies: [], expires })
    return statusResponse()
  }
)

const updateLobbyRoute = withLobby(request => {
  const { json, lobby } = request

  const [body, bodyError] = cleanRequest(asLoginRequestBody, json)
  if (body == null) return bodyError
  const [clean, cleanError] = cleanRequest(asEdgeLobbyReply, body.data)
  if (clean == null) return cleanError

  lobby.replies.push(clean)
  return statusResponse()
})

const getLobbyRoute = withLobby(request => {
  const { lobby } = request
  return payloadResponse(wasLobbyPayload(lobby))
})

const deleteLobbyRoute = withLobby(request => {
  const { db, lobbyId } = request
  db.lobbies.delete(lobbyId)
  return statusResponse()
})

// messages: ---------------------------------------------------------------

const messagesRoute = request => {
  const { db, json } = request
  const [clean, cleanError] = cleanRequest(asLoginRequestBody, json)
  if (clean == null) return cleanError

  const { loginIds } = clean
  if (loginIds == null) return statusResponse(statusCodes.invalidRequest)

  const out = []
  for (const loginId of loginIds) {
    const login = db.getLoginById(loginId)
    if (login != null) {
      out.push({
        loginId,
        otpResetPending: login.otpResetDate != null,
        pendingVouchers: makePendingVouchers(login),
        recovery2Corrupt: false
      })
    }
  }
  return payloadResponse(wasMessagesPayload(out))
}

// sync: -------------------------------------------------------------------

const withRepo =
  (server) =>
  request => {
    const { db, path } = request
    const elements = path.split('/')
    const syncKey = elements[4]
    // const hash = elements[5]

    const repo = db.repos.get(syncKey)
    if (repo == null) {
      // This is not the auth server, so we have a different format:
      return jsonResponse({ msg: 'Hash not found' }, { status: 404 })
    }

    return server({ ...request, repo })
  }

const storeReadRoute = withRepo(request => {
  const { repo } = request
  return jsonResponse({ changes: wasEdgeRepoDump(repo) })
})

const storeUpdateRoute = withRepo(request => {
  const { json, repo } = request
  const { changes } = asStoreBody(json)
  for (const change of Object.keys(changes)) {
    repo[change] = changes[change]
  }
  return jsonResponse({
    changes: wasEdgeRepoDump(repo),
    hash: '1111111111111111111111111111111111111111'
  })
})

const asStoreBody = asObject({
  changes: asObject(asEdgeBox)
})

// info: -------------------------------------------------------------------

const infoRoute = request => {
  return jsonResponse({
    infoServers: ['https://info-fake1.edge.app'],
    syncServers: [
      'https://sync-fake1.edge.app',
      'https://sync-fake2.edge.app',
      'https://sync-fake3.edge.app'
    ]
  })
}

// router: -----------------------------------------------------------------

const urls = pickPath(
  {
    // Login v2 endpoints:
    '/api/v2/login/?': pickMethod({
      GET: withApiKey(loginRoute),
      POST: withApiKey(loginRoute)
    }),
    '/api/v2/login/create/?': pickMethod({
      POST: withApiKey(createLoginRoute),
      PUT: withApiKey(createLoginRoute)
    }),
    '/api/v2/login/keys/?': pickMethod({
      POST: withApiKey(addKeysRoute)
    }),
    '/api/v2/login/otp/?': pickMethod({
      DELETE: withApiKey(deleteOtpRoute),
      POST: withApiKey(changeOtpRoute),
      PUT: withApiKey(changeOtpRoute)
    }),
    '/api/v2/login/password/?': pickMethod({
      DELETE: withApiKey(deletePasswordRoute),
      POST: withApiKey(changePasswordRoute),
      PUT: withApiKey(changePasswordRoute)
    }),
    '/api/v2/login/pin2/?': pickMethod({
      DELETE: withApiKey(deletePin2Route),
      POST: withApiKey(changePin2Route),
      PUT: withApiKey(changePin2Route)
    }),
    '/api/v2/login/recovery2/?': pickMethod({
      DELETE: withApiKey(deleteRecovery2Route),
      POST: withApiKey(changeRecovery2Route),
      PUT: withApiKey(changeRecovery2Route)
    }),
    '/api/v2/login/secret/?': pickMethod({
      POST: withApiKey(secretRoute)
    }),
    '/api/v2/login/username/?': pickMethod({
      POST: withApiKey(usernameRoute)
    }),
    '/api/v2/login/vouchers/?': pickMethod({
      POST: withApiKey(vouchersRoute)
    }),
    '/api/v2/messages/?': pickMethod({
      POST: withApiKey(messagesRoute)
    }),

    // Lobby server endpoints:
    '/api/v2/lobby/[^/]+/?': pickMethod({
      DELETE: withApiKey(deleteLobbyRoute),
      GET: withApiKey(getLobbyRoute),
      POST: withApiKey(updateLobbyRoute),
      PUT: withApiKey(createLobbyRoute)
    }),

    // Sync server endpoints:
    '/api/v2/store/[^/]+/?': pickMethod({
      GET: storeReadRoute,
      POST: storeUpdateRoute
    }),

    // Info server endpoints:
    '/v1/edgeServers': pickMethod({
      GET: infoRoute
    })
  },
  request =>
    statusResponse(statusCodes.notFound, `Unknown API endpoint ${request.path}`)
)

/**
 * Binds the fake server to a particular db instance.
 */
export function makeFakeServer(
  db
) {
  const serveRequest = request => {
    if (out.offline) throw new Error('Fake network error')
    const json =
      request.body.byteLength > 0
        ? JSON.parse(utf8.stringify(new Uint8Array(request.body)))
        : {}
    return urls({ ...request, db, json })
  }
  const out = addHiddenProperties(serveRequest, { offline: false })
  return out
}
