<?php

declare(strict_types=1);

// ?click_id={base64url_payload}&payout={decimal}&token={optional_hmac_secret}

require_once __DIR__ . '/../../Base64URL.php';
require_once __DIR__ . '/../../env.php';

// Load .env into the environment so pbEnv() can read POSTBACK_SECRET /
// POSTBACK_TIMEZONE. On cPanel/LiteSpeed the .env file is not injected into
// the real environment, so without this the secret reads empty and every
// postback fails with 500 SERVER_ERROR.
load_env_file(__DIR__ . '/../../.env');

if (!headers_sent()) {
    header('Content-Type: text/plain; charset=UTF-8');
    header('X-Content-Type-Options: nosniff');
    header('X-Frame-Options: DENY');
    header('Referrer-Policy: no-referrer');
    header('Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=()');
    header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
    header('Pragma: no-cache');
    header('Expires: 0');
    header("Content-Security-Policy: default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'");
}

function pbEnv(string $key, ?string $default = null): ?string
{
    $value = getenv($key);

    if ($value !== false) {
        return trim((string) $value);
    }

    if (isset($_ENV[$key])) {
        return trim((string) $_ENV[$key]);
    }

    if (isset($_SERVER[$key])) {
        return trim((string) $_SERVER[$key]);
    }

    return $default;
}

function pbLog(string $message, array $context = []): void
{
    $safe = [];

    foreach ($context as $key => $value) {
        $key = (string) $key;

        if (
            stripos($key, 'pass') !== false
            || stripos($key, 'secret') !== false
            || stripos($key, 'token') !== false
        ) {
            continue;
        }

        if (is_scalar($value) || $value === null) {
            $safe[$key] = $value;
        }
    }

    $suffix = '';

    if ($safe !== []) {
        $json = json_encode(
            $safe,
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR,
        );

        if (is_string($json)) {
            $suffix = ' ' . $json;
        }
    }

    error_log('[postback] ' . $message . $suffix);
}

function pbRespond(int $statusCode, string $message): never
{
    http_response_code($statusCode);
    echo $message;
    exit;
}

function pbHasControlChars(string $value): bool
{
    return preg_match('/[\x00-\x1F\x7F]/', $value) === 1;
}

function pbRejectSuspiciousRequest(): void
{
    $queryString = (string) ($_SERVER['QUERY_STRING'] ?? '');

    if (strlen($queryString) > 4096) {
        pbLog('blocked_suspicious_request', ['reason' => 'query_too_long']);
        pbRespond(400, 'BAD_REQUEST');
    }

    foreach ($_GET as $key => $value) {
        if (!is_string($key) || pbHasControlChars($key)) {
            pbLog('blocked_suspicious_request', ['reason' => 'invalid_key']);
            pbRespond(400, 'BAD_REQUEST');
        }

        if (is_array($value)) {
            pbLog('blocked_suspicious_request', ['reason' => 'array_param', 'key' => $key]);
            pbRespond(400, 'BAD_REQUEST');
        }

        $stringValue = (string) $value;

        if (pbHasControlChars($stringValue)) {
            pbLog('blocked_suspicious_request', ['reason' => 'control_chars', 'key' => $key]);
            pbRespond(400, 'BAD_REQUEST');
        }
    }
}

function pbRequirePostbackSecret(): void
{
    $secret = pbEnv('POSTBACK_SECRET', '');

    if ($secret === null || $secret === '') {
        pbLog('postback_secret_not_configured');
        pbRespond(500, 'SERVER_ERROR');
    }

    $token = isset($_GET['token']) ? (string) $_GET['token'] : '';

    if ($token === '' || !hash_equals($secret, $token)) {
        pbLog('unauthorized_postback');
        pbRespond(403, 'FORBIDDEN');
    }
}

