import Crypto from 'crypto'
import fs from 'fs'
import path from 'path'
import Request from 'request'
import Xml2js from 'xml2js'
import * as Config from './config'
import * as Save from './save-xml'

/**
 * SOAP management (sending, receiving, parsing) class for ONVIF modules.
 */
export default class Soap {
  HTTP_TIMEOUT = 3000

  private username: string = null
  private password: string = null

  /**
   * Internal method for parsing SOAP responses.
   * @param soap The XML to parse.
   */
  private parse(soap: string) {
    return new Promise<{ raw?: boolean; soap?: string; parsed?: any }>((resolve, reject) => {
      const prefix = soap.substring(0, 2)
      if (prefix === '--') {
        // this is a multi-part xml with attachment
        // it is up to the receiver to parse. This is
        // usually from GetSystemLog and the binary
        // part could be anything. Reference your
        // camera specs for dealing with it.
        resolve({ raw: true, soap })
      } else {
        const opts = {
          explicitRoot: false,
          explicitArray: false,
          // 'ignoreAttrs'      : true,
          ignoreAttrs: false,
          tagNameProcessors: [
            function (name: string) {
              // strip namespaces
              /* eslint-disable no-useless-escape */
              const m = name.match(/^([^\:]+)\:([^\:]+)$/)
              /* eslint-enable no-useless-escape */
              return m ? m[2] : name
            }
          ]
        }

        // console.log(soap)
        Xml2js.parseString(soap, opts, (error, results) => {
          if (error) {
            error['soap'] = soap
            reject(error)
          } else {
            resolve({ parsed: results, soap })
          }
        })
      }
    })
  }

  /**
   * Internal method used by the module classes.
   * @param params Object containing required parameters to create a SOAP request.
   * @param params.body Description in the &lt;s:Body&gt; of the generated xml.
   * @param params.xmlns A list of xmlns attributes used in the body
   *            e.g., xmlns:tds="http://www.onvif.org/ver10/device/wsdl".
   * @param params.diff Time difference [ms].
   * @param params.username The user name.
   * @param params.password The user Password.
   * @param params.subscriptionId
   */
  createRequest(params: {
    body: string
    xmlns: string[]
    diff: number
    username?: string
    password?: string
    subscriptionId?: any
  }) {
    let soap = ''

    soap += '<?xml version="1.0" encoding="UTF-8"?>'
    soap += '<s:Envelope'
    soap += ' xmlns:s="http://www.w3.org/2003/05/soap-envelope"'
    soap += ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
    soap += ' xmlns:xsd="http://www.w3.org/2001/XMLSchema"'
    soap += ' xmlns:wsa5="http://www.w3.org/2005/08/addressing"'
    soap += ' xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"'
    soap += ' xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"'
    soap += ' xmlns:tt="http://www.onvif.org/ver10/schema"'
    soap += ' xmlns:ter="http://www.onvif.org/ver10/error"'

    if (params.xmlns && Array.isArray(params.xmlns)) {
      params.xmlns.forEach(ns => {
        // make sure we don't add it twice (hikvision will fail)
        const index = soap.indexOf(ns)
        if (index < 0) {
          soap += ' ' + ns
        }
      })
    }
    soap += '>'
    soap += '<s:Header>'

    if (params.subscriptionId) {
      const address = this.getAddress(params.subscriptionId)
      if (address) {
        soap += '<wsa5:To s:mustUnderstand="true">'
        soap += address
        soap += '</wsa5:To>'
      }
    }

    // soap += '<wsa5:Action s:mustUnderstand="1">'
    // soap += 'http://www.onvif.org/ver10/events/wsdl/EventPortType/CreatePullPointSubscriptionRequest'
    // soap += '</wsa5:Action>'
    // soap += '<wsa5:MessageID>'
    // soap += 'urn:uuid:cca999f8-b0e1-4e4e-ac7e-04a074d49fbf'
    // soap += '</wsa5:MessageID>'
    soap += '<wsa5:ReplyTo>'
    soap += '<wsa5:Address>http://www.w3.org/2005/08/addressing/anonymous</wsa5:Address>'
    soap += '</wsa5:ReplyTo>'

    if (params.username) {
      this.username = params.username
      this.password = params.password || ''
      soap += this.createUserToken(params.diff, params.username, params.password)
    }

    soap += '</s:Header>'
    soap += '<s:Body>' + params.body + '</s:Body>'
    soap += '</s:Envelope>'

    return soap.replace(/\>\s+\</g, '><')
  }

