// SPDX-FileCopyrightText: 2024-2025 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only

import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { LiveChatJSONLDAttributeV1, PeertubeXMPPServerInfos } from './types'
import { URL } from 'url'

/**
 * Use this function for incoming remote informations.
 * It will sanitize them, by checking everything is ok.
 * It can also migrate from old format to the new one.
 *
 * This function can be used when informations are incoming,
 * or when reading stored information (to automatically migrate them)
 *
 * @param options server options
 * @param chatInfos remote chat informations
 * @param referenceUrl optional url string. If given, we must check that urls are on the same domain, to avoid spoofing.
 * @returns a sanitized version of the remote chat informations
 */
function sanitizePeertubeLiveChatInfos (
  options: RegisterServerOptions,
  chatInfos: any,
  referenceUrl?: string
): LiveChatJSONLDAttributeV1 {
  if (chatInfos === false) { return false }
  if (typeof chatInfos !== 'object') { return false }

  if (chatInfos.type !== 'xmpp') { return false }
  if ((typeof chatInfos.jid) !== 'string') { return false }

  if (!('xmppserver' in chatInfos)) {
    // V0 format, migrating on the fly to v1.
    return _sanitizePeertubeLiveChatInfosV0(options, chatInfos, referenceUrl)
  }

  if (!chatInfos.xmppserver || (typeof chatInfos.xmppserver !== 'object')) {
    return false
  }
  const xmppserver = sanitizePeertubeLiveChatServerInfos(options, chatInfos.xmppserver, referenceUrl)
  if (!xmppserver) { return false }

  let customEmojisUrl: string | undefined
  if (('customEmojisUrl' in chatInfos) && chatInfos.customEmojisUrl) {
    customEmojisUrl = sanitizeCustomEmojisUrl(options, chatInfos.customEmojisUrl, referenceUrl)
  }

  const r: LiveChatJSONLDAttributeV1 = {
    type: chatInfos.type,
    jid: chatInfos.jid,
    xmppserver,
    customEmojisUrl
  }

  return r
}

/**
 * Use this function for incoming remote server informations.
 * It will sanitize them, by checking everything is ok.
 * It can also migrate from old format to the new one.
 *
 * This function can be used when informations are incoming,
 * or when reading stored information (to automatically migrate them)
 * @param options server options
 * @param xmppserver remote server information
 * @param referenceUrl optional url string. If given, we must check that urls are on the same domain, to avoid spoofing.
 * @returns a sanitized version of remote server information (or false if not valid)
 */
function sanitizePeertubeLiveChatServerInfos (
  options: RegisterServerOptions, xmppserver: any, referenceUrl?: string
): PeertubeXMPPServerInfos | false {
  if (!xmppserver || (typeof xmppserver !== 'object')) {
    return false
  }

  let checkHost: undefined | string
  if (referenceUrl) {
    checkHost = _readReferenceUrl(referenceUrl)
    if (!checkHost) {
      options.peertubeHelpers.logger.error(
        'sanitizePeertubeLiveChatServerInfos: got an invalid referenceUrl: ' + referenceUrl
      )
      return false
    }
  }

  if ((typeof xmppserver.host) !== 'string') { return false }
  const host = _validateHost(xmppserver.host, checkHost)
  if (!host) { return false }
  if ((typeof xmppserver.muc) !== 'string') { return false }
  const muc = _validateHost(xmppserver.muc, checkHost)
  if (!muc) { return false }

  const r: PeertubeXMPPServerInfos = {
    host,
    muc
  }

  // This comes with livechat >= 9.0.0, can be absent.
  const external = _validateHost(xmppserver.external, checkHost)
  if (external) {
    r.external = external
  }

  if (xmppserver.directs2s) {
    if ((typeof xmppserver.directs2s) === 'object') {
      const port = xmppserver.directs2s.port
      if ((typeof port === 'string') && /^\d+$/.test(port)) {
        r.directs2s = {
          port
        }
      }
    }
  }
  if (xmppserver.websockets2s) {
    if ((typeof xmppserver.websockets2s) === 'object') {
      const url = xmppserver.websockets2s.url
      if ((typeof url === 'string') && _validUrl(url, {
        noSearchParams: true,
        protocol: 'ws.',
        domain: checkHost
      })) {
        r.websockets2s = {
          url
        }
      }
    }
  }
  if (xmppserver.anonymous) {
    const virtualhost = _validateHost(xmppserver.anonymous.virtualhost, checkHost)
    if (virtualhost) {
      r.anonymous = {
        virtualhost
      }

      const bosh = xmppserver.anonymous.bosh
      if ((typeof bosh === 'string') && _validUrl(bosh, {
        noSearchParams: true,
        protocol: 'http.',
        domain: checkHost
      })) {
        r.anonymous.bosh = bosh
      }

      const websocket = xmppserver.anonymous.websocket
      if ((typeof websocket === 'string') && _validUrl(websocket, {
        noSearchParams: true,
        protocol: 'ws.',
        domain: checkHost
      })) {
        r.anonymous.websocket = websocket
      }
    }
  }

  return r
}

