<?php
/* ============================================================
   report.php - Visitor analytics report built from visits.csv

   Reads the CSV written by log.php and renders a single HTML
   page with visit counts over rolling time windows, plus a few
   breakdowns (browser, device, referrer, language, pages, top
   IPs) and an optional by-location report driven by free
   reverse-geolocation lookups (ip-api.com).

   - Rows from EXCLUDE_IPS (your own addresses) are dropped.
   - Geolocation results are cached in geoip-cache.json so the
     free API is queried at most once per new IP.
   - "Visits" = CSV rows. "Visitors" = distinct IPs.

   NOTE: this page exposes IPs and locations. Protect it the way
   visits.csv is protected (see .htaccess) or add auth below.
   ============================================================ */

/* ---------------- configuration ---------------- */

// Your own IPs (and any bots/monitors) to exclude from the report.
// Supports exact IPs and CIDR ranges (e.g. '203.0.113.0/24').
// Shared with count.php via own_ips.php so both stay in sync.
define('EXCLUDE_IPS', require __DIR__ . '/own_ips.php');

// Reverse-geolocate IPs? Requires outbound HTTP to ip-api.com.
// Free tier: 45 requests/min. Results are cached to disk.
const ENABLE_GEOIP = true;

const CSV_FILE   = __DIR__ . '/visits.csv';
const GEO_CACHE  = __DIR__ . '/geoip-cache.json';

date_default_timezone_set('UTC');   // log.php writes server time; match it here.

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

function ip_in_cidr(string $ip, string $cidr): bool {
    if (strpos($cidr, '/') === false) return $ip === $cidr;
    [$subnet, $bits] = explode('/', $cidr, 2);
    $bits = (int)$bits;
    $ipB = @inet_pton($ip);
    $subB = @inet_pton($subnet);
    if ($ipB === false || $subB === false || strlen($ipB) !== strlen($subB)) return false;
    $bytes = intdiv($bits, 8);
    $rem   = $bits % 8;
    if ($bytes && strncmp($ipB, $subB, $bytes) !== 0) return false;
    if ($rem) {
        $mask = chr(0xff << (8 - $rem) & 0xff);
        if ((ord($ipB[$bytes]) & ord($mask)) !== (ord($subB[$bytes]) & ord($mask))) return false;
    }
    return true;
}

function is_excluded(string $ip): bool {
    foreach (EXCLUDE_IPS as $rule) {
        if (ip_in_cidr($ip, $rule)) return true;
    }
    return false;
}

function is_bot(string $ua): bool {
    return (bool)preg_match('/bot|crawl|spider|slurp|headless|monitor|curl|wget|python-requests|facebookexternalhit|preview/i', $ua);
}

function h(?string $s): string { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }

/* referrer -> source label */
function ref_label(string $ref): string {
    if ($ref === '') return 'Direct / none';
    $host = parse_url($ref, PHP_URL_HOST) ?: $ref;
    $host = preg_replace('/^www\./', '', strtolower($host));
    return $host ?: 'Direct / none';
}

/* language header -> primary language tag */
function lang_label(string $lang): string {
    if ($lang === '') return 'Unknown';
    $first = trim(explode(',', $lang)[0]);
    return $first !== '' ? $first : 'Unknown';
}

/* ---------------- load + filter the CSV ---------------- */

if (!is_readable(CSV_FILE)) {
    http_response_code(500);
    echo '<h1>report.php</h1><p>Cannot read ' . h(CSV_FILE) . '</p>';
    exit;
}

$rows = [];              // filtered visit rows
$excludedCount = 0;
$botCount = 0;

