# frozen_string_literal: true

# eSIM Atlas Partner API SDK (Ruby)
#
# A lightweight, zero-dependency Ruby SDK for the eSIM Atlas Partner API.
# Requires Ruby 3.0+ and only uses the standard library (net/http, json, openssl).
#
# Handles OAuth 2.0 client-credentials authentication automatically: tokens are
# cached in memory and refreshed transparently 60 seconds before expiry.
#
# @version 1.0.0
# @license Proprietary
# @see https://docs.gstarlink.com
#
# Usage:
#   require_relative 'esim_atlas_sdk'
#
#   client = EsimAtlas::Client.new(
#     client_id: 'sk_live_abc123',
#     client_secret: 'your_secret_here'
#   )
#   products = client.list_products(region: 'Asia')
#   products['data'].each { |p| puts p['name'] }

require 'net/http'
require 'json'
require 'openssl'
require 'uri'

module EsimAtlas
  # Raised for API errors (RFC 7807 Problem Detail responses).
  class ApiError < StandardError
    attr_reader :status, :type, :title, :detail, :instance, :errors

    def initialize(problem)
      @status = problem['status'].to_i
      @type = problem['type'].to_s
      @title = problem['title'].to_s
      @detail = problem['detail'].to_s
      @instance = problem['instance']
      @errors = problem['errors']
      super(@detail.empty? ? @title : @detail)
    end
  end

  # Raised for OAuth token-endpoint failures.
  class OAuthError < StandardError
    attr_reader :code, :status

    def initialize(status, code, description)
      @status = status
      @code = code
      super(description.to_s.empty? ? code : description)
    end
  end

  # eSIM Atlas Partner API client.
  class Client
    DEFAULT_BASE_URL = 'https://api.gstarlink.com'
    DEFAULT_SCOPE = 'read:products write:orders read:orders read:esims ' \
                    'write:webhooks read:webhooks'
    TOKEN_EXPIRY_BUFFER = 60 # refresh 60s before expiry

    # @param client_id     [String] OAuth client_id (API key)
    # @param client_secret [String] OAuth client_secret
    # @param base_url      [String] API base URL (defaults to https://api.gstarlink.com)
    # @param scope         [String] OAuth scopes (defaults to all scopes)
    def initialize(client_id:, client_secret:, base_url: nil, scope: nil)
      @client_id = client_id
      @client_secret = client_secret
      @base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, '')
      @scope = scope || DEFAULT_SCOPE
      @access_token = nil
      @token_expires_at = 0.0
    end

    # -------------------------------------------------------------------------
    # Products
    # -------------------------------------------------------------------------

    # List available eSIM products (region, type, status, sort_by, sort_order, limit, cursor).
    def list_products(**params)
      request('GET', '/api/v1/products', query: params)
    end

    # Get detailed information about a specific product.
    def get_product(id)
      request('GET', "/api/v1/products/#{encode(id)}")
    end

    # -------------------------------------------------------------------------
    # Orders
    # -------------------------------------------------------------------------

    # Create a new order. The idempotency key prevents duplicate orders —
    # reusing the same key returns the original order.
    def create_order(data, idempotency_key)
      request('POST', '/api/v1/orders', body: data, headers: { 'Idempotency-Key' => idempotency_key })
    end

    # List your orders (status, limit, cursor).
    def list_orders(**params)
      request('GET', '/api/v1/orders', query: params)
    end

    # Get a single order with full fulfillment details.
    def get_order(id)
      request('GET', "/api/v1/orders/#{encode(id)}")
    end

    # Get eSIM delivery details (ICCID, LPA code, QR URL) for a fulfilled order.
    def get_order_esims(order_id)
      request('GET', "/api/v1/orders/#{encode(order_id)}/esims")
    end

    # Get the activation status for all scheduled activations on an order.
    def get_activation_status(order_id)
      request('GET', "/api/v1/orders/#{encode(order_id)}/activation-status")
    end

    # Create up to 100 orders in a single request. Each order is processed
    # independently; individual failures do not affect the rest of the batch.
    def create_bulk_orders(data)
      request('POST', '/api/v1/orders/bulk', body: data)
    end

    # -------------------------------------------------------------------------
    # Activations (Physical SIM)
    # -------------------------------------------------------------------------

    # Submit a physical SIM activation request with KYC data. The carrier is
    # auto-detected from the ICCID prefix.
    def submit_activation(data)
      request('POST', '/api/v1/activations', body: data)
    end

    # List your physical SIM activation requests (iccid, status, limit).
    def list_activations(**params)
      request('GET', '/api/v1/activations', query: params)
    end

    # Get detailed status of a specific activation request.
    def get_activation(id)
      request('GET', "/api/v1/activations/#{encode(id)}")
    end

    # -------------------------------------------------------------------------
    # eSIMs
    # -------------------------------------------------------------------------

    # Check activation status, data usage, and current location of an eSIM.
    def get_esim_status(iccid)
      request('GET', "/api/v1/esims/#{encode(iccid)}/status")
    end

    # -------------------------------------------------------------------------
    # Inventory
    # -------------------------------------------------------------------------

    # Check real-time stock and availability (product_ids, skus, region).
    # Current pre-funded wallet balance per currency. Production orders are
    # charged to this wallet and fail with 402 if it is underfunded.
    def get_balance
      request('GET', '/api/v1/balance')
    end

    def get_inventory_status(**params)
      request('GET', '/api/v1/inventory/status', query: params)
    end

    # -------------------------------------------------------------------------
    # Webhooks
    # -------------------------------------------------------------------------

    # Register a webhook endpoint. The signing secret is returned only once —
    # store it securely.
    def create_webhook(data)
      request('POST', '/api/v1/webhooks', body: data)
    end

    # List your registered webhooks (status, environment, limit, cursor).
    def list_webhooks(**params)
      request('GET', '/api/v1/webhooks', query: params)
    end

    # Get detailed information about a specific webhook.
    def get_webhook(id)
      request('GET', "/api/v1/webhooks/#{encode(id)}")
    end

    # Delete a webhook registration. This action is irreversible.
    def delete_webhook(id)
      request('DELETE', "/api/v1/webhooks/#{encode(id)}")
    end

    # -------------------------------------------------------------------------
    # Catalog
    # -------------------------------------------------------------------------

    # Export the product catalog formatted for e-commerce platforms
    # (format, markup, region, type, include_images).
    def sync_catalog(**params)
      request('GET', '/api/v1/catalog/sync', query: params)
    end

    private

    def encode(value)
      URI.encode_www_form_component(value.to_s)
    end

    # Obtain or refresh the OAuth access token (cached until 60s before expiry).
    def token
      return @access_token if @access_token && Time.now.to_f < @token_expires_at

      status, body = raw_request(
        'POST',
        "#{@base_url}/api/v1/oauth/token",
        { 'Content-Type' => 'application/json', 'Accept' => 'application/json' },
        JSON.generate(
          grant_type: 'client_credentials',
          client_id: @client_id,
          client_secret: @client_secret,
          scope: @scope
        )
      )

      unless (200..299).cover?(status)
        code = body.is_a?(Hash) ? body['error'] : nil
        desc = body.is_a?(Hash) ? body['error_description'] : nil
        raise OAuthError.new(status, code || 'server_error', desc || "HTTP #{status}")
      end

      @access_token = body['access_token']
      @token_expires_at = Time.now.to_f + body['expires_in'].to_i - TOKEN_EXPIRY_BUFFER
      @access_token
    end

    def request(method, path, query: nil, body: nil, headers: nil)
      url = "#{@base_url}#{path}"
      if query && !query.empty?
        filtered = query.reject { |_, v| v.nil? }
        url += "?#{URI.encode_www_form(filtered)}" unless filtered.empty?
      end

      req_headers = { 'Authorization' => "Bearer #{token}", 'Accept' => 'application/json' }
      req_headers.merge!(headers) if headers

      payload = nil
      unless body.nil?
        req_headers['Content-Type'] = 'application/json'
        payload = JSON.generate(body)
      end

      status, decoded = raw_request(method, url, req_headers, payload)

      return nil if status == 204

      unless (200..299).cover?(status)
        if decoded.is_a?(Hash) && decoded.key?('type') && decoded.key?('status')
          raise ApiError.new(decoded)
        end

        raise ApiError.new(
          'type' => 'https://docs.gstarlink.com/errors/unknown',
          'title' => "HTTP #{status}",
          'status' => status,
          'detail' => decoded.nil? ? '' : JSON.generate(decoded)
        )
      end

      decoded
    end

    # Perform a raw HTTP request. Returns [status_code, decoded_json_or_nil].
    def raw_request(method, url, headers, body)
      uri = URI.parse(url)
      klass = Net::HTTP.const_get(method.capitalize)
      req = klass.new(uri)
      headers.each { |k, v| req[k] = v }
      req.body = body if body

      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = uri.scheme == 'https'
      http.read_timeout = 30
      res = http.request(req)

      decoded = res.body.nil? || res.body.empty? ? nil : (JSON.parse(res.body) rescue nil)
      [res.code.to_i, decoded]
    end
  end

  # Verify the authenticity of an incoming webhook request.
  #
  # The signature header format is: `t=<timestamp>,v1=<hmac_sha256_hex>`.
  # Computes HMAC-SHA256 of `<timestamp>.<payload>` with the signing secret and
  # compares it against the provided signature in constant time.
  #
  # @param payload   [String] Raw request body
  # @param signature [String] Value of the `X-Webhook-Signature` header
  # @param secret    [String] Webhook signing secret (from create_webhook)
  # @return [Boolean]
  def self.verify_webhook_signature(payload, signature, secret)
    parts = {}
    signature.split(',').each do |segment|
      idx = segment.index('=')
      parts[segment[0...idx]] = segment[(idx + 1)..] if idx && idx.positive?
    end

    timestamp = parts['t']
    signature_hex = parts['v1']
    return false if timestamp.nil? || signature_hex.nil?

    expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{payload}")
    secure_compare(expected, signature_hex)
  end

  # Constant-time string comparison. Uses the native implementation when
  # available (openssl >= 2.2), otherwise falls back to comparing HMAC
  # digests of both values — equality of digests implies equality of the
  # originals without leaking timing information about where they differ.
  def self.secure_compare(a, b)
    if OpenSSL.respond_to?(:fixed_length_secure_compare)
      begin
        return OpenSSL.fixed_length_secure_compare(a, b)
      rescue ArgumentError
        # Raised when lengths differ.
        return false
      end
    end
    key = OpenSSL::Random.random_bytes(32)
    OpenSSL::HMAC.digest('SHA256', key, a) == OpenSSL::HMAC.digest('SHA256', key, b)
  end
  private_class_method :secure_compare
end