/**
 * Use this function for incoming custom emojis definition url.
 * It will sanitize them, by checking everything is ok.
 *
 * @param options server options
 * @param customEmojisUrl the value to test.
 * @param referenceUrl optional url string. If given, we must check that urls are on the same domain, to avoid spoofing.
 * @returns the url if valid, else undefined.
 */
function sanitizeCustomEmojisUrl (
  options: RegisterServerOptions,
  customEmojisUrl: unknown,
  referenceUrl?: string
): string | undefined {
  let checkHost: undefined | string

  if (referenceUrl) {
    checkHost = _readReferenceUrl(referenceUrl)
    if (!checkHost) {
      options.peertubeHelpers.logger.error(
        'sanitizeCustomEmojisUrl: got an invalid referenceUrl: ' + referenceUrl
      )
      return undefined
    }
  }

  if ((typeof customEmojisUrl) !== 'string') { return undefined }

  if (
    !_validUrl(customEmojisUrl, {
      noSearchParams: true,
      protocol: 'http.',
      domain: checkHost
    })
  ) {
    return undefined
  }

  // No further verification. The frontend must use this url carefully (should only get JSON data).
  return customEmojisUrl
}

interface URLConstraints {
  protocol: 'http.' | 'ws.'
  noSearchParams: boolean
  domain?: string
}

function _validUrl (s: unknown, constraints: URLConstraints): s is string {
  if (typeof s !== 'string') { return false }
  if (s === '') { return false }
  let url: URL
  try {
    url = new URL(s)
  } catch (_err) {
    return false
  }

  if (constraints.protocol) {
    if (constraints.protocol === 'http.') {
      if (url.protocol !== 'https:' && url.protocol !== 'http:') {
        return false
      }
    } else if (constraints.protocol === 'ws.') {
      if (url.protocol !== 'wss:' && url.protocol !== 'ws:') {
        return false
      }
    }
  }

  if (constraints.noSearchParams) {
    if (url.search !== '') {
      return false
    }
  }

  if (constraints.domain) {
    if (url.hostname !== constraints.domain) {
      return false
    }
  }

  return true
}

function _validateHost (s: any, mustBeSubDomainOf?: string): false | string {
  try {
    if (typeof s !== 'string') { return false }
    if (s.includes('/')) { return false }
    const url = new URL('http://' + s)
    const hostname = url.hostname
    if (mustBeSubDomainOf && hostname !== mustBeSubDomainOf) {
      const parts = hostname.split('.')
      if (parts.length <= 2) {
        return false
      }
      parts.shift()
      if (parts.join('.') !== mustBeSubDomainOf) {
        return false
      }
    }
    return hostname
  } catch (_err) {
    return false
  }
}