$fh = fopen(CSV_FILE, 'r');
$header = fgetcsv($fh);   // skip header line
while (($r = fgetcsv($fh)) !== false) {
    if (count($r) < 9) continue;
    [$dt, $ip, $browser, $device, $ref, $page, $screen, $lang, $ua] = $r;
    if ($ip !== '' && is_excluded($ip)) { $excludedCount++; continue; }
    $ts = strtotime($dt);
    if ($ts === false) continue;
    $bot = is_bot($ua);
    if ($bot) $botCount++;
    $rows[] = [
        'ts' => $ts, 'dt' => $dt, 'ip' => $ip, 'browser' => $browser,
        'device' => $device, 'ref' => $ref, 'page' => $page,
        'screen' => $screen, 'lang' => $lang, 'ua' => $ua, 'bot' => $bot,
    ];
}
fclose($fh);

$now = time();

/* ---------------- rolling time windows ---------------- */

$windows = [
    'Last 1 hour'   => 3600,
    'Last 6 hours'  => 6 * 3600,
    'Last 24 hours' => 24 * 3600,
    'Last 7 days'   => 7 * 86400,
    'Last 30 days'  => 30 * 86400,
];

$windowStats = [];
foreach ($windows as $label => $secs) {
    $cutoff = $now - $secs;
    $visits = 0; $ips = [];
    foreach ($rows as $row) {
        if ($row['ts'] >= $cutoff) { $visits++; $ips[$row['ip']] = true; }
    }
    $windowStats[$label] = ['visits' => $visits, 'visitors' => count($ips)];
}

$totalVisits   = count($rows);
$uniqueIps     = [];
foreach ($rows as $row) $uniqueIps[$row['ip']] = true;
$totalVisitors = count($uniqueIps);

/* ---------------- breakdown helper ---------------- */

function tally(array $rows, callable $keyFn): array {
    $out = [];
    foreach ($rows as $row) {
        $k = $keyFn($row);
        if ($k === '' || $k === null) continue;
        $out[$k] = ($out[$k] ?? 0) + 1;
    }
    arsort($out);
    return $out;
}

$byBrowser = tally($rows, fn($r) => $r['browser'] !== '' ? $r['browser'] : 'Unknown');
$byDevice  = tally($rows, fn($r) => $r['device'] !== '' ? $r['device'] : 'Unknown');
$byRef     = tally($rows, fn($r) => ref_label($r['ref']));
$byLang    = tally($rows, fn($r) => lang_label($r['lang']));
$byPage    = tally($rows, fn($r) => $r['page'] !== '' ? $r['page'] : '(none)');

/* top IPs by visit count */
$byIp = tally($rows, fn($r) => $r['ip'] !== '' ? $r['ip'] : '(blank)');

/* visits per hour-of-day (0-23), to spot busy times */
$byHour = array_fill(0, 24, 0);
foreach ($rows as $row) $byHour[(int)date('G', $row['ts'])]++;

/* visits per calendar day (last 14 days) */
$byDay = [];
for ($i = 13; $i >= 0; $i--) $byDay[date('Y-m-d', $now - $i * 86400)] = 0;
foreach ($rows as $row) {
    $d = date('Y-m-d', $row['ts']);
    if (isset($byDay[$d])) $byDay[$d]++;
}

/* ---------------- reverse geolocation ---------------- */

$geo = [];       // ip => ['country','regionName','city','isp'] (or null on failure)
$byCountry = []; $byCity = [];

