<?php
/**
 * Shared SQLite client for site traffic analytics.
 *
 * Single source of truth for the per-user monthly SQLite schema. Used by
 * the nightly aggregator (write side) and by the Traffic page + AI Monitor
 * panel (read side).
 *
 * Storage layout:
 *   /docker/users/<username>/whp-analytics/<YYYY-MM>.sqlite3
 *
 * Folder is mode 0750 owned by the site user; files are mode 0640 owned by
 * the site user. The aggregator runs as root, so it chowns after create.
 */

class TrafficAnalytics
{
    private string $usersBaseDir;

    /** @var array<string, PDO> Cache keyed by "<username>|<yyyy-mm>" */
    private array $connectionCache = [];

    public function __construct(string $usersBaseDir = '/docker/users')
    {
        $this->usersBaseDir = rtrim($usersBaseDir, '/');
    }

    public function getOrCreateMonthDb(string $username, string $yearMonth): PDO
    {
        $this->assertYearMonth($yearMonth);
        $this->assertUsername($username);

        $cacheKey = $username . '|' . $yearMonth;
        if (isset($this->connectionCache[$cacheKey])) {
            return $this->connectionCache[$cacheKey];
        }

        $analyticsDir = $this->usersBaseDir . '/' . $username . '/whp-analytics';
        $dbPath = $analyticsDir . '/' . $yearMonth . '.sqlite3';

        $this->ensureAnalyticsDir($analyticsDir, $username);

        $isNewFile = !file_exists($dbPath);

        $pdo = new PDO('sqlite:' . $dbPath);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $pdo->exec('PRAGMA journal_mode=WAL;');
        $pdo->exec('PRAGMA synchronous=NORMAL;');

        $this->ensureSchema($pdo);

        if ($isNewFile) {
            $this->chownToUser($dbPath, $username, 0640);
        }

        $this->connectionCache[$cacheKey] = $pdo;
        return $pdo;
    }

    /**
     * @param array{total_requests:int,human_views:int,bytes_sent:int} $totals
     * @param array<int,array{url:string,hits:int}> $topUrls
     * @param array<int,array{url:string,bytes_total:int,hits:int}> $topBandwidth
     */
    public function recordDailySnapshot(
        string $username,
        int $siteId,
        string $domain,
        string $date,
        array $totals,
        array $topUrls,
        array $topBandwidth
    ): void {
        $this->assertDate($date);
        $yearMonth = substr($date, 0, 7);
        $pdo = $this->getOrCreateMonthDb($username, $yearMonth);

        $pdo->beginTransaction();
        try {
            $stmt = $pdo->prepare(
                'INSERT OR REPLACE INTO daily_totals
                 (date, site_id, domain, total_requests, human_views, bytes_sent)
                 VALUES (:date, :site_id, :domain, :total_requests, :human_views, :bytes_sent)'
            );
            $stmt->execute([
                ':date' => $date,
                ':site_id' => $siteId,
                ':domain' => $domain,
                ':total_requests' => (int)($totals['total_requests'] ?? 0),
                ':human_views' => (int)($totals['human_views'] ?? 0),
                ':bytes_sent' => (int)($totals['bytes_sent'] ?? 0),
            ]);

            $pdo->prepare('DELETE FROM daily_top_urls WHERE date = :date AND site_id = :site_id')
                ->execute([':date' => $date, ':site_id' => $siteId]);
            $pdo->prepare('DELETE FROM daily_top_bandwidth WHERE date = :date AND site_id = :site_id')
                ->execute([':date' => $date, ':site_id' => $siteId]);

            $urlStmt = $pdo->prepare(
                'INSERT OR REPLACE INTO daily_top_urls
                 (date, site_id, rank, url, hits)
                 VALUES (:date, :site_id, :rank, :url, :hits)'
            );
            $rank = 0;
            foreach (array_slice($topUrls, 0, 10) as $row) {
                $rank++;
                $urlStmt->execute([
                    ':date' => $date,
                    ':site_id' => $siteId,
                    ':rank' => $rank,
                    ':url' => (string)($row['url'] ?? ''),
                    ':hits' => (int)($row['hits'] ?? 0),
                ]);
            }

            $bwStmt = $pdo->prepare(
                'INSERT OR REPLACE INTO daily_top_bandwidth
                 (date, site_id, rank, url, bytes_total, hits)
                 VALUES (:date, :site_id, :rank, :url, :bytes_total, :hits)'
            );
            $rank = 0;
            foreach (array_slice($topBandwidth, 0, 10) as $row) {
                $rank++;
                $bwStmt->execute([
                    ':date' => $date,
                    ':site_id' => $siteId,
                    ':rank' => $rank,
                    ':url' => (string)($row['url'] ?? ''),
                    ':bytes_total' => (int)($row['bytes_total'] ?? 0),
                    ':hits' => (int)($row['hits'] ?? 0),
                ]);
            }

            $pdo->commit();
        } catch (Throwable $e) {
            $pdo->rollBack();
            throw $e;
        }
    }

