<?php
/**
 * =============================================================
 *  SHARED API GATE  —  api_auth.php
 * =============================================================
 *  Drop this into every endpoint:
 *
 *      require_once __DIR__ . '/api_auth.php';
 *      $client = api_guard('products.read');   // scope is optional
 *
 *  It handles:
 *    - Bearer token authentication (401 on bad/missing token)
 *    - Client scope + IP allow-list checks (403)
 *    - Per-IP flood protection (before auth, stops key guessing)
 *    - Per-client rate limiting, per-minute and per-day (429)
 *    - Request body size cap + safe JSON parsing (400 / 413)
 *    - Consistent JSON error output + real HTTP status codes
 * =============================================================
 */

declare(strict_types=1);

/* ---------------- Configuration ---------------- */

/**
 * Private directory that lives OUTSIDE public_html, holding secrets
 * and writable storage. Nothing in here is reachable over HTTP.
 *
 * This file sits at:  /home/USER/public_html/api/api_auth.php
 * so dirname(__DIR__, 2) resolves to:  /home/USER
 * giving a private dir of:  /home/USER/api_private
 *
 * If your hosting layout differs, delete the calculation and just
 * hardcode the absolute path, e.g.:
 *     define('API_PRIVATE_DIR', '/home/kajaria/api_private');
 * Run  <?php echo __DIR__;  from this folder if you are unsure of it.
 */
if (!defined('API_PRIVATE_DIR')) {
    define('API_PRIVATE_DIR', dirname(__DIR__, 2) . '/api_private');
}

// Writable directory for rate-limit counters.
if (!defined('API_RATE_DIR')) {
    define('API_RATE_DIR', API_PRIVATE_DIR . '/rate');
}

// Where the client registry lives.
if (!defined('API_CLIENTS_FILE')) {
    define('API_CLIENTS_FILE', API_PRIVATE_DIR . '/api_clients.php');
}

// Max accepted request body size (bytes).
if (!defined('API_MAX_BODY_BYTES')) {
    define('API_MAX_BODY_BYTES', 64 * 1024); // 64 KB
}

// Flood guard applied to EVERY request by IP, before authentication.
if (!defined('API_IP_PER_MINUTE')) {
    define('API_IP_PER_MINUTE', 300);
}

// Failed auth attempts allowed per IP per minute (key-guessing guard).
if (!defined('API_IP_FAIL_PER_MINUTE')) {
    define('API_IP_FAIL_PER_MINUTE', 20);
}

/* ---------------- Output helpers ---------------- */

function api_respond(int $httpCode, array $payload, array $headers = []): void
{
    if (!headers_sent()) {
        http_response_code($httpCode);
        header('Content-Type: application/json; charset=utf-8');
        header('X-Content-Type-Options: nosniff');
        header('Cache-Control: no-store');
        foreach ($headers as $k => $v) {
            header($k . ': ' . $v);
        }
    }
    echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    exit;
}

function api_error(int $code, string $message, array $headers = []): void
{
    api_respond($code, [
        'code'    => $code,
        'status'  => false,
        'message' => $message,
    ], $headers);
}

/* ---------------- Request helpers ---------------- */

function api_client_ip(): string
{
    // If you sit behind Cloudflare / a load balancer, whitelist it here
    // and read the forwarded header. Otherwise REMOTE_ADDR is the truth.
    $trustedProxies = []; // e.g. ['10.0.0.5']

    $remote = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';

    if (in_array($remote, $trustedProxies, true)) {
        $fwd = $_SERVER['HTTP_CF_CONNECTING_IP']
            ?? $_SERVER['HTTP_X_FORWARDED_FOR']
            ?? '';
        $fwd = trim(explode(',', (string)$fwd)[0]);
        if (filter_var($fwd, FILTER_VALIDATE_IP)) {
            return $fwd;
        }
    }
    return $remote;
}

function api_bearer_token(): ?string
{
    $header = '';

    if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
        $header = $_SERVER['HTTP_AUTHORIZATION'];
    } elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
        $header = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
    } elseif (function_exists('getallheaders')) {
        foreach (getallheaders() as $name => $value) {
            if (strcasecmp($name, 'Authorization') === 0) {
                $header = $value;
                break;
            }
        }
    }

    if ($header === '' || !preg_match('/^\s*Bearer\s+(\S+)\s*$/i', $header, $m)) {
        return null;
    }
    return $m[1];
}

