<?php

declare(strict_types=1);

use Srp\Redirect\FilterEngine;
use Srp\Redirect\GeoResolver;

require_once dirname(__DIR__) . '/env.php';

load_env_file(dirname(__DIR__) . '/.env');

/*
 * SRP entry handler.
 * Supported public token formats:
 * - legacy sub_id (base64url, CSV):
 *   [0]=?,
 *   [1]=click_id,
 *   [2]=?,
 *   [3]=canonical_url,
 *   [4]=user_lp,
 *   [5]=title,
 *   [6]=image_url,
 *   [7]=lg (landing|1=>landing, direct|0|null|legacy|unknown=>direct)
 * - short code: s-<code>
 */
$failBootstrap = static function (string $message): never {
    http_response_code(500);
    header('Content-Type: text/plain; charset=UTF-8');
    header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
    header('Pragma: no-cache');
    header('Expires: 0');
    header('X-Content-Type-Options: nosniff');
    error_log($message);
    exit('Internal Server Error');
};

$autoloadPath = dirname(__DIR__) . '/vendor/autoload.php';
if (!is_file($autoloadPath) || !is_readable($autoloadPath)) {
    $failBootstrap('Missing Composer autoload: ' . $autoloadPath);
}

try {
    require_once $autoloadPath;
} catch (Throwable $e) {
    $failBootstrap('Composer autoload bootstrap failed: ' . $e->getMessage());
}

$requiredFiles = [
    dirname(__DIR__) . '/ip_address.php',
    dirname(__DIR__) . '/Base64URL.php',
    __DIR__ . '/functions.php',
    __DIR__ . '/GeoResolver.php',
    __DIR__ . '/FilterEngine.php',
    __DIR__ . '/public_link.php',
    __DIR__ . '/redirect_payload.php',
];

foreach ($requiredFiles as $requiredFile) {
    if (!is_file($requiredFile) || !is_readable($requiredFile)) {
        $failBootstrap('Missing required bootstrap file: ' . $requiredFile);
    }

    require_once $requiredFile;
}

const SRP_TARGET_BASE = '/_meetups/';
const SRP_BLOCK_URL = 'https://www.youtube.com/';
const SRP_DEFAULT_CANONICAL = 'https://www.denic.de/';
const SRP_GEOIP2LITE_DB = __DIR__ . '/databases/GeoLite2-Country.mmdb';
const SRP_CF_INSIGHTS_SCRIPT = 'https://static.cloudflareinsights.com';
const SRP_CF_INSIGHTS_BEACON = 'https://cloudflareinsights.com';
const SRP_FB_APP_ID = '115190258555800';

// Timed filter mode: 2 min filter → 3 min normal → repeat (5-min cycle)
// SRP_FILTER_URL = hardcoded fallback only; runtime URL comes from cache file or env
const SRP_FILTER_URL = '';
const SRP_CYCLE_LENGTH = 300; // seconds total per cycle (2+3 min)
const SRP_FILTER_DURATION = 120; // seconds at start of cycle = filter window
const SRP_CACHE_DIR_NAME = 'srp_bb'; // shared tmp subdir for all SRP caches

require_once __DIR__ . '/render_og.php';
require_once __DIR__ . '/render_landing.php';

/** @var list<string> $blockedCountries */
$blockedCountries = array_values(
    array_filter(
        array_map(
            'strtoupper',
            explode(',', app_env('SRP_BLOCK_COUNTRIES', 'ID') ?? 'ID'),
        ),
    ),
);

$allowCloudflareInsights = filter_var(
    app_env('SRP_ALLOW_CLOUDFLARE_INSIGHTS', '0') ?? '0',
    FILTER_VALIDATE_BOOL,
);

header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header('X-Frame-Options: DENY');
header('Permissions-Policy: geolocation=(), microphone=(), camera=(), interest-cohort=()');

if (srp_is_https()) {
    header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}

// Step 1 — IP
$userIp = srp_normalize_ip(getUserIP());

// Step 2 — Social bot detection (early, before geo lookup and rate limit)
$userAgent = srp_server_string('HTTP_USER_AGENT');
$isSocialBot = srp_is_social_bot($userAgent);

