<?php
// Visitor/request logging (Section: log_dashboard.php). Deliberately not
// tied to a logged-in user — this app has exactly two accounts behind a
// login wall, so "visitor tracking" here really means "an audit trail of
// page visits," including the login page itself before anyone's signed in.
//
// Every write is wrapped so a logging failure (DB hiccup, slow geo-IP
// lookup, malformed header) can never break the actual page — this is a
// side feature, not core functionality, and must behave like one.

require_once __DIR__ . '/db.php';

/** Best-effort real client IP, checking common proxy/CDN headers before REMOTE_ADDR. */
function get_client_ip(): string
{
    foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_CLIENT_IP'] as $header) {
        if (!empty($_SERVER[$header])) {
            // X-Forwarded-For can be a comma-separated chain; the first
            // entry is the original client.
            $ip = trim(explode(',', $_SERVER[$header])[0]);
            if (filter_var($ip, FILTER_VALIDATE_IP)) {
                return $ip;
            }
        }
    }

    return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}

/** True for localhost/private-network addresses a geo-IP lookup can never resolve anyway. */
function is_local_ip(string $ip): bool
{
    return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}

/**
 * Raw HTTP GET for the geo-IP lookup, returning the response body or false.
 * Tries cURL first: some hosts (as seen in practice) disable
 * allow_url_fopen for security, which silently turns every
 * file_get_contents($url) call into `false` — this made every geo-IP
 * lookup quietly return nulls, with no error anywhere to notice. cURL is a
 * separate PHP extension, not gated by allow_url_fopen, and is enabled on
 * nearly every shared/cPanel host even when allow_url_fopen is locked
 * down, so it's tried first. file_get_contents() remains as a fallback
 * for the rare host with allow_url_fopen on but cURL missing.
 */
function fetch_geo_ip_json(string $url): string|false
{
    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 2,
        ]);
        $response = curl_exec($ch);

        return $response !== false ? $response : false;
    }

    return @file_get_contents($url, false, stream_context_create(['http' => ['timeout' => 2]]));
}

/**
 * Country/region/city/lat/lon/isp for an IP, or all-null fields if it can't
 * be resolved. Checks visitor_log for a recent row from the same IP first
 * — a household app sees the same handful of IPs repeatedly, so this
 * avoids calling the external API on every single page view. Only calls
 * out to ip-api.com (no key required) on a genuine cache miss, with a
 * short timeout so a slow/unreachable API can't stall the page.
 */
function lookup_geo_ip(string $ip): array
{
    $empty = ['country' => null, 'region' => null, 'city' => null, 'latitude' => null, 'longitude' => null, 'isp' => null];

    if (is_local_ip($ip)) {
        return $empty;
    }

    try {
        $stmt = get_db()->prepare(
            'SELECT country, region, city, latitude, longitude, isp FROM visitor_log
             WHERE ip = :ip AND country IS NOT NULL AND visited_at > :since
             ORDER BY visited_at DESC LIMIT 1'
        );
        $stmt->execute(['ip' => $ip, 'since' => date('Y-m-d H:i:s', strtotime('-30 days'))]);
        $cached = $stmt->fetch();
        if ($cached) {
            return $cached;
        }
    } catch (Throwable $e) {
        // Fall through to a fresh lookup — a lookup failure here shouldn't
        // block a fresh attempt below.
    }

    try {
        $url = 'http://ip-api.com/json/' . urlencode($ip) . '?fields=status,country,regionName,city,lat,lon,isp';
        $response = fetch_geo_ip_json($url);
        if ($response === false) {
            return $empty;
        }
        $data = json_decode($response, true);
        if (!is_array($data) || ($data['status'] ?? '') !== 'success') {
            return $empty;
        }

        return [
            'country'   => $data['country'] ?? null,
            'region'    => $data['regionName'] ?? null,
            'city'      => $data['city'] ?? null,
            'latitude'  => $data['lat'] ?? null,
            'longitude' => $data['lon'] ?? null,
            'isp'       => $data['isp'] ?? null,
        ];
    } catch (Throwable $e) {
        return $empty;
    }
}

