'use strict'

const { EventEmitter } = require('node:events')
const { Buffer } = require('node:buffer')
const { InvalidArgumentError, Socks5ProxyError } = require('./errors')
const { debuglog } = require('node:util')
const { parseAddress } = require('./socks5-utils')

const debug = debuglog('undici:socks5')
const EMPTY_BUFFER = Buffer.alloc(0)

// SOCKS5 constants
const SOCKS_VERSION = 0x05

// Authentication methods
const AUTH_METHODS = {
  NO_AUTH: 0x00,
  GSSAPI: 0x01,
  USERNAME_PASSWORD: 0x02,
  NO_ACCEPTABLE: 0xFF
}

// SOCKS5 commands
const COMMANDS = {
  CONNECT: 0x01,
  BIND: 0x02,
  UDP_ASSOCIATE: 0x03
}

// Address types
const ADDRESS_TYPES = {
  IPV4: 0x01,
  DOMAIN: 0x03,
  IPV6: 0x04
}

// Reply codes
const REPLY_CODES = {
  SUCCEEDED: 0x00,
  GENERAL_FAILURE: 0x01,
  CONNECTION_NOT_ALLOWED: 0x02,
  NETWORK_UNREACHABLE: 0x03,
  HOST_UNREACHABLE: 0x04,
  CONNECTION_REFUSED: 0x05,
  TTL_EXPIRED: 0x06,
  COMMAND_NOT_SUPPORTED: 0x07,
  ADDRESS_TYPE_NOT_SUPPORTED: 0x08
}

// State machine states
const STATES = {
  INITIAL: 'initial',
  HANDSHAKING: 'handshaking',
  AUTHENTICATING: 'authenticating',
  CONNECTING: 'connecting',
  CONNECTED: 'connected',
  ERROR: 'error',
  CLOSED: 'closed'
}

/**
 * SOCKS5 client implementation
 * Handles SOCKS5 protocol negotiation and connection establishment
 */
class Socks5Client extends EventEmitter {
  constructor (socket, options = {}) {
    super()

    if (!socket) {
      throw new InvalidArgumentError('socket is required')
    }

    this.socket = socket
    this.options = options
    this.state = STATES.INITIAL
    this.buffer = EMPTY_BUFFER
    this.onSocketData = this.onData.bind(this)
    this.onSocketError = this.onError.bind(this)
    this.onSocketClose = this.onClose.bind(this)

    // Authentication settings
    this.authMethods = []
    if (options.username && options.password) {
      this.authMethods.push(AUTH_METHODS.USERNAME_PASSWORD)
    }
    this.authMethods.push(AUTH_METHODS.NO_AUTH)

    // Socket event handlers
    this.socket.on('data', this.onSocketData)
    this.socket.on('error', this.onSocketError)
    this.socket.on('close', this.onSocketClose)
  }

  /**
   * Handle incoming data from the socket
   */
  onData (data) {
    debug('received data', data.length, 'bytes in state', this.state)
    this.buffer = Buffer.concat([this.buffer, data])

    try {
      switch (this.state) {
        case STATES.HANDSHAKING:
          this.handleHandshakeResponse()
          break
        case STATES.AUTHENTICATING:
          this.handleAuthResponse()
          break
        case STATES.CONNECTING:
          this.handleConnectResponse()
          break
      }
    } catch (err) {
      this.onError(err)
    }
  }

  /**
   * Handle socket errors
   */
  onError (err) {
    debug('socket error', err)
    this.state = STATES.ERROR
    this.emit('error', err)
    this.destroy()
  }

  /**
   * Handle socket close
   */
  onClose () {
    debug('socket closed')
    this.state = STATES.CLOSED
    this.emit('close')
  }

  /**
   * Destroy the client and underlying socket
   */
  destroy () {
    if (this.socket && !this.socket.destroyed) {
      this.socket.destroy()
    }
  }

  /**
   * Start the SOCKS5 handshake
   */
  handshake () {
    if (this.state !== STATES.INITIAL) {
      throw new InvalidArgumentError('Handshake already started')
    }

    debug('starting handshake with', this.authMethods.length, 'auth methods')
    this.state = STATES.HANDSHAKING

    // Build handshake request
    // +----+----------+----------+
    // |VER | NMETHODS | METHODS  |
    // +----+----------+----------+
    // | 1  |    1     | 1 to 255 |
    // +----+----------+----------+
    const request = Buffer.alloc(2 + this.authMethods.length)
    request[0] = SOCKS_VERSION
    request[1] = this.authMethods.length
    this.authMethods.forEach((method, i) => {
      request[2 + i] = method
    })

    this.socket.write(request)
  }