// Redirect checkers / trackers / preview / validators that spoof a mobile UA are
// treated like preview bots: they are served OG meta tags (HTTP 200) instead of
// the real offer redirect, and like social bots they skip the rate limit, geo
// block, and timed filter. Real mobile visitors (normal mobile browser UA) are
// NOT affected — see srp_is_suspicious_mobile_probe().
$isPreviewProbe = $isSocialBot || srp_is_suspicious_mobile_probe($userAgent);

// Rate limit: 60 requests/minute/IP using file-based token bucket.
// Expensive (geo, proxy check, DB) paths are protected. Social bots skip the limit
// to ensure OG scrapers always get meta tags for link previews.
$rateLimitKey   = 'rl_' . md5($userIp);
$rateLimitDir   = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . SRP_CACHE_DIR_NAME;
$rateLimitFile  = $rateLimitDir . DIRECTORY_SEPARATOR . $rateLimitKey . '.json';
$rateLimitMax   = 60;  // requests per window
$rateLimitWindow = 60; // seconds

$rateLimited = false;
if (!$isPreviewProbe) {
    // Read-modify-write the token bucket under a single exclusive lock so
    // concurrent requests cannot all observe the same stale count (TOCTOU).
    // Schema unchanged: {'c': <count>, 't': <window-start-epoch>}.
    if (!is_dir($rateLimitDir)) {
        @mkdir($rateLimitDir, 0700, true);
    }

    // Fail-open by design: if the bucket file can't be opened or locked
    // (disk/permission fault), let the request through rather than block
    // legitimate traffic on a rate-limit I/O error.
    $rlFp = @fopen($rateLimitFile, 'c+');
    if ($rlFp !== false) {
        if (flock($rlFp, LOCK_EX)) {
            $raw    = (string) stream_get_contents($rlFp, 128, 0);
            $rlData = $raw !== '' ? json_decode($raw, true) : null;

            $now   = time();
            $count = 1;
            $start = $now;

            if (is_array($rlData) && isset($rlData['c'], $rlData['t'])) {
                $elapsed = $now - (int) $rlData['t'];
                if ($elapsed < $rateLimitWindow) {
                    $count = (int) $rlData['c'] + 1;
                    $start = (int) $rlData['t'];
                }
            }

            if ($count > $rateLimitMax) {
                $rateLimited = true;
            } else {
                rewind($rlFp);
                ftruncate($rlFp, 0);
                fwrite($rlFp, (string) json_encode(['c' => $count, 't' => $start]));
                fflush($rlFp);
            }

            flock($rlFp, LOCK_UN);
        }
        fclose($rlFp);
    }

    if ($rateLimited) {
        http_response_code(429);
        header('Retry-After: ' . $rateLimitWindow);
        header('Content-Type: text/plain; charset=UTF-8');
        exit('Rate limit exceeded. Try again later.');
    }
}

// Step 3 — Geo + country block only for real users (skip expensive MaxMind for crawlers)
// Capture raw CF-IPCountry before normalization — srp_header_country_code() discards T1
// for geo purposes, but T1 must still reach the proxy/VPN check in step 4.
$cfRawCountry = '';
$countryCode = 'XX';
if (!$isPreviewProbe) {
    $cfRawCountry = strtoupper(trim(srp_server_string('HTTP_CF_IPCOUNTRY')));
    $countryCode = srp_request_country_code($userIp);

    if (in_array($countryCode, $blockedCountries, true)) {
        header('Location: ' . SRP_BLOCK_URL, true, 302);
        exit;
    }
}

// Step 4 — Token extraction and early validation.
// Validated before the filter check so invalid tokens always return 400,
// never leak into the filter redirect path.
$publicToken = isset($_GET['sub_id']) && is_string($_GET['sub_id']) ? trim($_GET['sub_id']) : '';
if ($publicToken === '' || strlen($publicToken) > 2048 || preg_match('/^[A-Za-z0-9_-]+$/', $publicToken) !== 1) {
    http_response_code(400);
    exit;
}