if (ENABLE_GEOIP && $totalVisitors > 0) {
    // load cache
    $cache = is_readable(GEO_CACHE) ? json_decode(file_get_contents(GEO_CACHE), true) : [];
    if (!is_array($cache)) $cache = [];

    // which public IPs still need a lookup?
    $need = [];
    foreach (array_keys($uniqueIps) as $ip) {
        if ($ip === '' || isset($cache[$ip])) continue;
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
            $cache[$ip] = ['status' => 'skip'];   // private/reserved
            continue;
        }
        $need[] = $ip;
    }

    // query ip-api.com in batches of 100 (free, no key). Best-effort.
    foreach (array_chunk($need, 100) as $chunk) {
        $payload = array_map(fn($ip) => ['query' => $ip, 'fields' => 'status,country,regionName,city,isp,query'], $chunk);
        $ctx = stream_context_create(['http' => [
            'method'  => 'POST',
            'header'  => "Content-Type: application/json\r\n",
            'content' => json_encode($payload),
            'timeout' => 8,
        ]]);
        $resp = @file_get_contents('http://ip-api.com/batch', false, $ctx);
        if ($resp === false) break;   // network unavailable -> stop trying
        $data = json_decode($resp, true);
        if (!is_array($data)) break;
        foreach ($data as $item) {
            if (!empty($item['query'])) $cache[$item['query']] = $item;
        }
        usleep(300000);   // be gentle with the rate limit
    }

    @file_put_contents(GEO_CACHE, json_encode($cache), LOCK_EX);

    // build location tallies over VISITS (weight by activity)
    foreach ($rows as $row) {
        $ip = $row['ip'];
        $info = $cache[$ip] ?? null;
        if (!$info || ($info['status'] ?? '') !== 'success') continue;
        $geo[$ip] = $info;
        $country = $info['country'] ?: 'Unknown';
        $city    = trim(($info['city'] ?? '') . ', ' . ($info['regionName'] ?? '') . ', ' . ($info['country'] ?? ''), ', ');
        $byCountry[$country] = ($byCountry[$country] ?? 0) + 1;
        if ($city !== '') $byCity[$city] = ($byCity[$city] ?? 0) + 1;
    }
    arsort($byCountry); arsort($byCity);
}

/* ---------------- render ---------------- */

function bar_table(string $title, array $data, int $limit = 12): string {
    if (!$data) return '<section class="card"><h2>' . h($title) . '</h2><p class="muted">No data.</p></section>';
    $max = max($data);
    $total = array_sum($data);
    $html = '<section class="card"><h2>' . h($title) . '</h2><table>';
    $i = 0;
    foreach ($data as $k => $v) {
        if ($i++ >= $limit) break;
        $pct = $total ? round($v / $total * 100) : 0;
        $w   = $max ? round($v / $max * 100) : 0;
        $html .= '<tr><td class="k">' . h((string)$k) . '</td>'
              . '<td class="bar"><span style="width:' . $w . '%"></span></td>'
              . '<td class="v">' . $v . ' <span class="muted">(' . $pct . '%)</span></td></tr>';
    }
    $html .= '</table></section>';
    return $html;
}