function _readReferenceUrl (s: unknown): undefined | string {
  try {
    if (typeof s !== 'string') { return undefined }
    if (!s.startsWith('https://') && !s.startsWith('http://')) {
      s = 'http://' + s
    }
    const url = new URL(s as string)
    const host = url.hostname
    // Just to avoid some basic spoofing, we must check that host is not simply something like "com".
    // We will check if there is at least one dot. This test is not perfect, but can limit spoofing cases.
    if (!host.includes('.')) { return undefined }
    return host
  } catch (_err) {
    return undefined
  }
}

function _sanitizePeertubeLiveChatInfosV0 (
  options: RegisterServerOptions, chatInfos: unknown, referenceUrl?: string
): LiveChatJSONLDAttributeV1 {
  const logger = options.peertubeHelpers.logger
  logger.debug('We are have to migrate data from the old JSONLD format')

  if (chatInfos === false) { return false }
  if (!_assertObjectType(chatInfos)) { return false }

  if (chatInfos.type !== 'xmpp') { return false }
  if (typeof chatInfos.jid !== 'string') { return false }

  // no link? invalid! dropping all.
  if (!Array.isArray(chatInfos.links)) { return false }

  let checkHost: undefined | string
  if (referenceUrl) {
    checkHost = _readReferenceUrl(referenceUrl)
    if (!checkHost) {
      options.peertubeHelpers.logger.error(
        '_sanitizePeertubeLiveChatInfosV0: got an invalid referenceUrl: ' + referenceUrl
      )
      return false
    }
  }

  const muc = _validateHost(chatInfos.jid.split('@')[1], checkHost)
  if (!muc) { return false }
  if (!muc.startsWith('room.')) {
    logger.error('We expected old format host to begin with "room.". Discarding.')
    return false
  }
  const host = _validateHost(muc.replace(/^room\./, ''), checkHost)
  if (!host) { return false }

  const r: LiveChatJSONLDAttributeV1 = {
    type: chatInfos.type,
    jid: chatInfos.jid,
    xmppserver: {
      host,
      muc
    }
  }

  for (const link of chatInfos.links) {
    if (!_assertObjectType(link) || (typeof link.type !== 'string')) { continue }
    if (['xmpp-bosh-anonymous', 'xmpp-websocket-anonymous'].includes(link.type)) {
      if (typeof link.jid !== 'string') { continue }
      if (typeof link.url !== 'string') { continue }

      if (
        !_validUrl(link.url, {
          noSearchParams: true,
          protocol: link.type === 'xmpp-websocket-anonymous' ? 'ws.' : 'http.',
          domain: checkHost
        })
      ) {
        continue
      }

      if (!r.xmppserver.anonymous) {
        r.xmppserver.anonymous = {
          virtualhost: link.jid
        }
      }
      if (link.type === 'xmpp-bosh-anonymous') {
        r.xmppserver.anonymous.bosh = link.url
      } else if (link.type === 'xmpp-websocket-anonymous') {
        r.xmppserver.anonymous.websocket = link.url
      }
    }
  }

  return r
}

function sanitizeXMPPHost (options: RegisterServerOptions, host: any): false | string {
  return _validateHost(host)
}

function sanitizeXMPPHostFromInstanceUrl (_options: RegisterServerOptions, s: any): false | string {
  try {
    if (typeof s !== 'string') { return false }
    const url = new URL(s)
    return url.hostname
  } catch (_err) {
    return false
  }
}

function _assertObjectType (data: unknown): data is Record<string, unknown> {
  return !!data && (typeof data === 'object') && Object.keys(data).every(k => typeof k === 'string')
}

export {
  sanitizePeertubeLiveChatInfos,
  sanitizePeertubeLiveChatServerInfos,
  sanitizeXMPPHost,
  sanitizeXMPPHostFromInstanceUrl
}
