<?php

/*
|--------------------------------------------------------------------------
| GMB CALL DATA - WEBHOOK RECEIVER
|--------------------------------------------------------------------------
|
| Vendor (RightChoice) POSTs call records here as they happen.
|
| This endpoint ONLY writes to the staging table
| dealer_gmb_call_inbox. A separate cron promotes rows into
| dealer_gmb_call_history. That separation means a bad payload can
| never corrupt the live table, and every request stays auditable.
|
| Auth      : X-API-Key header (or Authorization: Bearer <key>)
| Method    : POST
| Body      : application/json
|
| PHP 8+ Compatible
|
*/

declare(strict_types=1);

/* -------------------------------------------------
   ERROR HANDLING
   ------------------------------------------------
   Never print errors to the response body: it would
   leak paths and SQL to a third party.
------------------------------------------------- */

ini_set('display_errors', '0');
ini_set('display_startup_errors', '0');
error_reporting(E_ALL);

header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');

/* -------------------------------------------------
   CONFIG
------------------------------------------------- */

$config = require __DIR__ . '/gmb_api_config.php';

$logDir = $config['log_dir'];

if (!is_dir($logDir)) {
    mkdir($logDir, 0755, true);
}

$logFile = $logDir . '/gmb_call_webhook_' . date('Ymd') . '.log';

/* -------------------------------------------------
   HELPERS
------------------------------------------------- */

function writeLog(string $message): void
{
    global $logFile;

    file_put_contents(
        $logFile,
        '[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL,
        FILE_APPEND | LOCK_EX
    );
}

function clientIp(): string
{
    return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}

/**
 * Send a JSON response and stop.
 */
function respond(int $httpStatus, array $body): void
{
    http_response_code($httpStatus);

    echo json_encode(
        $body,
        JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
    );

    exit;
}

/**
 * Pull the API key out of the request headers.
 * Supports X-API-Key and Authorization: Bearer.
 */
function apiKeyFromRequest(): ?string
{
    if (!empty($_SERVER['HTTP_X_API_KEY'])) {
        return trim((string)$_SERVER['HTTP_X_API_KEY']);
    }

    if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {

        $auth = trim((string)$_SERVER['HTTP_AUTHORIZATION']);

        if (stripos($auth, 'Bearer ') === 0) {
            return trim(substr($auth, 7));
        }
    }

    // Some Apache/CGI setups strip custom headers from $_SERVER
    if (function_exists('getallheaders')) {

        foreach (getallheaders() as $name => $value) {

            if (strcasecmp($name, 'X-API-Key') === 0) {
                return trim((string)$value);
            }
        }
    }

    return null;
}

/**
 * Constant-time key check against all configured keys.
 * Returns the matched key label, or null.
 */
function matchApiKey(?string $provided, array $keys): ?string
{
    if ($provided === null || $provided === '') {
        return null;
    }

    $matched = null;

    foreach ($keys as $label => $expected) {

        // hash_equals guards against timing attacks
        if (hash_equals((string)$expected, $provided)) {
            $matched = (string)$label;
        }
    }

    return $matched;
}

/**
 * Normalise an incoming timestamp to MySQL DATETIME.
 * Accepts ISO 8601 (with or without offset) and 'Y-m-d H:i:s'.
 */
function normalizeDateTime(mixed $value): ?string
{
    if ($value === null || $value === '' || $value === 'NULL') {
        return null;
    }

    $timestamp = strtotime((string)$value);

    if ($timestamp === false) {
        return null;
    }

    return date('Y-m-d H:i:s', $timestamp);
}

/**
 * Treat the string "NULL" and empty values as a real null.
 */
function cleanString(mixed $value): string
{
    if ($value === null) {
        return '';
    }

    $value = trim((string)$value);

    if (strcasecmp($value, 'null') === 0) {
        return '';
    }

    return $value;
}

/* -------------------------------------------------
   METHOD CHECK
------------------------------------------------- */

$method = $_SERVER['REQUEST_METHOD'] ?? '';

if ($method === 'GET') {

    // Simple health check so the vendor can verify reachability
    respond(200, [
        'status'  => 'ok',
        'service' => 'gmb-call-webhook',
        'time'    => date('c'),
    ]);
}

if ($method !== 'POST') {

    header('Allow: POST');

    respond(405, [
        'status'  => 'error',
        'code'    => 'method_not_allowed',
        'message' => 'Only POST is accepted.',
    ]);
}

/* -------------------------------------------------
   IP ALLOWLIST (OPTIONAL)
------------------------------------------------- */

if (!empty($config['allowed_ips'])
    && !in_array(clientIp(), $config['allowed_ips'], true)
) {

    writeLog('Rejected request from disallowed IP: ' . clientIp());

    respond(403, [
        'status'  => 'error',
        'code'    => 'ip_not_allowed',
        'message' => 'Source IP is not permitted.',
    ]);
}

/* -------------------------------------------------
   AUTHENTICATION
------------------------------------------------- */

