<?php

declare(strict_types=1);

namespace PpPhp\Core\Analytics;

use PpPhp\Core\PlatformSettings;

/**
 * The registry of "properties" that report into this install's Visitor console —
 * the marketing site, the community portal, a backup server, a generated app.
 *
 * Each property has a public `site_id` (safe to embed in a browser snippet) and a
 * secret used ONLY for server-to-server reporting (stored hashed, shown once).
 * This is the Umami/Plausible model: one dashboard, many sites, each with its own
 * id. The owner manages the list from the console; the collector
 * ({@see AnalyticsCollector}) validates against it.
 */
final class AnalyticsSites
{
    public const SETTING = 'platform.analytics_sites';

    /** Channels a site may map its traffic to. */
    public const CHANNELS = ['marketing', 'community', 'app', 'update', 'console'];

    /** @return array<string,array<string,mixed>> site_id => record (no secrets) */
    public static function all(): array
    {
        $raw = [];
        try {
            $bag = PlatformSettings::get(self::SETTING);
            if (is_array($bag)) $raw = $bag;
        } catch (\Throwable $e) { /* no DB yet */ }

        $out = [];
        foreach ($raw as $id => $s) {
            if (!is_array($s)) continue;
            $out[(string) $id] = [
                'site_id'         => (string) $id,
                'label'           => (string) ($s['label'] ?? $id),
                'channel'         => (string) ($s['channel'] ?? 'marketing'),
                'browser_enabled' => (bool) ($s['browser_enabled'] ?? true),
                'created'         => (int) ($s['created'] ?? 0),
                'last_seen'       => (int) ($s['last_seen'] ?? 0),
                'has_secret'      => ($s['secret_hash'] ?? '') !== '',
            ];
        }
        return $out;
    }

    /** @return array<string,mixed>|null the RAW stored record (with secret_hash) */
    private static function raw(string $id): ?array
    {
        try {
            $bag = PlatformSettings::get(self::SETTING);
            if (is_array($bag) && isset($bag[$id]) && is_array($bag[$id])) return $bag[$id];
        } catch (\Throwable $e) { /* ignore */ }
        return null;
    }

    private static function save(array $bag): bool
    {
        try { return PlatformSettings::set(self::SETTING, $bag); }
        catch (\Throwable $e) { return false; }
    }

    /**
     * Create a property. Returns its public id AND the plaintext secret — the
     * secret is shown to the owner ONCE and only its hash is stored.
     *
     * @return array{site_id:string,secret:string,label:string,channel:string}
     */
    public static function create(string $label, string $channel = 'marketing', bool $browserEnabled = true): array
    {
        $channel = in_array($channel, self::CHANNELS, true) ? $channel : 'marketing';
        $bag = [];
        try { $bag = (array) PlatformSettings::get(self::SETTING); } catch (\Throwable $e) {}

        $id = 'st_' . bin2hex(random_bytes(6));
        $secret = bin2hex(random_bytes(20));
        $bag[$id] = [
            'label'           => substr(trim($label) !== '' ? trim($label) : $id, 0, 120),
            'channel'         => $channel,
            'browser_enabled' => $browserEnabled,
            'secret_hash'     => hash('sha256', $secret),
            'created'         => time(),
        ];
        self::save($bag);
        return ['site_id' => $id, 'secret' => $secret, 'label' => $bag[$id]['label'], 'channel' => $channel];
    }

    /**
     * Return an EXISTING property for $channel (first match), or create one — so a
     * deliverable / portal always has a VALID, registered site id to report against.
     * This closes the two-registry gap where the Deliverables page could bake a
     * site id that was never registered, and the collector then silently 404'd
     * every event. The `secret` is non-null only when a property was just created.
     *
     * @return array{site_id:string,channel:string,label:string,secret:?string,created:bool}
     */
    public static function ensureForChannel(string $channel, string $label = ''): array
    {
        $channel = in_array($channel, self::CHANNELS, true) ? $channel : 'marketing';
        foreach (self::all() as $id => $s) {
            if (($s['channel'] ?? '') === $channel) {
                return ['site_id' => (string) $id, 'channel' => $channel, 'label' => (string) $s['label'], 'secret' => null, 'created' => false];
            }
        }
        $made = self::create($label !== '' ? $label : (ucfirst($channel) . ' site'), $channel, true);
        return ['site_id' => $made['site_id'], 'channel' => $channel, 'label' => $made['label'], 'secret' => $made['secret'], 'created' => true];
    }

    /** True if $id is a registered property; optionally require a specific channel. */
    public static function isRegistered(string $id, string $channel = ''): bool
    {
        $raw = self::raw($id);
        if ($raw === null) return false;
        return $channel === '' || (string) ($raw['channel'] ?? '') === $channel;
    }

    /**
     * Stamp that a property just reported in — throttled to ~1/min so the hot
     * collector path doesn't rewrite the settings blob on every pageview. Lets the
     * console show "last received" and flag properties that are wired but silent.
     */
    public static function touch(string $id): void
    {
        try {
            $bag = (array) PlatformSettings::get(self::SETTING);
            if (!isset($bag[$id]) || !is_array($bag[$id])) return;
            $now = time();
            if ($now - (int) ($bag[$id]['last_seen'] ?? 0) < 60) return;   // throttle writes
            $bag[$id]['last_seen'] = $now;
            self::save($bag);
        } catch (\Throwable $e) { /* ignore */ }
    }

    /** Rotate a property's secret. Returns the new plaintext secret (shown once). */
    public static function regenerate(string $id): ?string
    {
        $raw = self::raw($id);
        if ($raw === null) return null;
        $secret = bin2hex(random_bytes(20));
        try {
            $bag = (array) PlatformSettings::get(self::SETTING);
            $bag[$id]['secret_hash'] = hash('sha256', $secret);
            self::save($bag);
        } catch (\Throwable $e) { return null; }
        return $secret;
    }

    public static function update(string $id, array $patch): bool
    {
        try {
            $bag = (array) PlatformSettings::get(self::SETTING);
            if (!isset($bag[$id]) || !is_array($bag[$id])) return false;
            if (isset($patch['label']))   $bag[$id]['label'] = substr((string) $patch['label'], 0, 120);
            if (isset($patch['channel']) && in_array($patch['channel'], self::CHANNELS, true)) $bag[$id]['channel'] = (string) $patch['channel'];
            if (isset($patch['browser_enabled'])) $bag[$id]['browser_enabled'] = (bool) $patch['browser_enabled'];
            return self::save($bag);
        } catch (\Throwable $e) { return false; }
    }

    public static function delete(string $id): bool
    {
        try {
            $bag = (array) PlatformSettings::get(self::SETTING);
            if (!isset($bag[$id])) return false;
            unset($bag[$id]);
            return self::save($bag);
        } catch (\Throwable $e) { return false; }
    }

    /** @return array{channel:string,browser_enabled:bool}|null site meta if it exists */
    public static function lookup(string $id): ?array
    {
        $raw = self::raw($id);
        if ($raw === null) return null;
        return [
            'channel'         => (string) ($raw['channel'] ?? 'marketing'),
            'browser_enabled' => (bool) ($raw['browser_enabled'] ?? true),
        ];
    }

    /** Constant-time check of a server-to-server reporting secret. */
    public static function verifySecret(string $id, string $secret): bool
    {
        $raw = self::raw($id);
        if ($raw === null || ($raw['secret_hash'] ?? '') === '') return false;
        return hash_equals((string) $raw['secret_hash'], hash('sha256', $secret));
    }
}