/** Browser/OS/device guessed from the User-Agent string — good enough for a household traffic log, not a precise UA-parsing library. */
function parse_user_agent(string $ua): array
{
    $browser = match (true) {
        str_contains($ua, 'Edg/') => 'Edge',
        str_contains($ua, 'OPR/') || str_contains($ua, 'Opera') => 'Opera',
        str_contains($ua, 'Chrome/') => 'Chrome',
        str_contains($ua, 'CriOS/') => 'Chrome',
        str_contains($ua, 'FxiOS/') => 'Firefox',
        str_contains($ua, 'Firefox/') => 'Firefox',
        str_contains($ua, 'Safari/') && str_contains($ua, 'Version/') => 'Safari',
        default => null,
    };

    $os = match (true) {
        str_contains($ua, 'iPhone') || str_contains($ua, 'iPad') || str_contains($ua, 'iPod') => 'iOS',
        str_contains($ua, 'Android') => 'Android',
        str_contains($ua, 'Windows') => 'Windows',
        str_contains($ua, 'Mac OS X') => 'macOS',
        str_contains($ua, 'Linux') => 'Linux',
        default => null,
    };

    $device = match (true) {
        str_contains($ua, 'iPad') || str_contains($ua, 'Tablet') => 'Tablet',
        str_contains($ua, 'Mobi') || str_contains($ua, 'iPhone') || str_contains($ua, 'Android') => 'Mobile',
        default => 'Desktop',
    };

    return ['browser' => $browser, 'os' => $os, 'device' => $device];
}

/** Primary language code from the Accept-Language header (e.g. "en", "zh"), or null. */
function parse_language(): ?string
{
    $header = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
    if (preg_match('/^([a-zA-Z-]+)/', $header, $m)) {
        return strtolower(substr($m[1], 0, 32));
    }

    return null;
}

/**
 * Records one row for the current request. Call once near the top of
 * every page (after require_login() where present). Never throws — a
 * logging failure must never take the actual page down with it.
 */
function log_visit(): void
{
    try {
        $ip = get_client_ip();
        $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
        $geo = lookup_geo_ip($ip);
        $agent = parse_user_agent($ua);

        $stmt = get_db()->prepare(
            'INSERT INTO visitor_log
                (ip, country, region, city, latitude, longitude, isp, page, referrer, user_agent, browser, os, device, language, visited_at)
             VALUES
                (:ip, :country, :region, :city, :latitude, :longitude, :isp, :page, :referrer, :user_agent, :browser, :os, :device, :language, :visited_at)'
        );
        $stmt->execute([
            'ip'         => substr($ip, 0, 45),
            'country'    => $geo['country'],
            'region'     => $geo['region'],
            'city'       => $geo['city'],
            'latitude'   => $geo['latitude'],
            'longitude'  => $geo['longitude'],
            'isp'        => $geo['isp'] !== null ? substr($geo['isp'], 0, 128) : null,
            'page'       => substr($_SERVER['REQUEST_URI'] ?? $_SERVER['PHP_SELF'] ?? '', 0, 512),
            'referrer'   => isset($_SERVER['HTTP_REFERER']) ? substr($_SERVER['HTTP_REFERER'], 0, 512) : null,
            'user_agent' => substr($ua, 0, 512),
            'browser'    => $agent['browser'],
            'os'         => $agent['os'],
            'device'     => $agent['device'],
            'language'   => parse_language(),
            // Written from PHP's own clock (Asia/Shanghai — see config.php's
            // date_default_timezone_set) rather than MySQL's NOW(), so the
            // stored value's timezone is fixed and known regardless of
            // whatever timezone the underlying MySQL server happens to be
            // configured with (which differs between local XAMPP and a
            // production host, and previously made this column's true
            // timezone effectively undefined).
            'visited_at' => date('Y-m-d H:i:s'),
        ]);
    } catch (Throwable $e) {
        // Deliberately swallowed — see file header. Nothing to do here;
        // a missing log row is fine, a broken page is not.
    }
}