function pbNormalizePayout(string $raw): string
{
    $raw = trim($raw);

    if ($raw === '') {
        pbRespond(400, 'BAD_REQUEST');
    }

    if (strlen($raw) > 20) {
        pbLog('invalid_payout', ['reason' => 'too_long']);
        pbRespond(400, 'BAD_REQUEST');
    }

    if (preg_match('/^\d{1,12}(\.\d{1,4})?$/', $raw) !== 1) {
        pbLog('invalid_payout', ['reason' => 'format']);
        pbRespond(400, 'BAD_REQUEST');
    }

    $float = (float) $raw;

    if (!is_finite($float) || $float < 0) {
        pbLog('invalid_payout', ['reason' => 'range']);
        pbRespond(400, 'BAD_REQUEST');
    }

    return number_format($float, 2, '.', '');
}

function pbNormalizeClickId(string $raw): string
{
    $value = strtoupper(trim($raw));

    if ($value === '' || strlen($value) > 128) {
        pbLog('invalid_click_id', ['reason' => 'length']);
        pbRespond(400, 'BAD_REQUEST');
    }

    if (preg_match('/^[A-Z0-9._:-]+$/', $value) !== 1) {
        pbLog('invalid_click_id', ['reason' => 'chars']);
        pbRespond(400, 'BAD_REQUEST');
    }

    return $value;
}

function pbNormalizeCountry(string $raw): string
{
    $value = strtoupper(trim($raw));

    if (preg_match('/^[A-Z]{2,3}$/', $value) !== 1) {
        pbLog('invalid_country');
        pbRespond(400, 'BAD_REQUEST');
    }

    return $value;
}

function pbNormalizeIp(string $raw): string
{
    $value = trim($raw);

    if (filter_var($value, FILTER_VALIDATE_IP) === false) {
        pbLog('invalid_ip');
        pbRespond(400, 'BAD_REQUEST');
    }

    return $value;
}

function pbNormalizeTraffic(string $raw): string
{
    $value = strtoupper(trim($raw));

    if ($value === '' || strlen($value) > 255 || pbHasControlChars($value)) {
        pbLog('invalid_traffic');
        pbRespond(400, 'BAD_REQUEST');
    }

    return $value;
}

function pbNormalizeNetwork(string $raw): string
{
    $value = strtolower(trim($raw));

    if ($value === '' || strlen($value) > 64) {
        pbLog('invalid_network', ['reason' => 'length']);
        pbRespond(400, 'BAD_REQUEST');
    }

    if (preg_match('/^[a-z0-9._-]+$/', $value) !== 1) {
        pbLog('invalid_network', ['reason' => 'chars']);
        pbRespond(400, 'BAD_REQUEST');
    }

    return $value;
}

function pbDecodePayload(string $encoded): array
{
    $encoded = trim($encoded);

    if ($encoded === '' || strlen($encoded) > 2048) {
        pbLog('invalid_encoded_click_id', ['reason' => 'length']);
        pbRespond(400, 'BAD_REQUEST');
    }

    if (preg_match('/^[A-Za-z0-9_-]+={0,2}$/', $encoded) !== 1) {
        pbLog('invalid_encoded_click_id', ['reason' => 'chars']);
        pbRespond(400, 'BAD_REQUEST');
    }

    if (!function_exists('base64url_decode')) {
        pbLog('base64url_decode_missing');
        pbRespond(500, 'SERVER_ERROR');
    }

    $decoded = base64url_decode($encoded);

    if (!is_string($decoded) || $decoded === '') {
        pbLog('decode_failed');
        pbRespond(400, 'BAD_REQUEST');
    }

    if (strlen($decoded) > 2048 || pbHasControlChars($decoded)) {
        pbLog('invalid_decoded_payload');
        pbRespond(400, 'BAD_REQUEST');
    }

    $parts = explode(',', $decoded);

    if (count($parts) < 5) {
        pbLog('invalid_payload_parts', ['count' => count($parts)]);
        pbRespond(400, 'BAD_REQUEST');
    }

    return $parts;
}