  /**
   * Handle handshake response from server
   */
  handleHandshakeResponse () {
    if (this.buffer.length < 2) {
      return // Not enough data yet
    }

    const version = this.buffer[0]
    const method = this.buffer[1]

    if (version !== SOCKS_VERSION) {
      throw new Socks5ProxyError(`Invalid SOCKS version: ${version}`, 'UND_ERR_SOCKS5_VERSION')
    }

    if (method === AUTH_METHODS.NO_ACCEPTABLE) {
      throw new Socks5ProxyError('No acceptable authentication method', 'UND_ERR_SOCKS5_AUTH_REJECTED')
    }

    this.buffer = this.buffer.subarray(2)
    debug('server selected auth method', method)

    if (method === AUTH_METHODS.NO_AUTH) {
      this.emit('authenticated')
    } else if (method === AUTH_METHODS.USERNAME_PASSWORD) {
      this.state = STATES.AUTHENTICATING
      this.sendAuthRequest()
    } else {
      throw new Socks5ProxyError(`Unsupported authentication method: ${method}`, 'UND_ERR_SOCKS5_AUTH_METHOD')
    }
  }

  /**
   * Send username/password authentication request
   */
  sendAuthRequest () {
    const { username, password } = this.options

    if (!username || !password) {
      throw new InvalidArgumentError('Username and password required for authentication')
    }

    debug('sending username/password auth')

    // Username/Password authentication request (RFC 1929)
    // +----+------+----------+------+----------+
    // |VER | ULEN |  UNAME   | PLEN |  PASSWD  |
    // +----+------+----------+------+----------+
    // | 1  |  1   | 1 to 255 |  1   | 1 to 255 |
    // +----+------+----------+------+----------+
    const usernameBuffer = Buffer.from(username)
    const passwordBuffer = Buffer.from(password)

    if (usernameBuffer.length > 255 || passwordBuffer.length > 255) {
      throw new InvalidArgumentError('Username or password too long')
    }

    const request = Buffer.alloc(3 + usernameBuffer.length + passwordBuffer.length)
    request[0] = 0x01 // Sub-negotiation version
    request[1] = usernameBuffer.length
    usernameBuffer.copy(request, 2)
    request[2 + usernameBuffer.length] = passwordBuffer.length
    passwordBuffer.copy(request, 3 + usernameBuffer.length)

    this.socket.write(request)
  }

  /**
   * Handle authentication response
   */
  handleAuthResponse () {
    if (this.buffer.length < 2) {
      return // Not enough data yet
    }

    const version = this.buffer[0]
    const status = this.buffer[1]

    if (version !== 0x01) {
      throw new Socks5ProxyError(`Invalid auth sub-negotiation version: ${version}`, 'UND_ERR_SOCKS5_AUTH_VERSION')
    }

    if (status !== 0x00) {
      throw new Socks5ProxyError('Authentication failed', 'UND_ERR_SOCKS5_AUTH_FAILED')
    }

    this.buffer = this.buffer.subarray(2)
    debug('authentication successful')
    this.emit('authenticated')
  }

  /**
   * Send CONNECT command
   * @param {string} address - Target address (IP or domain)
   * @param {number} port - Target port
   */
  connect (address, port) {
    if (this.state === STATES.CONNECTED) {
      throw new InvalidArgumentError('Already connected')
    }

    debug('connecting to', address, port)
    this.state = STATES.CONNECTING

    const request = this.buildConnectRequest(COMMANDS.CONNECT, address, port)
    this.socket.write(request)
  }

  /**
   * Build a SOCKS5 request
   */
  buildConnectRequest (command, address, port) {
    // Parse address to determine type and buffer
    const { type: addressType, buffer: addressBuffer } = parseAddress(address)

    // Build request
    // +----+-----+-------+------+----------+----------+
    // |VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
    // +----+-----+-------+------+----------+----------+
    // | 1  |  1  | X'00' |  1   | Variable |    2     |
    // +----+-----+-------+------+----------+----------+
    const request = Buffer.alloc(4 + addressBuffer.length + 2)
    request[0] = SOCKS_VERSION
    request[1] = command
    request[2] = 0x00 // Reserved
    request[3] = addressType
    addressBuffer.copy(request, 4)
    request.writeUInt16BE(port, 4 + addressBuffer.length)

    return request
  }