/**
 * Reads and decodes the JSON body. Returns [] for an empty body,
 * so `{}` and no-body both mean "no filters".
 */
function api_json_body(): array
{
    $length = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
    if ($length > API_MAX_BODY_BYTES) {
        api_error(413, 'Request body too large');
    }

    $raw = file_get_contents('php://input', false, null, 0, API_MAX_BODY_BYTES + 1);
    if ($raw === false) {
        api_error(400, 'Unable to read request body');
    }
    if (strlen($raw) > API_MAX_BODY_BYTES) {
        api_error(413, 'Request body too large');
    }

    $raw = trim($raw);
    if ($raw === '') {
        return [];
    }

    $data = json_decode($raw, true, 32);
    if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
        api_error(400, 'Invalid JSON');
    }
    return $data;
}

/* ---------------- Rate limiting ---------------- */

/**
 * Fixed-window counter stored on disk. No Redis/APCu required.
 * Returns [allowed(bool), retryAfterSeconds(int), remaining(int)].
 */
function api_rate_hit(string $bucket, int $perMinute, int $perDay): array
{
    if (!is_dir(API_RATE_DIR)) {
        @mkdir(API_RATE_DIR, 0770, true);
    }

    $file = API_RATE_DIR . '/' . hash('sha256', $bucket) . '.json';
    $fh   = @fopen($file, 'c+');

    // If storage is unavailable, fail open rather than break the API,
    // but log it so you notice.
    if ($fh === false) {
        error_log("[api_auth] rate-limit storage unavailable: {$file}");
        return [true, 0, $perMinute];
    }

    flock($fh, LOCK_EX);

    $contents = stream_get_contents($fh);
    $state    = json_decode((string)$contents, true);
    if (!is_array($state)) {
        $state = [];
    }

    $now       = time();
    $minWindow = intdiv($now, 60);
    $dayWindow = (int)date('Ymd', $now);

    if (($state['min_window'] ?? null) !== $minWindow) {
        $state['min_window'] = $minWindow;
        $state['min_count']  = 0;
    }
    if (($state['day_window'] ?? null) !== $dayWindow) {
        $state['day_window'] = $dayWindow;
        $state['day_count']  = 0;
    }

    $state['min_count']++;
    $state['day_count']++;

    $allowed    = true;
    $retryAfter = 0;

    if ($perMinute > 0 && $state['min_count'] > $perMinute) {
        $allowed    = false;
        $retryAfter = 60 - ($now % 60);
    } elseif ($perDay > 0 && $state['day_count'] > $perDay) {
        $allowed    = false;
        $retryAfter = strtotime('tomorrow') - $now;
    }

    ftruncate($fh, 0);
    rewind($fh);
    fwrite($fh, json_encode($state));
    fflush($fh);
    flock($fh, LOCK_UN);
    fclose($fh);

    $remaining = max(0, $perMinute - (int)$state['min_count']);

    return [$allowed, $retryAfter, $remaining];
}

/** Occasionally clean up stale counter files. */
function api_rate_gc(): void
{
    if (random_int(1, 200) !== 1 || !is_dir(API_RATE_DIR)) {
        return;
    }
    foreach (glob(API_RATE_DIR . '/*.json') ?: [] as $f) {
        if (filemtime($f) < time() - 172800) { // 2 days
            @unlink($f);
        }
    }
}

/* ---------------- The gate ---------------- */

/**
 * Authenticates the caller and applies rate limits.
 * Terminates the request with a JSON error on failure.
 *
 * @param  string|null $requiredScope e.g. 'products.read'
 * @return array       The matched client config (with 'client_id', 'name', ...)
 */