/**
 * Build a per-minute dedup key for this postback.
 * Two identical payloads within the same 60-second window share the same key.
 * Different payout, network, or traffic → always a different key (legitimate re-conversion).
 */
function pbComputeIdempotencyKey(
    string $clickId,
    string $payout,
    string $network,
    string $traffic,
): string {
    $minute = (int) (time() / 60);

    return hash('sha256', $clickId . '|' . $payout . '|' . $network . '|' . $traffic . '|' . $minute);
}

/**
 * Insert a lead report row.
 * Returns true if the row was inserted (new conversion).
 * Returns false if the idempotency_key already exists (duplicate postback within same minute).
 */
function pbInsertLeadReport(
    PDO $pdo,
    string $clickId,
    string $ipAddress,
    string $country,
    string $payout,
    string $conversionDate,
    string $currencySymbol,
    string $network,
    string $traffic,
    string $idempotencyKey,
): bool {
    $stmt = $pdo->prepare(
        'INSERT INTO leadreport
            (click_id, ip_address, country, payout, conversion_date, currency_symbol, network, traffic, idempotency_key)
         VALUES
            (:click_id, :ip_address, :country, :payout, :conversion_date, :currency_symbol, :network, :traffic, :idempotency_key)
         ON DUPLICATE KEY UPDATE idempotency_key = idempotency_key',
    );

    $stmt->execute([
        'click_id'         => $clickId,
        'ip_address'       => $ipAddress,
        'country'          => $country,
        'payout'           => $payout,
        'conversion_date'  => $conversionDate,
        'currency_symbol'  => $currencySymbol,
        'network'          => $network,
        'traffic'          => $traffic,
        'idempotency_key'  => $idempotencyKey,
    ]);

    // rowCount() = 1 → inserted; 0 → duplicate key, no-op update (same value written back).
    // This relies on MySQL's default behavior (not CLIENT_FOUND_ROWS) which connection_pdo.php preserves.
    return $stmt->rowCount() === 1;
}

function pbClickRecordExists(PDO $pdo, string $clickId, string $conversionDate): bool
{
    $stmt = $pdo->prepare(
        'SELECT id FROM clickrecord WHERE click_id = :click_id AND click_date = :click_date LIMIT 1',
    );

    $stmt->execute([
        'click_id'   => $clickId,
        'click_date' => $conversionDate,
    ]);

    return $stmt->fetch(PDO::FETCH_ASSOC) !== false;
}

function pbInsertClickRecord(PDO $pdo, string $clickId, string $payout, string $conversionDate): void
{
    // No click_date row exists yet — create one seeded with this conversion.
    // clicks starts at 0: the postback arrived but no tracked click exists for this date.
    // leads = 1 because this postback IS the first lead.
    $stmt = $pdo->prepare(
        'INSERT INTO clickrecord
            (click_id, clicks, leads, payout, click_date)
         VALUES
            (:click_id, 0, 1, :payout, :click_date)',
    );

    $stmt->execute([
        'click_id'   => $clickId,
        'payout'     => $payout,
        'click_date' => $conversionDate,
    ]);
}

function pbUpdateClickRecord(PDO $pdo, string $clickId, string $payout, string $conversionDate): void
{
    // Postback = lead/conversion only — do NOT increment clicks here.
    // Clicks are tracked exclusively in redirect/_meetups/index.php.
    $stmt = $pdo->prepare(
        'UPDATE clickrecord
            SET leads = leads + 1,
                payout = payout + CAST(:payout AS DECIMAL(18,2))
          WHERE click_id = :click_id
            AND click_date = :click_date',
    );

    $stmt->execute([
        'payout'     => $payout,
        'click_id'   => $clickId,
        'click_date' => $conversionDate,
    ]);
}

function pbConnect(): PDO
{
    /** @var PDO $pdo */
    $pdo = require __DIR__ . '/../../connection_pdo.php';

    return $pdo;
}

pbRejectSuspiciousRequest();
pbRequirePostbackSecret();