  /**
   * Handle CONNECT response
   */
  handleConnectResponse () {
    if (this.buffer.length < 4) {
      return // Not enough data for header
    }

    const version = this.buffer[0]
    const reply = this.buffer[1]
    const addressType = this.buffer[3]

    if (version !== SOCKS_VERSION) {
      throw new Socks5ProxyError(`Invalid SOCKS version in reply: ${version}`, 'UND_ERR_SOCKS5_REPLY_VERSION')
    }

    // Calculate the expected response length
    let responseLength = 4 // VER + REP + RSV + ATYP
    if (addressType === ADDRESS_TYPES.IPV4) {
      responseLength += 4 + 2 // IPv4 + port
    } else if (addressType === ADDRESS_TYPES.DOMAIN) {
      if (this.buffer.length < 5) {
        return // Need domain length byte
      }
      responseLength += 1 + this.buffer[4] + 2 // length byte + domain + port
    } else if (addressType === ADDRESS_TYPES.IPV6) {
      responseLength += 16 + 2 // IPv6 + port
    } else {
      throw new Socks5ProxyError(`Invalid address type in reply: ${addressType}`, 'UND_ERR_SOCKS5_ADDR_TYPE')
    }

    if (this.buffer.length < responseLength) {
      return // Not enough data for full response
    }

    if (reply !== REPLY_CODES.SUCCEEDED) {
      const errorMessage = this.getReplyErrorMessage(reply)
      throw new Socks5ProxyError(`SOCKS5 connection failed: ${errorMessage}`, `UND_ERR_SOCKS5_REPLY_${reply}`)
    }

    // Parse bound address and port
    let boundAddress
    let offset = 4

    if (addressType === ADDRESS_TYPES.IPV4) {
      boundAddress = Array.from(this.buffer.subarray(offset, offset + 4)).join('.')
      offset += 4
    } else if (addressType === ADDRESS_TYPES.DOMAIN) {
      const domainLength = this.buffer[offset]
      offset += 1
      boundAddress = this.buffer.subarray(offset, offset + domainLength).toString()
      offset += domainLength
    } else if (addressType === ADDRESS_TYPES.IPV6) {
      // Parse IPv6 address from 16-byte buffer
      const parts = []
      for (let i = 0; i < 8; i++) {
        const value = this.buffer.readUInt16BE(offset + i * 2)
        parts.push(value.toString(16))
      }
      boundAddress = parts.join(':')
      offset += 16
    }

    const boundPort = this.buffer.readUInt16BE(offset)

    this.buffer = EMPTY_BUFFER
    this.state = STATES.CONNECTED
    this.socket.removeListener('data', this.onSocketData)

    debug('connected, bound address:', boundAddress, 'port:', boundPort)
    this.emit('connected', { address: boundAddress, port: boundPort })
  }

  /**
   * Get human-readable error message for reply code
   */
  getReplyErrorMessage (reply) {
    switch (reply) {
      case REPLY_CODES.GENERAL_FAILURE:
        return 'General SOCKS server failure'
      case REPLY_CODES.CONNECTION_NOT_ALLOWED:
        return 'Connection not allowed by ruleset'
      case REPLY_CODES.NETWORK_UNREACHABLE:
        return 'Network unreachable'
      case REPLY_CODES.HOST_UNREACHABLE:
        return 'Host unreachable'
      case REPLY_CODES.CONNECTION_REFUSED:
        return 'Connection refused'
      case REPLY_CODES.TTL_EXPIRED:
        return 'TTL expired'
      case REPLY_CODES.COMMAND_NOT_SUPPORTED:
        return 'Command not supported'
      case REPLY_CODES.ADDRESS_TYPE_NOT_SUPPORTED:
        return 'Address type not supported'
      default:
        return `Unknown error code: ${reply}`
    }
  }
}

module.exports = {
  Socks5Client,
  AUTH_METHODS,
  COMMANDS,
  ADDRESS_TYPES,
  REPLY_CODES,
  STATES
}