// Step 5 — Timed filter mode: 2 min filter / 3 min normal, repeating
// Filter: mobile (non-tablet) + no VPN/proxy → redirect to SRP_FILTER_URL.
// If SRP_FILTER_URL is not configured (null), filter is skipped → normal flow.
// Mobile Detect (mobiledetect.net) instantiated once here; reused for $deviceType.
$mobileDetect = $isPreviewProbe ? null : srp_mobile_detect($userAgent);
if ($mobileDetect !== null && srp_is_filter_mode()) {
    $filterUrl = srp_filter_url();

    if ($filterUrl === null) {
        // Filter window is active but no valid filter URL is configured, so
        // filtering is silently inert. Log it (throttled) so the misconfig is
        // visible instead of failing open without a trace.
        srp_log_filter_misconfig();
    } elseif (
        srp_device_type($mobileDetect) === 'WAP'
        && !srp_is_proxy_or_vpn($countryCode, $userIp, $cfRawCountry)
    ) {
        header('Location: ' . $filterUrl, true, 302);
        exit;
    }
}

$publicLink = srp_public_link_decode_legacy_token($publicToken);

if ($publicLink === null) {
    $shortCode = srp_public_link_extract_short_code($publicToken);

    if ($shortCode === null) {
        http_response_code(400);
        exit;
    }

    try {
        /** @var PDO $publicLinkPdo */
        $publicLinkPdo = require __DIR__ . '/connection.config.php';

        // Check active srp_short_links table first; fall back to legacy shortlinks.
        $publicLink = srp_short_link_find($publicLinkPdo, $shortCode);

        if ($publicLink === null) {
            $shortlinkRow = srp_shortlinks_find($publicLinkPdo, $shortCode);

            if ($shortlinkRow !== null) {
                $slLongUrl = trim((string) $shortlinkRow['long_url']);
                $slOgTitle = $shortlinkRow['og_title'];
                $slLongScheme = strtolower((string) parse_url($slLongUrl, PHP_URL_SCHEME));

                if (
                    filter_var($slLongUrl, FILTER_VALIDATE_URL) === false
                    || !in_array($slLongScheme, ['http', 'https'], true)
                ) {
                    http_response_code(404);
                    exit;
                }

                // Accept only HTTPS images because the same-origin proxy fetcher is HTTPS-only.
                $slOgImageRaw = trim((string) $shortlinkRow['og_image']);
                $slOgImageScheme = strtolower((string) parse_url($slOgImageRaw, PHP_URL_SCHEME));
                $slOgImage = (
                    $slOgImageRaw !== ''
                    && $slOgImageScheme === 'https'
                    && filter_var($slOgImageRaw, FILTER_VALIDATE_URL) !== false
                )
                    ? $slOgImageRaw
                    : '';

                if ($isPreviewProbe) {
                    srp_render_og($slOgTitle, $slOgImage);
                    exit;
                }

                header('Location: ' . $slLongUrl, true, 302);
                header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
                exit;
            }
        }
    } catch (Throwable $e) {
        error_log('Public short link lookup failed: ' . $e->getMessage());
        http_response_code(500);
        exit;
    }

    if ($publicLink === null) {
        http_response_code(404);
        exit;
    }
}

$clickId = srp_sanitize_token($publicLink['click_id'], 64);
$userLp = srp_sanitize_token($publicLink['user_lp'], 64);
$canonicalUrl = srp_sanitize_https_url($publicLink['canonical_url']) ?? SRP_DEFAULT_CANONICAL;
$imageUrl = srp_sanitize_https_url($publicLink['image_url']) ?? '';
$title = srp_sanitize_title($publicLink['title']);
$lg = srp_normalize_lg($publicLink['lg']);

if ($clickId === null || $userLp === null) {
    http_response_code(400);
    exit;
}

if ($isPreviewProbe) {
    srp_render_og($title, $imageUrl);
    exit;
}

$deviceType = srp_device_type($mobileDetect ?? srp_mobile_detect($userAgent));

$redirectToken = srp_redirect_payload_encode(
    strtoupper($clickId),
    $countryCode,
    $deviceType,
    $userIp,
    strtoupper($userLp),
);

$target = SRP_TARGET_BASE . '?rk=' . rawurlencode($redirectToken);