  /**
   * Send a SOAP request to the specified serviceAddress.
   * @param service The service name.
   * @param serviceAddress The service address.
   * @param methodName The request name.
   * @param soapEnvelope The request SOAP envelope.
   */
  async makeRequest(service: string, serviceAddress: { href: string }, methodName: string, soapEnvelope: string) {
    Save.saveXml(service, methodName + '.Request', soapEnvelope)

    const xml = await this.runRequest(service, serviceAddress, methodName, soapEnvelope)
    const response = await this.parse(xml)

    if (response.raw) {
      Save.saveXml(service, methodName + '.Response', response.soap)
      return response
    }

    const fault = this.getFault(response.parsed)

    if (fault) {
      Save.saveXml(service, methodName + '.Error', xml)
      const err = new Error(`${methodName}`)
      err['fault'] = fault
      err['soap'] = xml
      throw err
    } else {
      const parsed = this.parseResponse(methodName, response.parsed)

      if (parsed) {
        const res = {
          soap: xml,
          schemas: parsed.$ || '',
          data: parsed
        }

        Save.saveXml(service, methodName + '.Response', xml)
        return res
      } else {
        throw new Error(methodName + ':The device seems to not support the ' + methodName + '() method.')
      }
    }
  }

  /**
   * Internal method to send a SOAP request.
   * @param service The service.
   * @param serviceAddress The service address.
   * @param methodName The request name.
   * @param soapEnvelope The request SOAP envelope.
   */
  private runRequest(service: string, serviceAddress: { href: string }, methodName: string, soapEnvelope: string) {
    return new Promise<string>((resolve, reject) => {
      if (Config.isTest()) {
        // in testing mode (for Jest)
        const testCameraType = Config.getCameraType()
        const testService = service
        let filePath = path.resolve(
          __dirname,
          `../../test/data/xml/${testCameraType}/${testService}/${methodName}.Response.xml`
        )
        // see if the file exists
        if (!fs.existsSync(filePath)) {
          // see if there is an Error file
          filePath = path.resolve(
            __dirname,
            `../../test/data/xml/${testCameraType}/${testService}/${methodName}.Error.xml`
          )
        }

        if (!fs.existsSync(filePath)) {
          throw new Error(`File does not exist for test: ${filePath}`)
        }

        // it's good, read it in
        const xml = fs.readFileSync(filePath, 'utf8')
        resolve(xml)
      } else {
        // some cameras enable HTTP digest or digest realm,
        // so using 'Request' to handle this for us.
        const options = {
          method: 'POST',
          uri: serviceAddress.href,
          // gzip: true,
          encoding: 'utf8',
          headers: {
            'Content-Type': 'application/soap+xml; charset=utf-8;',
            'Content-Length': Buffer.byteLength(soapEnvelope)
          },
          body: soapEnvelope,
          auth: {
            user: this.username,
            pass: this.password,
            sendImmediately: false
          }
        }

        Request(options, (error, response, body) => {
          if (error) {
            console.error(error)
            reject(error)
          } else {
            if (response.statusCode === 200) {
              resolve(body)
            } else {
              reject(response)
            }
          }
        })
      }
    })
  }

  private parseResponse(methodName: string, response: { Body?: any }) {
    const s0 = response.Body
    if (!s0) {
      return null
    }
    const responseName = methodName + 'Response'
    if (responseName in s0) {
      return s0
    } else {
      return null
    }
  }

  /**
   * Parses results to see if there is a fault.
   * @param results The results of a communication with a server.
   */
  getFault(results: { Body: { Fault?: any } }) {
    if (results.Body?.Fault) {
      const bodyFault = results.Body.Fault
      const r1 = this.parseForReason(bodyFault)
      const c1 = this.parseForCode(bodyFault)
      const d1 = this.parseForDetail(bodyFault)
      return {
        reason: r1,
        code: c1,
        detail: d1
      }
    }
  }