$providedKey = apiKeyFromRequest();
$keyLabel    = matchApiKey($providedKey, $config['api_keys']);

if ($keyLabel === null) {

    writeLog('AUTH FAILED from IP ' . clientIp());

    respond(401, [
        'status'  => 'error',
        'code'    => 'unauthorized',
        'message' => 'Missing or invalid API key. Send it in the X-API-Key header.',
    ]);
}

/* -------------------------------------------------
   READ BODY
------------------------------------------------- */

$rawBody = file_get_contents('php://input');

if ($rawBody === false || $rawBody === '') {

    respond(400, [
        'status'  => 'error',
        'code'    => 'empty_body',
        'message' => 'Request body is empty.',
    ]);
}

if (strlen($rawBody) > (int)$config['max_body_bytes']) {

    respond(413, [
        'status'  => 'error',
        'code'    => 'payload_too_large',
        'message' => 'Request body exceeds the maximum allowed size.',
    ]);
}

if (!empty($config['log_raw_requests'])) {

    file_put_contents(
        $logDir . '/gmb_call_webhook_raw_' . date('Ymd') . '.log',
        '[' . date('Y-m-d H:i:s') . '] ' . clientIp() . ' ' . $rawBody . PHP_EOL,
        FILE_APPEND | LOCK_EX
    );
}

/* -------------------------------------------------
   PARSE JSON
   ------------------------------------------------
   JSON_BIGINT_AS_STRING matters: if call_log_id is
   sent as an unquoted number larger than PHP_INT_MAX,
   PHP would otherwise convert it to a float and the
   value becomes "1.2345678901235E+19" - a corrupted,
   non-unique identifier. This keeps it exact.
------------------------------------------------- */

$payload = json_decode($rawBody, true, 512, JSON_BIGINT_AS_STRING);

if (json_last_error() !== JSON_ERROR_NONE) {

    writeLog('JSON parse error: ' . json_last_error_msg());

    respond(400, [
        'status'  => 'error',
        'code'    => 'invalid_json',
        'message' => 'Body is not valid JSON: ' . json_last_error_msg(),
    ]);
}

/* -------------------------------------------------
   NORMALISE TO A LIST OF RECORDS
   ------------------------------------------------
   Accepts three shapes:
     { "calls": [ {...}, {...} ] }
     [ {...}, {...} ]
     { ...single record... }
------------------------------------------------- */

if (isset($payload['calls']) && is_array($payload['calls'])) {

    $records = $payload['calls'];

} elseif (is_array($payload) && array_is_list($payload)) {

    $records = $payload;

} elseif (is_array($payload) && isset($payload['call_log_id'])) {

    $records = [$payload];

} else {

    respond(400, [
        'status'  => 'error',
        'code'    => 'invalid_structure',
        'message' => 'Expected an object with a "calls" array, an array of records, or a single call record.',
    ]);
}

if (empty($records)) {

    respond(400, [
        'status'  => 'error',
        'code'    => 'no_records',
        'message' => 'No call records supplied.',
    ]);
}

if (count($records) > (int)$config['max_batch_size']) {

    respond(413, [
        'status'  => 'error',
        'code'    => 'batch_too_large',
        'message' => 'Maximum ' . $config['max_batch_size'] . ' records per request.',
    ]);
}

/* -------------------------------------------------
   DB CONNECTION
------------------------------------------------- */

require __DIR__ . '/db_connect.php';   // must expose $conn (mysqli)

if (!isset($conn) || !($conn instanceof mysqli) || $conn->connect_error) {

    writeLog('DB connection failed on webhook.');

    // 5xx so the vendor's retry logic kicks in
    respond(503, [
        'status'  => 'error',
        'code'    => 'database_unavailable',
        'message' => 'Temporary storage failure. Please retry.',
    ]);
}

$conn->set_charset('utf8mb4');

/* -------------------------------------------------
   PREPARE INSERT (ONCE)
------------------------------------------------- */

$sql = "
    INSERT INTO dealer_gmb_call_inbox (

        website_id,
        sap_code,
        call_log_id,
        call_number_with_c_code,
        mobile,
        call_start_time,
        call_duration_minutes,
        call_did_number,
        call_event_type,
        substatus,
        call_recording_file_link,
        total_calls_from_caller,
        prev_calls_count,
        raw_payload,
        source_ip

    ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)

    ON DUPLICATE KEY UPDATE

        sap_code                 = VALUES(sap_code),
        call_number_with_c_code  = VALUES(call_number_with_c_code),
        mobile                   = VALUES(mobile),
        call_start_time          = VALUES(call_start_time),
        call_duration_minutes    = VALUES(call_duration_minutes),
        call_did_number          = VALUES(call_did_number),
        call_event_type          = VALUES(call_event_type),
        substatus                = VALUES(substatus),
        call_recording_file_link = VALUES(call_recording_file_link),
        total_calls_from_caller  = VALUES(total_calls_from_caller),
        prev_calls_count         = VALUES(prev_calls_count),
        raw_payload              = VALUES(raw_payload),
        source_ip                = VALUES(source_ip),
        received_at              = NOW(),

        /* an update means there is something new to promote */
        sync_status              = 0,
        sync_attempts            = 0,
        sync_error               = NULL