switch ($lg) {
    case 'direct':
        header('Location: ' . $target, true, 302);
        exit;

    case 'landing':
        srp_render_landing($target, $allowCloudflareInsights);
        exit;

    default:
        http_response_code(400);
        exit;
}

// ── Pure functions moved to redirect/functions.php ───────────────────────────
// srp_sanitize_token, srp_sanitize_title, srp_sanitize_https_url,
// srp_normalize_lg, srp_device_type, srp_is_social_bot,
// srp_header_country_code, srp_geo_country_code, srp_normalize_ip,
// srp_is_valid_ip, srp_is_https, srp_server_string, srp_validate_host, srp_e

/**
 * Resolve country code with header priority + GeoIP fallback.
 */
function srp_request_country_code(string $ip): string
{
    $headerKeys = [
        'HTTP_CF_IPCOUNTRY',
        'GEOIP_COUNTRY_CODE',
        'HTTP_GEOIP_COUNTRY_CODE',
    ];

    foreach ($headerKeys as $headerKey) {
        $countryCode = srp_header_country_code($headerKey);

        if ($countryCode !== null) {
            return $countryCode;
        }
    }

    return srp_geo_country_code(srp_geo_lookup($ip));
}

// ── GeoIP lookup (I/O — MMDB + file cache) ───────────────────────────────────
// Delegated to GeoResolver class. Legacy wrapper kept for backward compat.

/**
 * @return array<string, mixed>
 * @deprecated Use GeoResolver directly.
 */
function srp_geo_lookup(string $ip): array
{
    static $resolver = null;
    if ($resolver === null) {
        $resolver = new GeoResolver(SRP_GEOIP2LITE_DB, SRP_CACHE_DIR_NAME);
    }

    return $resolver->resolve($ip);
}

// ── Filter engine (I/O — file cache + env) ───────────────────────────────────
// Delegated to FilterEngine class. Legacy wrappers kept for backward compat.

/**
 * @return array{0:int,1:int}
 * @deprecated Use FilterEngine directly.
 */
function srp_filter_timing(): array
{
    static $engine = null;
    $engine ??= new FilterEngine(SRP_CACHE_DIR_NAME, SRP_FILTER_DURATION, SRP_CYCLE_LENGTH, SRP_FILTER_URL);

    return $engine->getTiming();
}

/**
 * @deprecated Use FilterEngine directly.
 */
function srp_is_filter_mode(): bool
{
    static $engine = null;
    $engine ??= new FilterEngine(SRP_CACHE_DIR_NAME, SRP_FILTER_DURATION, SRP_CYCLE_LENGTH, SRP_FILTER_URL);

    return $engine->isFilterMode();
}

/**
 * @deprecated Use FilterEngine directly.
 */
function srp_filter_url(): ?string
{
    static $engine = null;
    $engine ??= new FilterEngine(SRP_CACHE_DIR_NAME, SRP_FILTER_DURATION, SRP_CYCLE_LENGTH, SRP_FILTER_URL);

    return $engine->getFilterUrl();
}

/**
 * Log the "filter window active but no URL configured" misconfiguration at most
 * once per cycle, using a marker file in the cache dir to throttle. Only reached
 * during an actual misconfiguration, so it adds no cost to normal operation.
 */
function srp_log_filter_misconfig(): void
{
    $dir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . SRP_CACHE_DIR_NAME;
    $marker = $dir . DIRECTORY_SEPARATOR . 'filter_misconfig.marker';

    $mtime = is_file($marker) ? filemtime($marker) : false;
    if ($mtime !== false && (time() - $mtime) < SRP_CYCLE_LENGTH) {
        return; // already logged within this cycle
    }

    if (!is_dir($dir)) {
        @mkdir($dir, 0700, true);
    }
    @touch($marker);
    error_log('[srp] Filter window active but no valid SRP_FILTER_URL configured; filtering is inert.');
}

