AlkantarClanX12

Your IP : 216.73.217.24


Current Path : /www/capitalgmcbuickregina_830/public/wp-content/plugins/leadbox/classes/
Upload File :
Current File : /www/capitalgmcbuickregina_830/public/wp-content/plugins/leadbox/classes/os-api.php

<?php if (!defined('ABSPATH')) exit;

/**
 * LBT-1468 — HTTP client for the Leadbox Platform lead-intake endpoint
 * (POST {url}/api/v1/leads, Bearer auth).
 *
 * Mirrors LB_API_Client::CreateLead()'s return contract exactly — the
 * standardized { success, data, count, message, error_code, timestamp } array —
 * so the router and the Ninja Forms action can consume either client without
 * branching on which backend handled the lead.
 *
 * @see lead-intake-endpoint-contract.md (Desktop) for status/error mapping.
 */
final class LB_OS_API_Client
{
  /** Matches the legacy client's 5s budget; OS pipeline is sub-second + async fan-out. */
  const TIMEOUT_SECONDS = 5;

  private $url;
  private $token;

  public function __construct($url, $token)
  {
    $this->url   = $url;
    $this->token = $token;
  }

  public static function getInstance()
  {
    $settings = LB_API_Settings::getInstance();
    return new LB_OS_API_Client(
      $settings->getLeadboxOsLeadIntakeUrl(),
      $settings->getLeadboxOsLeadIntakeToken()
    );
  }

  /**
   * Same shape as LB_API_Client::create_response().
   */
  private function create_response($success, $data, $message = null, $error_code = null, $http_status = null, $latency_ms = null)
  {
    return array(
      'success'     => $success,
      'data'        => $data,
      'count'       => is_array($data) ? count($data) : 0,
      'message'     => $message,
      'error_code'  => $error_code,
      // LBT-1468 — surfaced for the dispatch log so the router can record the
      // real HTTP status and latency without re-deriving them.
      'http_status' => $http_status,
      'latency_ms'  => $latency_ms,
      'timestamp'   => time(),
    );
  }

  /**
   * POST a lead to Leadbox Platform.
   *
   * @param LB_Lead_Dto $lead
   * @param bool $blocking When false, fire-and-forget (used by the 'both'
   *                       dual-write mode so Leadbox Platform never blocks the legacy
   *                       authoritative POST). Non-blocking cannot read a status.
   * @return array Standardized response.
   */
  public function CreateLead($lead, $blocking = true)
  {
    if (false === ($lead instanceof LB_Lead_Dto)) {
      throw new Exception('$lead needs to be instance of Lead Object');
    }

    // The URL always resolves to a valid base (configured value or the default
    // constant), so only the per-site token can be missing.
    if (empty($this->token)) {
      $this->log_error('Leadbox Platform lead-intake not configured', array(
        'has_token' => false,
      ));
      return $this->create_response(
        false,
        null,
        'Leadbox Platform lead-intake token is not configured.',
        'os_not_configured'
      );
    }

    // LBT-1468 — resolve the vehicle's stock number from its (website) id so the
    // Platform can match the vehicle by stock, which is stable across systems.
    // Best-effort: never throws, never blocks the dispatch on failure.
    self::maybe_enrich_stock($lead);

    $payload  = LB_Lead_Mapper::toLeadboxOs($lead);
    $endpoint = $this->url . '/api/v1/leads';

    $args = array(
      'timeout'  => self::TIMEOUT_SECONDS,
      'blocking' => (bool) $blocking,
      // LBT-1468 — relax TLS verification only for loopback endpoints so local
      // dev can target a Platform over http(s)://localhost with a self-signed or
      // no certificate. Production hosts are never loopback, so they keep
      // verification on.
      'sslverify' => !LB_API_Settings::isLoopbackHost(parse_url($this->url, PHP_URL_HOST)),
      'headers'  => array(
        'Authorization' => 'Bearer ' . $this->token,
        'Content-Type'  => 'application/json',
        'Accept'        => 'application/json',
      ),
      'body' => wp_json_encode($payload),
    );

    $start   = microtime(true);
    $result  = wp_remote_post($endpoint, $args);
    $latency = (int) round((microtime(true) - $start) * 1000);

    // Transport-level failure (DNS, timeout, refused).
    if (is_wp_error($result)) {
      $this->log_error('Leadbox Platform lead-intake connection failed', array(
        'endpoint'   => $endpoint,
        'latency_ms' => $latency,
        'error'      => $result->get_error_message(),
      ));
      return $this->create_response(
        false,
        null,
        'Unable to connect to Leadbox Platform. Please try again later.',
        'network_error',
        null,
        $latency
      );
    }

    // Fire-and-forget: WordPress returns immediately with no readable status.
    if (!$blocking) {
      return $this->create_response(true, null, 'Dispatched to Leadbox Platform (non-blocking).', null);
    }

    $status  = (int) wp_remote_retrieve_response_code($result);
    $body    = wp_remote_retrieve_body($result);
    $decoded = json_decode($body, true);
    if (json_last_error() !== JSON_ERROR_NONE) {
      $decoded = array();
    }

    return $this->map_response($status, $decoded, $latency, $endpoint);
  }