";

$stmt = $conn->prepare($sql);

if (!$stmt) {

    writeLog('Prepare failed: ' . $conn->error);

    respond(503, [
        'status'  => 'error',
        'code'    => 'database_error',
        'message' => 'Temporary storage failure. Please retry.',
    ]);
}

/* -------------------------------------------------
   PROCESS RECORDS
------------------------------------------------- */

$results    = [];
$acceptedNo = 0;
$rejectedNo = 0;
$failedNo   = 0;

$sourceIp = clientIp();

foreach ($records as $index => $record) {

    if (!is_array($record)) {

        $rejectedNo++;

        $results[] = [
            'index'   => $index,
            'status'  => 'rejected',
            'message' => 'Record is not an object.',
        ];

        continue;
    }

    /* ---------- extract ---------- */

    $website_id  = isset($record['website_id']) ? (int)$record['website_id'] : 0;
    $call_log_id = cleanString($record['call_log_id'] ?? '');

    /* ---------- validate ---------- */

    $errors = [];

    if ($website_id <= 0) {
        $errors[] = 'website_id is required and must be a positive integer';
    }

    if ($call_log_id === '') {
        $errors[] = 'call_log_id is required';
    }

    if (strlen($call_log_id) > 100) {
        $errors[] = 'call_log_id exceeds 100 characters';
    }

    if (!empty($errors)) {

        $rejectedNo++;

        $results[] = [
            'index'       => $index,
            'call_log_id' => $call_log_id !== '' ? $call_log_id : null,
            'status'      => 'rejected',
            'message'     => implode('; ', $errors),
        ];

        continue;
    }

    /* ---------- map remaining fields ---------- */

    $sap_code                = cleanString($record['sap_code'] ?? '');
    $call_number_with_c_code = cleanString($record['call_number_with_c_code'] ?? '');

    // Optional. If absent, the sync cron derives it from the full number.
    $mobile                  = cleanString($record['mobile'] ?? '');
    $mobile                  = $mobile !== '' ? $mobile : null;

    $call_start_time         = normalizeDateTime($record['call_start_time'] ?? null);
    $call_duration_minutes   = (float)($record['call_duration_minutes'] ?? 0);
    $call_did_number         = cleanString($record['call_did_number'] ?? '');
    $call_event_type         = cleanString($record['call_event_type'] ?? '');
    $substatus               = cleanString($record['substatus'] ?? '');
    $call_recording_link     = cleanString($record['call_recording_file_link'] ?? '');
    $total_calls_from_caller = (int)($record['total_calls_from_caller'] ?? 0);
    $prev_calls_count        = (int)($record['prev_calls_count'] ?? 0);

    $rawRecord = json_encode($record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

    /* ---------- store ---------- */

    $stmt->bind_param(
        'isssssdssssiiss',
        $website_id,
        $sap_code,
        $call_log_id,
        $call_number_with_c_code,
        $mobile,
        $call_start_time,
        $call_duration_minutes,
        $call_did_number,
        $call_event_type,
        $substatus,
        $call_recording_link,
        $total_calls_from_caller,
        $prev_calls_count,
        $rawRecord,
        $sourceIp
    );

    if (!$stmt->execute()) {

        $failedNo++;

        writeLog(
            "Insert failed for call_log_id {$call_log_id} "
            . "(website_id {$website_id}): " . $stmt->error
        );

        $results[] = [
            'index'       => $index,
            'call_log_id' => $call_log_id,
            'status'      => 'failed',
            'message'     => 'Storage error, please retry this record.',
        ];

        continue;
    }

    /* affected_rows: 1 = new insert, 2 = updated, 0 = identical */

    $acceptedNo++;

    $results[] = [
        'index'       => $index,
        'call_log_id' => $call_log_id,
        'status'      => $stmt->affected_rows === 1 ? 'accepted' : 'updated',
    ];
}

$stmt->close();
$conn->close();

writeLog(
    "Batch from [{$keyLabel}] {$sourceIp}: "
    . count($records) . " received, "
    . "{$acceptedNo} stored, {$rejectedNo} rejected, {$failedNo} failed"
);

/* -------------------------------------------------
   RESPONSE
   ------------------------------------------------
   If ANY record failed on our side we return 500 so
   the vendor retries the batch. Duplicates are safe
   to resend, so a full-batch retry costs nothing.
------------------------------------------------- */

$httpStatus = $failedNo > 0 ? 500 : 200;

respond($httpStatus, [
    'status'   => $failedNo > 0 ? 'partial_failure' : 'success',
    'received' => count($records),
    'stored'   => $acceptedNo,
    'rejected' => $rejectedNo,
    'failed'   => $failedNo,
    'results'  => $results,
]);