    public function getDailyTotals(string $username, int $siteId, string $date): ?array
    {
        $this->assertDate($date);
        $yearMonth = substr($date, 0, 7);
        $pdo = $this->openExistingMonthDb($username, $yearMonth);
        if (!$pdo) {
            return null;
        }
        $stmt = $pdo->prepare(
            'SELECT date, site_id, domain, total_requests, human_views, bytes_sent
             FROM daily_totals
             WHERE date = :date AND site_id = :site_id'
        );
        $stmt->execute([':date' => $date, ':site_id' => $siteId]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row !== false ? $row : null;
    }

    public function getMonthSummary(string $username, int $siteId, string $yearMonth): array
    {
        $this->assertYearMonth($yearMonth);
        $pdo = $this->openExistingMonthDb($username, $yearMonth);
        if (!$pdo) {
            return [];
        }
        $stmt = $pdo->prepare(
            'SELECT date, site_id, domain, total_requests, human_views, bytes_sent
             FROM daily_totals
             WHERE site_id = :site_id
             ORDER BY date ASC'
        );
        $stmt->execute([':site_id' => $siteId]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function getTopUrls(string $username, int $siteId, string $date): array
    {
        $this->assertDate($date);
        $yearMonth = substr($date, 0, 7);
        $pdo = $this->openExistingMonthDb($username, $yearMonth);
        if (!$pdo) {
            return [];
        }
        $stmt = $pdo->prepare(
            'SELECT rank, url, hits
             FROM daily_top_urls
             WHERE date = :date AND site_id = :site_id
             ORDER BY rank ASC'
        );
        $stmt->execute([':date' => $date, ':site_id' => $siteId]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function getTopBandwidth(string $username, int $siteId, string $date): array
    {
        $this->assertDate($date);
        $yearMonth = substr($date, 0, 7);
        $pdo = $this->openExistingMonthDb($username, $yearMonth);
        if (!$pdo) {
            return [];
        }
        $stmt = $pdo->prepare(
            'SELECT rank, url, bytes_total, hits
             FROM daily_top_bandwidth
             WHERE date = :date AND site_id = :site_id
             ORDER BY rank ASC'
        );
        $stmt->execute([':date' => $date, ':site_id' => $siteId]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function getRecentDailyTotals(string $username, int $siteId, int $days): array
    {
        if ($days < 1) {
            return [];
        }

        // The window may straddle a month boundary, so collect from the
        // current month back through whichever older month files we need.
        $today = new DateTimeImmutable('today');
        $start = $today->modify('-' . ($days - 1) . ' days');

        $months = [];
        $cursor = new DateTimeImmutable($start->format('Y-m-01'));
        $endMonth = new DateTimeImmutable($today->format('Y-m-01'));
        while ($cursor <= $endMonth) {
            $months[] = $cursor->format('Y-m');
            $cursor = $cursor->modify('+1 month');
        }

        $startStr = $start->format('Y-m-d');
        $endStr = $today->format('Y-m-d');

        $rows = [];
        foreach ($months as $ym) {
            $pdo = $this->openExistingMonthDb($username, $ym);
            if (!$pdo) {
                continue;
            }
            $stmt = $pdo->prepare(
                'SELECT date, site_id, domain, total_requests, human_views, bytes_sent
                 FROM daily_totals
                 WHERE site_id = :site_id AND date >= :start AND date <= :end
                 ORDER BY date ASC'
            );
            $stmt->execute([
                ':site_id' => $siteId,
                ':start' => $startStr,
                ':end' => $endStr,
            ]);
            foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
                $rows[] = $r;
            }
        }

        usort($rows, fn($a, $b) => strcmp($a['date'], $b['date']));
        return $rows;
    }

    public function listAvailableMonths(string $username): array
    {
        $this->assertUsername($username);
        $dir = $this->usersBaseDir . '/' . $username . '/whp-analytics';
        if (!is_dir($dir)) {
            return [];
        }
        $months = [];
        foreach (scandir($dir) ?: [] as $name) {
            if (preg_match('/^(\d{4}-\d{2})\.sqlite3$/', $name, $m)) {
                $months[] = $m[1];
            }
        }
        rsort($months);
        return array_slice($months, 0, 12);
    }

    private function openExistingMonthDb(string $username, string $yearMonth): ?PDO
    {
        $this->assertUsername($username);
        $this->assertYearMonth($yearMonth);
        $dbPath = $this->usersBaseDir . '/' . $username . '/whp-analytics/' . $yearMonth . '.sqlite3';
        if (!file_exists($dbPath)) {
            return null;
        }
        $cacheKey = $username . '|' . $yearMonth;
        if (isset($this->connectionCache[$cacheKey])) {
            return $this->connectionCache[$cacheKey];
        }
        $pdo = new PDO('sqlite:' . $dbPath);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $pdo->exec('PRAGMA journal_mode=WAL;');
        $this->connectionCache[$cacheKey] = $pdo;
        return $pdo;
    }

    private function ensureSchema(PDO $pdo): void
    {
        $pdo->exec(
            'CREATE TABLE IF NOT EXISTS daily_totals (
                date TEXT NOT NULL,
                site_id INTEGER NOT NULL,
                domain TEXT NOT NULL,
                total_requests INTEGER NOT NULL,
                human_views INTEGER NOT NULL,
                bytes_sent INTEGER NOT NULL,
                PRIMARY KEY (date, site_id)
            )'
        );
        $pdo->exec(
            'CREATE TABLE IF NOT EXISTS daily_top_urls (
                date TEXT NOT NULL,
                site_id INTEGER NOT NULL,
                rank INTEGER NOT NULL,
                url TEXT NOT NULL,
                hits INTEGER NOT NULL,
                PRIMARY KEY (date, site_id, rank)
            )'
        );
        $pdo->exec(
            'CREATE TABLE IF NOT EXISTS daily_top_bandwidth (
                date TEXT NOT NULL,
                site_id INTEGER NOT NULL,
                rank INTEGER NOT NULL,
                url TEXT NOT NULL,
                bytes_total INTEGER NOT NULL,
                hits INTEGER NOT NULL,
                PRIMARY KEY (date, site_id, rank)
            )'
        );
        $pdo->exec(
            'CREATE INDEX IF NOT EXISTS idx_daily_totals_site ON daily_totals(site_id, date)'
        );
    }

    private function ensureAnalyticsDir(string $analyticsDir, string $username): void
    {
        if (!is_dir($analyticsDir)) {
            if (!@mkdir($analyticsDir, 0750, true) && !is_dir($analyticsDir)) {
                throw new RuntimeException("Failed to create $analyticsDir");
            }
            @chmod($analyticsDir, 0750);
            $this->chownToUser($analyticsDir, $username, null);
        }
    }

    private function chownToUser(string $path, string $username, ?int $mode): void
    {
        if (function_exists('posix_geteuid') && posix_geteuid() !== 0) {
            if ($mode !== null) {
                @chmod($path, $mode);
            }
            return;
        }

        if (!function_exists('posix_getpwnam')) {
            return;
        }
        $pw = @posix_getpwnam($username);
        if ($pw === false) {
            return;
        }
        @chown($path, $pw['uid']);
        @chgrp($path, $pw['gid']);
        if ($mode !== null) {
            @chmod($path, $mode);
        }
    }

    private function assertUsername(string $username): void
    {
        if ($username === '' || !preg_match('/^[a-zA-Z0-9_.\-]+$/', $username)) {
            throw new InvalidArgumentException("Invalid username: $username");
        }
    }

    private function assertYearMonth(string $yearMonth): void
    {
        if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $yearMonth)) {
            throw new InvalidArgumentException("Invalid year-month: $yearMonth");
        }
    }

    private function assertDate(string $date): void
    {
        if (!preg_match('/^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/', $date, $m)
            || !checkdate((int)$m[2], (int)$m[3], (int)$m[1])) {
            throw new InvalidArgumentException("Invalid date: $date");
        }
    }
}