  /**
   * Map an HTTP status + decoded body to the standardized response shape.
   */
  private function map_response($status, $decoded, $latency, $endpoint)
  {
    $message = isset($decoded['message']) ? $decoded['message'] : null;

    if ($status >= 200 && $status < 300) {
      $data = isset($decoded['data']) ? $decoded['data'] : $decoded;
      $this->log_info('Leadbox Platform lead dispatched', array(
        'status'     => $status,
        'latency_ms' => $latency,
      ));
      return $this->create_response(true, $data, $message, null, $status, $latency);
    }

    switch ($status) {
      case 400:
        $error_code = 'spam_rejected';
        break;
      case 401:
        $error_code = 'auth_failed';
        break;
      case 403:
        $error_code = 'location_mismatch';
        break;
      case 422:
        // OS distinguishes unresolvable-location from field validation via `error`.
        $error_code = (isset($decoded['error']) && $decoded['error'] === 'location_unresolved')
          ? 'location_unresolved'
          : 'validation_failed';
        break;
      case 429:
        $error_code = 'rate_limited';
        break;
      default:
        $error_code = ($status >= 500) ? 'server_error' : ('http_error_' . $status);
        break;
    }

    if ($message === null) {
      $message = 'Leadbox Platform rejected the lead (HTTP ' . $status . ').';
    }

    $this->log_error('Leadbox Platform lead failed', array(
      'endpoint'   => $endpoint,
      'status'     => $status,
      'error_code' => $error_code,
      'latency_ms' => $latency,
      'message'    => $message,
    ));

    return $this->create_response(false, null, $message, $error_code, $status, $latency);
  }

  /**
   * LBT-1468 — Best-effort enrichment: when the lead carries a numeric website
   * vehicle id but no stock number, resolve the vehicle's `stocknumber` and add
   * it to the DTO. The mapper then forwards it as AdditionalData.stock, which the
   * Platform resolves the vehicle by — stable across systems, unlike the website's
   * vehicle id (which does not match the Platform's own inventory ids).
   *
   * Resolution is FILE-FIRST: the per-vehicle inventory file
   * (uploads/data/{id}.json) is the same source that renders the VDP, so its id
   * matches the one we derive here, and it needs no external call (no SaaS API
   * latency/timeout, no credentials). Only when that file is unavailable or
   * carries no stock do we fall back to the inventory API (VehicleDetail).
   *
   * Never throws and never blocks the dispatch on failure (the lead is still
   * sent, just without a resolved stock).
   *
   * @param LB_Lead_Dto $lead
   * @return void
   */
  private static function maybe_enrich_stock($lead)
  {
    if (!($lead instanceof LB_Lead_Dto)) {
      return;
    }

    $vehicle_id = null;
    $referrer   = null;
    foreach ($lead->ToArray() as $key => $value) {
      $lc = strtolower($key);
      if (($lc === 'stocknumber' || $lc === 'stock') && self::value_present($value)) {
        return; // A stock is already present — nothing to resolve.
      }
      if ($lc === 'vehicleid' && is_numeric($value)) {
        $vehicle_id = (int) $value;
      }
      if ($lc === 'referrer' && is_string($value)) {
        $referrer = $value;
      }
    }

    // Forms embedded on a VDP (clean permalink, no `?vehicle_id=`) submit an
    // empty `vehicleid`. Fall back to the vehicle id in the page URL the plugin
    // sends as `referrer`: the VDP slug ends in `-{id}` (see get_vdp_slug()).
    if ($vehicle_id === null && $referrer !== null) {
      $vehicle_id = self::vehicle_id_from_referrer($referrer);
    }

    if ($vehicle_id === null) {
      return; // No vehicle id to resolve a stock from.
    }

    // 1) File-first: read the stock from the local inventory file (no external
    //    call, no timeout, same source as the rendered VDP).
    $stock  = self::stocknumber_from_local_inventory($vehicle_id);
    $source = 'local_inventory_file';

    // 2) Fallback to the inventory API only when the local file is missing or
    //    carries no stock (e.g. sites that render inventory straight from the API).
    if ($stock === '' && class_exists('LB_API_Client')) {
      try {
        $resp = LB_API_Client::getInstance()->VehicleDetail($vehicle_id);
      } catch (Exception $e) {
        $resp = null;
      }
      if (is_array($resp) && !empty($resp['success']) && isset($resp['data'])) {
        $stock  = self::extract_stocknumber($resp['data']);
        $source = 'inventory_api';
      }
    }

    if ($stock === '') {
      return;
    }

    // Merge into the DTO; LB_Lead_Mapper maps `stocknumber` -> AdditionalData.stock.
    $lead->FromArray(array('stocknumber' => $stock));

    if (function_exists('leadbox_logger')) {
      leadbox_logger()->info('Leadbox Platform lead enriched with stock from vehicle id', array(
        'vehicle_id' => $vehicle_id,
        'source'     => $source,
      ));
    }
  }