function srp_is_proxy_or_vpn(string $countryCode, string $ip, string $cfRawCountry = ''): bool
{
    // Cloudflare marks Tor exit nodes and known VPN infrastructure as T1.
    // Check $cfRawCountry (raw header) first — srp_header_country_code() discards T1
    // for geo-blocking purposes, so it never reaches $countryCode.
    if ($cfRawCountry === 'T1' || $countryCode === 'T1') {
        return true;
    }

    // Explicit proxy-signature headers — only present when client is behind
    // an additional proxy layer (not injected by Cloudflare itself)
    foreach (['HTTP_VIA', 'HTTP_PROXY_CONNECTION', 'HTTP_X_PROXY_ID'] as $header) {
        if (isset($_SERVER[$header]) && trim((string) $_SERVER[$header]) !== '') {
            return true;
        }
    }

    // blackbox.ipinfo.app v1 — VPN / proxy / Tor / cloud/hosting detection
    if ($ip !== '' && srp_is_valid_ip($ip)) {
        $apiResult = srp_blackbox_lookup($ip);

        if ($apiResult !== null) {
            return $apiResult;
        }
    }

    return false;
}

/**
 * Query blackbox.ipinfo.app v1 (GET /api/v1/{ip}) for VPN/proxy/Tor/cloud.
 * v1 replies with a single char (Y/N/E); a JSON flag object is also accepted.
 * Result cached in sys_get_temp_dir() for 1 hour per IP.
 * Returns null on timeout or API error (caller falls back to false).
 */
function srp_blackbox_lookup(string $ip): ?bool
{
    $cacheDir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . SRP_CACHE_DIR_NAME;
    $cacheFile = $cacheDir . DIRECTORY_SEPARATOR . 'vpn_' . md5($ip) . '.json';
    $cacheTtl = 3600;

    if (is_file($cacheFile)) {
        $mtime = filemtime($cacheFile);

        if ($mtime !== false && (time() - $mtime) < $cacheTtl) {
            $cachedContent = file_get_contents($cacheFile);
            $cached = is_string($cachedContent) ? json_decode($cachedContent, true) : null;

            if (isset($cached['r']) && is_bool($cached['r'])) {
                return $cached['r'];
            }
        }
    }

    if (!function_exists('curl_init')) {
        return null;
    }

    $ch = curl_init('https://blackbox.ipinfo.app/api/v1/' . rawurlencode($ip));

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

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 2,
        CURLOPT_TIMEOUT => 3,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_HTTPHEADER => ['Accept: application/json'],
        CURLOPT_USERAGENT => 'SRP/1.0',
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
        CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
    ]);

    $body = curl_exec($ch);
    $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $errno = curl_errno($ch);
    curl_close($ch);

    if ($errno !== 0 || !is_string($body) || $httpCode !== 200) {
        return null;
    }

    // blackbox v1 returns a single character: Y = listed (proxy/VPN/Tor/cloud),
    // N = clean, E = error. Older/alternate responses are a JSON object with
    // boolean flags — handle both so the lookup never silently fail-opens.
    $token = strtoupper(trim($body));

    if ($token === 'Y' || $token === 'N') {
        $isPrivacy = ($token === 'Y');
    } elseif ($token === 'E' || $token === '') {
        return null;
    } else {
        $data = json_decode($body, true);

        if (!is_array($data)) {
            return null;
        }

        $isPrivacy = (bool) ($data['vpn'] ?? false)
            || (bool) ($data['proxy'] ?? false)
            || (bool) ($data['tor'] ?? false)
            || (bool) ($data['cloud'] ?? false);
    }

    if (!is_dir($cacheDir)) {
        @mkdir($cacheDir, 0700, true);
    }

    @file_put_contents($cacheFile, json_encode(['r' => $isPrivacy]), LOCK_EX);

    return $isPrivacy;
}

// ── Image proxy helper ───────────────────────────────────────────────────────

function srp_proxy_image_url(string $imageUrl): string
{
    if ($imageUrl === '') {
        return '';
    }

    $host = srp_server_string('HTTP_HOST');

    if (!srp_validate_host($host)) {
        return '';
    }

    // Proxy through same-origin /imgp so FB linter can always fetch the image
    $encoded = rtrim(strtr(base64_encode($imageUrl), '+/', '-_'), '=');
    $scheme = srp_is_https() ? 'https' : 'http';

    return $scheme . '://' . $host . '/imgp?u=' . rawurlencode($encoded);
}

// srp_render_og() → render_og.php
// srp_render_landing() → render_landing.php