  parseForCode(fault: any) {
    let code = ''
    if ('Code' in fault) {
      const faultCode = fault.Code
      if ('Value' in faultCode) {
        const faultValue = faultCode.Value
        if ('Subcode' in faultCode) {
          const faultSubcode = faultCode.Subcode
          if ('Value' in faultSubcode) {
            const faultSubcodeValue = faultSubcode.Value
            code = faultValue + '|' + faultSubcodeValue
          } else {
            code = faultSubcode
          }
        } else {
          code = faultValue
        }
      }
    }

    return code
  }

  parseForDetail(fault: any) {
    let detail = ''
    if ('Detail' in fault) {
      const faultDetail = fault.Detail
      if ('Text' in faultDetail) {
        const faultText = faultDetail.Text
        if (typeof faultText === 'string') {
          detail = faultText
        } else if (typeof faultText === 'object' && '_' in faultText) {
          detail = faultText._
        }
      }
    }

    return detail
  }

  parseForReason(fault: any) {
    let reason = ''

    if ('Reason' in fault) {
      const faultReason = fault.Reason
      if ('Text' in faultReason) {
        const faultText = faultReason.Text
        if (typeof faultText === 'string') {
          reason = faultText
        } else if (typeof faultText === 'object' && '_' in faultText) {
          reason = faultText._
        }
      }
    } else if ('faultstring' in fault) {
      reason = fault.faultstring
    }

    return reason
  }

  /**
   * Internal method used to create the user token xml.
   * @param diff The server timeDiff [ms].
   * @param user The user name.
   * @param pass The user password.
   */
  createUserToken(diff: number, user: string, pass?: string) {
    if (!diff) {
      diff = 0
    }

    if (!pass) {
      pass = ''
    }

    const created = new Date(Date.now() + diff).toISOString()

    // 10 second expiry
    const expires = new Date(Date.now() + diff + 10000).toISOString()
    const nonceBuffer = this.createNonce(16)
    const nonceBase64 = nonceBuffer.toString('base64')
    const shasum = Crypto.createHash('sha1')

    shasum.update(Buffer.concat([nonceBuffer, Buffer.from(created), Buffer.from(pass)]))
    const digest = shasum.digest('base64')

    let soap = ''
    soap += '<wsse:Security s:mustUnderstand="1">'
    soap += '  <wsu:Timestamp wsu:Id="Time">'
    soap += '    <wsu:Created>' + created + '</wsu:Created>'
    soap += '    <wsu:Expires>' + expires + '</wsu:Expires>'
    soap += '  </wsu:Timestamp>'
    soap += '  <wsse:UsernameToken wsu:Id="User">'
    soap += '    <wsse:Username>' + user + '</wsse:Username>'
    soap +=
      '    <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">' +
      digest +
      '</wsse:Password>'
    soap +=
      '    <wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">' +
      nonceBase64 +
      '</wsse:Nonce>'
    soap +=
      '    <wsu:Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">' +
      created +
      '</wsu:Created>'
    soap += '  </wsse:UsernameToken>'
    soap += '</wsse:Security>'
    return soap
  }

  createNonce(digit: number) {
    const nonce = Buffer.alloc(digit)
    for (let i = 0; i < digit; i++) {
      nonce.writeUInt8(Math.floor(Math.random() * 256), i)
    }
    return nonce
  }

  getAddress(subscriptionid: { Address?: string }) {
    if (subscriptionid) {
      if (subscriptionid.Address) {
        return subscriptionid.Address
      }
    }
    return null
  }

  getCustomSubscriptionIdXml(subscriptionId: { _?: string; $?: any }) {
    if (subscriptionId) {
      if (subscriptionId._) {
        const id = subscriptionId._
        let xml: string | null = null
        if (subscriptionId.$) {
          const keys = Object.keys(subscriptionId.$)
          const tag = keys[0]
          const url = subscriptionId.$[tag]
          if (id && tag && url) {
            const tags = tag.split(':')
            // don't need Action tag
            xml =
              '<SubscriptionId s:mustUnderstand="1" s:IsReferenceParameter="1" ' +
              tags[0] +
              '="' +
              url +
              '">' +
              id +
              '</SubscriptionId>'
            // console.log(xml)
          }
        }
        return xml
      }
    }
    return null
  }
}