if (!isset($_GET['click_id'], $_GET['payout'])) {
    pbRespond(400, 'BAD_REQUEST');
}

$payload = pbDecodePayload((string) $_GET['click_id']);

$clickId = pbNormalizeClickId((string) $payload[0]);
$country = pbNormalizeCountry((string) $payload[1]);
$ipAddress = pbNormalizeIp((string) $payload[2]);
$traffic = pbNormalizeTraffic((string) $payload[3]);
$network = pbNormalizeNetwork((string) $payload[4]);
$payout = pbNormalizePayout((string) $_GET['payout']);

$timezone = pbEnv('POSTBACK_TIMEZONE', 'UTC');

try {
    $dateTimeZone = new DateTimeZone((string) $timezone);
} catch (Throwable $e) {
    pbLog('invalid_timezone', ['timezone' => $timezone]);
    $dateTimeZone = new DateTimeZone('UTC');
}

$conversionDate = (new DateTimeImmutable('now', $dateTimeZone))->format('Y-m-d');
$currencySymbol = '$';
$idempotencyKey = pbComputeIdempotencyKey($clickId, $payout, $network, $traffic);

// Retry loop: 3 attempts with exponential backoff (200ms, 400ms, 800ms).
// Returns 503 on final failure so affiliate networks know this is temporary
// and should retry (most networks treat 5xx the same, but 503 is semantically correct).
const PB_MAX_RETRIES    = 3;
const PB_RETRY_BASE_MS  = 100; // base delay in milliseconds

$lastError = '';

try {
    $pdo = pbConnect();
} catch (Throwable $e) {
    pbLog('db_not_connected', ['error' => $e->getMessage()]);
    pbRespond(500, 'SERVER_ERROR');
}

for ($attempt = 0; $attempt < PB_MAX_RETRIES; $attempt++) {
    try {
        $pdo->beginTransaction();

        $isNewConversion = pbInsertLeadReport(
            $pdo,
            $clickId,
            $ipAddress,
            $country,
            $payout,
            $conversionDate,
            $currencySymbol,
            $network,
            $traffic,
            $idempotencyKey,
        );

        if ($isNewConversion) {
            if (pbClickRecordExists($pdo, $clickId, $conversionDate)) {
                pbUpdateClickRecord($pdo, $clickId, $payout, $conversionDate);
            } else {
                pbInsertClickRecord($pdo, $clickId, $payout, $conversionDate);
            }
        }

        $pdo->commit();
        pbRespond(200, 'OK');
    } catch (Throwable $e) {
        $lastError = $e->getMessage();

        if ($pdo->inTransaction()) {
            try {
                $pdo->rollBack();
            } catch (Throwable) {
                // rollback failed — connection may be dead
            }
        }

        if ($attempt < PB_MAX_RETRIES - 1) {
            // Exponential backoff + jitter
            $delayUs = (PB_RETRY_BASE_MS * (2 ** $attempt)) * 1000;
            $jitterUs = random_int(0, (int) ($delayUs * 0.3)); // +/-30% jitter
            usleep($delayUs + $jitterUs);

            // Reconnect if connection may be stale
            try {
                $pdo = pbConnect();
            } catch (Throwable $reconnectError) {
                pbLog('postback_retry_failed', [
                    'attempt' => $attempt + 1,
                    'error'   => $lastError,
                ]);
                break;
            }
        }
    }
}

// All retries exhausted — log full payload for manual recovery
$rawClickId = (string) ($_GET['click_id'] ?? '');
$rawPayout = (string) ($_GET['payout'] ?? '');
pbLog('postback_all_retries_failed', [
    'error'          => $lastError,
    'click_id_hash'  => $rawClickId !== '' ? hash('sha256', $rawClickId) : '',
    'click_id_len'   => strlen($rawClickId),
    'payout_present' => $rawPayout !== '',
    'attempts'       => PB_MAX_RETRIES,
]);

$pdo = null;
pbRespond(503, 'SERVICE_UNAVAILABLE');