?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Visitor Report &middot; snoopytennis</title>
<style>
  :root { color-scheme: light dark; }
  * { box-sizing: border-box; }
  body { font: 15px/1.5 -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
         margin: 0; padding: 24px; background: #0f1216; color: #e6e9ee; }
  h1 { margin: 0 0 4px; font-size: 22px; }
  .sub { color: #8b93a1; margin: 0 0 24px; font-size: 13px; }
  .grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
  .stat { background: #171b22; border: 1px solid #232a34; border-radius: 12px; padding: 16px 18px; }
  .stat .big { font-size: 28px; font-weight: 700; }
  .stat .lbl { color: #8b93a1; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
  .stat .sub2 { font-size: 12px; margin-top: 2px; color:#7d8695; }
  .cards { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); margin-top: 24px; }
  .card { background: #171b22; border: 1px solid #232a34; border-radius: 12px; padding: 16px 18px; }
  .card h2 { margin: 0 0 12px; font-size: 15px; }
  table { width: 100%; border-collapse: collapse; }
  td { padding: 4px 0; vertical-align: middle; font-size: 13px; }
  td.k { white-space: nowrap; max-width: 170px; overflow: hidden; text-overflow: ellipsis; padding-right: 10px; }
  td.v { text-align: right; white-space: nowrap; padding-left: 10px; }
  td.bar { width: 100%; }
  td.bar span { display: block; height: 8px; border-radius: 4px; background: linear-gradient(90deg,#3b82f6,#22d3ee); min-width: 2px; }
  .muted { color: #7d8695; }
  section.full { grid-column: 1 / -1; }
  .hours { display: flex; gap: 3px; align-items: flex-end; height: 80px; }
  .hours div { flex: 1; background:#2b3644; border-radius: 3px 3px 0 0; position: relative; }
  .hours div span { position: absolute; bottom: -18px; left: 0; right: 0; text-align: center; font-size: 9px; color: #6f7683; }
  code { background:#0f1216; padding:1px 5px; border-radius:4px; }
  a { color:#60a5fa; }
</style>
</head>
<body>
  <h1>Visitor Report</h1>
  <p class="sub">
    Generated <?= h(date('Y-m-d H:i:s')) ?> UTC &middot;
    <?= number_format($totalVisits) ?> visits from <?= number_format($totalVisitors) ?> unique IPs &middot;
    <?= number_format($excludedCount) ?> own-IP rows excluded &middot;
    <?= number_format($botCount) ?> bot/headless hits flagged
  </p>

  <div class="grid">
    <div class="stat"><div class="lbl">Total visits</div><div class="big"><?= number_format($totalVisits) ?></div><div class="sub2"><?= number_format($totalVisitors) ?> unique visitors</div></div>
<?php foreach ($windowStats as $label => $s): ?>
    <div class="stat"><div class="lbl"><?= h($label) ?></div><div class="big"><?= number_format($s['visits']) ?></div><div class="sub2"><?= number_format($s['visitors']) ?> unique visitors</div></div>
<?php endforeach; ?>
  </div>

  <div class="cards">
    <?= bar_table('Browsers', $byBrowser) ?>
    <?= bar_table('Devices', $byDevice) ?>
    <?= bar_table('Traffic sources (referrer)', $byRef) ?>
    <?= bar_table('Languages', $byLang) ?>
    <?= bar_table('Pages', $byPage) ?>
    <?= bar_table('Top visitors (by IP)', $byIp) ?>

<?php if (ENABLE_GEOIP): ?>
    <?= bar_table('Visits by country', $byCountry) ?>
    <?= bar_table('Visits by city', $byCity) ?>
<?php endif; ?>

    <section class="card full">
      <h2>Visits by hour of day (UTC)</h2>
      <div class="hours">
<?php $maxH = max(1, max($byHour)); foreach ($byHour as $hh => $c): ?>
        <div style="height:<?= round($c / $maxH * 100) ?>%" title="<?= $hh ?>:00 — <?= $c ?> visits"><?php if ($hh % 3 === 0): ?><span><?= $hh ?></span><?php endif; ?></div>
<?php endforeach; ?>
      </div>
    </section>

    <?= bar_table('Visits per day (last 14 days)', $byDay, 14) ?>
  </div>

<?php if (ENABLE_GEOIP && $geo): ?>
  <div class="cards">
    <section class="card full">
      <h2>Per-visitor detail</h2>
      <table>
        <tr class="muted"><td class="k">IP</td><td>Location</td><td>ISP</td><td class="v">Visits</td></tr>
<?php foreach ($byIp as $ip => $cnt): $g = $geo[$ip] ?? null;
        $loc = $g ? trim(($g['city'] ?? '') . ', ' . ($g['regionName'] ?? '') . ', ' . ($g['country'] ?? ''), ', ') : '—'; ?>
        <tr>
          <td class="k"><code><?= h((string)$ip) ?></code></td>
          <td><?= h($loc ?: '—') ?></td>
          <td class="muted"><?= h($g['isp'] ?? '—') ?></td>
          <td class="v"><?= (int)$cnt ?></td>
        </tr>
<?php endforeach; ?>
      </table>
    </section>
  </div>
<?php endif; ?>

  <p class="sub" style="margin-top:24px">
    Excluded IPs: <?= h(implode(', ', EXCLUDE_IPS)) ?>.
    Edit <code>EXCLUDE_IPS</code> in <code>report.php</code> to change.
    <?php if (ENABLE_GEOIP): ?>Geolocation cached in <code>geoip-cache.json</code>.<?php endif; ?>
  </p>
</body>
</html>