  /**
   * LBT-1468 — Resolve a vehicle's stock number from the local inventory file
   * (uploads/data/{id}.json) via LB_Inventory_Manager, reusing the same reader
   * that renders the VDPs. Returns '' when the manager is unavailable, the file
   * is missing, or it carries no stock. Never throws.
   *
   * @param int $vehicle_id Website/file vehicle id (the per-vehicle file name).
   * @return string Trimmed stock number, or '' if not resolvable from a file.
   */
  private static function stocknumber_from_local_inventory($vehicle_id)
  {
    if (!class_exists('LB_Inventory_Manager')) {
      return '';
    }
    try {
      $vehicle = LB_Inventory_Manager::getInstance()->get_vehicle_by_id($vehicle_id);
    } catch (Exception $e) {
      return '';
    }
    return self::extract_stocknumber($vehicle);
  }

  /**
   * Extract the vehicle id from a Leadbox VDP URL. Pure (no WordPress) so it is
   * unit-testable. The VDP slug is built as
   * `/{view|vue}/{condition}-{year}-{make}-{model}-{id}/` (see get_vdp_slug()),
   * so the id is the last `-`-delimited token of the final path segment.
   *
   * @param mixed $referrer The page URL the lead was submitted from.
   * @return int|null The vehicle id, or null if the URL is not a VDP slug.
   */
  public static function vehicle_id_from_referrer($referrer)
  {
    if (!is_string($referrer) || $referrer === '') {
      return null;
    }

    $path = parse_url($referrer, PHP_URL_PATH);
    if (!is_string($path) || $path === '') {
      return null;
    }

    $segments = array_values(array_filter(explode('/', $path), 'strlen'));
    if (empty($segments)) {
      return null;
    }

    $tokens    = explode('-', end($segments));
    $candidate = end($tokens);

    return ctype_digit((string) $candidate) ? (int) $candidate : null;
  }

  /**
   * Pull a stock number out of a vehicle-detail payload. Pure (no WordPress) so
   * it is unit-testable. Handles the vehicle object at the top level or nested
   * under a common wrapper key. Returns a trimmed string, or '' if absent.
   *
   * @param mixed $vehicle Decoded vehicle-detail data.
   * @return string
   */
  public static function extract_stocknumber($vehicle)
  {
    if (!is_array($vehicle)) {
      return '';
    }
    if (isset($vehicle['stocknumber']) && is_scalar($vehicle['stocknumber'])) {
      return trim((string) $vehicle['stocknumber']);
    }
    foreach (array('data', 'vehicle', 'result') as $k) {
      if (isset($vehicle[$k]['stocknumber']) && is_scalar($vehicle[$k]['stocknumber'])) {
        return trim((string) $vehicle[$k]['stocknumber']);
      }
    }
    return '';
  }

  /**
   * A value is present if non-null and not an empty / whitespace-only string.
   */
  private static function value_present($value)
  {
    if ($value === null) {
      return false;
    }
    if (is_string($value)) {
      return trim($value) !== '';
    }
    return !empty($value);
  }

  private function log_info($message, $context)
  {
    if (function_exists('leadbox_logger')) {
      leadbox_logger()->info($message, $context);
    }
  }

  private function log_error($message, $context)
  {
    if (function_exists('leadbox_logger')) {
      leadbox_logger()->error($message, $context);
    }
  }
}