function api_guard(?string $requiredScope = null, array $allowedMethods = ['POST']): array
{
    api_rate_gc();

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

    if ($method === 'OPTIONS') {
        api_respond(204, []);
    }

    if (!in_array($method, $allowedMethods, true)) {
        api_error(405, 'Only ' . implode('/', $allowedMethods) . ' allowed', [
            'Allow' => implode(', ', $allowedMethods),
        ]);
    }

    $ip = api_client_ip();

    /* --- Flood guard, applied before we look at any token --- */
    [$ok, $retry] = api_rate_hit('ip:' . $ip, API_IP_PER_MINUTE, 0);
    if (!$ok) {
        api_error(429, 'Too many requests', ['Retry-After' => (string)$retry]);
    }

    /* --- Bearer token --- */
    $token = api_bearer_token();
    if ($token === null) {
        api_rate_hit('fail:' . $ip, API_IP_FAIL_PER_MINUTE, 0);
        api_error(401, 'Access denied. Missing or malformed Authorization header.', [
            'WWW-Authenticate' => 'Bearer',
        ]);
    }

    /* --- Look the client up by the hash of the token --- */
    if (!is_readable(API_CLIENTS_FILE)) {
        error_log('[api_auth] client registry not readable at ' . API_CLIENTS_FILE);
        api_error(500, 'Internal Server Error');
    }

    $clients = require API_CLIENTS_FILE;
    if (!is_array($clients)) {
        error_log('[api_auth] client registry did not return an array');
        api_error(500, 'Internal Server Error');
    }

    $hash = hash('sha256', $token);

    $client = null;
    foreach ($clients as $storedHash => $config) {
        if (hash_equals((string)$storedHash, $hash)) {  // constant time
            $client = $config;
            break;
        }
    }

    if ($client === null || empty($client['active'])) {
        [$fok, $fretry] = api_rate_hit('fail:' . $ip, API_IP_FAIL_PER_MINUTE, 0);
        if (!$fok) {
            api_error(429, 'Too many failed attempts', ['Retry-After' => (string)$fretry]);
        }
        api_error(401, 'Access denied. Invalid or inactive API key.');
    }

    /* --- IP allow-list --- */
    if (!empty($client['allowed_ips']) && !in_array($ip, $client['allowed_ips'], true)) {
        api_error(403, 'Access denied. IP not permitted for this client.');
    }

    /* --- Scope --- */
    $scopes = $client['scopes'] ?? ['*'];
    if ($requiredScope !== null
        && !in_array('*', $scopes, true)
        && !in_array($requiredScope, $scopes, true)) {
        api_error(403, 'Access denied. This key is not allowed to use this endpoint.');
    }

    /* --- Per-client rate limit --- */
    $perMinute = (int)($client['rate']['per_minute'] ?? 60);
    $perDay    = (int)($client['rate']['per_day'] ?? 10000);

    [$ok, $retry, $remaining] = api_rate_hit('client:' . $client['client_id'], $perMinute, $perDay);
    if (!$ok) {
        api_error(429, 'Rate limit exceeded. Please slow down.', [
            'Retry-After'           => (string)$retry,
            'X-RateLimit-Limit'     => (string)$perMinute,
            'X-RateLimit-Remaining' => '0',
        ]);
    }

    if (!headers_sent()) {
        header('X-RateLimit-Limit: ' . $perMinute);
        header('X-RateLimit-Remaining: ' . $remaining);
    }

    return $client;
}

/* ---------------- Small input sanitisers ---------------- */

/** Returns a trimmed string, or null if absent/empty/not a scalar. */
function api_str(array $data, string $key, int $maxLen = 190): ?string
{
    if (!isset($data[$key]) || is_array($data[$key]) || is_object($data[$key])) {
        return null;
    }
    $value = trim((string)$data[$key]);
    if ($value === '') {
        return null;
    }
    return function_exists('mb_substr')
        ? mb_substr($value, 0, $maxLen)
        : substr($value, 0, $maxLen);
}

/** Returns a bounded integer. */
function api_int(array $data, string $key, int $default, int $min, int $max): int
{
    if (!isset($data[$key]) || !is_scalar($data[$key]) || !is_numeric($data[$key])) {
        return $default;
    }
    return max($min, min($max, (int)$data[$key]));
}

function api_bool(array $data, string $key, bool $default): bool
{
    if (!array_key_exists($key, $data)) {
        return $default;
    }
    return filter_var($data[$key], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? $default;
}
