<?php
declare(strict_types=1);

/**
 * Operator admin for url_shortener DB — SPEC §20 (session RBAC, not public API).
 */
final class ShortLinksRepository {
    private const MAX_URL_LEN = 2048;

    /** @return array{ok: bool, error?: string} */
    public static function availability(): array {
        if (!UrlShortenerDatabase::enabled()) {
            return ['ok' => false, 'error' => 'disabled'];
        }
        if (!UrlShortenerDatabase::ping()) {
            return ['ok' => false, 'error' => 'db_unreachable'];
        }
        return ['ok' => true];
    }

    /** @return list<array<string,mixed>> */
    public static function listBearers(): array {
        $pdo = UrlShortenerDatabase::pdo();
        $rows = $pdo->query(
            'SELECT b.id, b.label, b.trusted, b.can_temporary, b.can_permanent,
                    b.rate_limit_per_hour, b.revoked_at, b.created_at,
                    COUNT(l.id) AS link_count,
                    SUM(CASE WHEN l.expires_at IS NOT NULL AND l.expires_at < NOW() THEN 1 ELSE 0 END) AS expired_link_count
             FROM bearer_tokens b
             LEFT JOIN links l ON l.bearer_id = b.id
             GROUP BY b.id
             ORDER BY b.id DESC'
        )->fetchAll();
        return array_map(static fn(array $r) => self::formatBearerRow($r), $rows);
    }

    /** @return list<array<string,mixed>> */
    public static function listLinks(int $bearerId, int $limit = 100): array {
        $pdo = UrlShortenerDatabase::pdo();
        $limit = max(1, min(500, $limit));
        $stmt = $pdo->prepare(
            'SELECT id, slug, origin, ttl_seconds, expires_at, created_at, accessed_at, access_count
             FROM links WHERE bearer_id = ? ORDER BY id DESC LIMIT ' . $limit
        );
        $stmt->execute([$bearerId]);
        $base = UrlShortenerDatabase::publicBase();
        $out = [];
        foreach ($stmt->fetchAll() as $row) {
            $slug = (string) $row['slug'];
            $expired = self::rowExpired($row);
            $out[] = [
                'id' => (int) $row['id'],
                'slug' => $slug,
                'short_url' => $base . '/' . $slug,
                'qr_url' => $base . '/api/v1/qr/' . $slug . '.png',
                'origin' => (string) $row['origin'],
                'ttl_seconds' => (int) $row['ttl_seconds'],
                'expires_at' => $row['expires_at'],
                'expired' => $expired,
                'created_at' => $row['created_at'],
                'accessed_at' => $row['accessed_at'],
                'access_count' => (int) $row['access_count'],
            ];
        }
        return $out;
    }

    /** @return list<array<string,mixed>> */
    public static function listAudit(int $limit = 50): array {
        $pdo = UrlShortenerDatabase::pdo();
        $limit = max(1, min(200, $limit));
        $rows = $pdo->query(
            'SELECT id, event, bearer_id, slug, ip, detail, created_at
             FROM audit_log ORDER BY id DESC LIMIT ' . $limit
        )->fetchAll();
        return array_map(static function (array $r): array {
            return [
                'id' => (int) $r['id'],
                'event' => (string) $r['event'],
                'bearer_id' => $r['bearer_id'] !== null ? (int) $r['bearer_id'] : null,
                'slug' => $r['slug'],
                'ip' => (string) $r['ip'],
                'detail' => (string) ($r['detail'] ?? ''),
                'created_at' => $r['created_at'],
            ];
        }, $rows);
    }

    /**
     * @return array{ok: true, bearer_id: int, token: string, signer_key?: string, signer_id?: int}|array{ok: false, error: string}
     */
    public static function mintBearer(
        string $label,
        bool $canTemporary = true,
        bool $canPermanent = false,
        int $rateLimit = 1000,
        bool $mintSigner = false
    ): array {
        $label = trim($label);
        if ($label === '') {
            return ['ok' => false, 'error' => 'missing_label'];
        }
        if (strlen($label) > 128) {
            return ['ok' => false, 'error' => 'label_too_long'];
        }
        $token = bin2hex(random_bytes(24));
        $pdo = UrlShortenerDatabase::pdo();
        $stmt = $pdo->prepare(
            'INSERT INTO bearer_tokens (token_hash, label, trusted, can_temporary, can_permanent, rate_limit_per_hour)
             VALUES (?, ?, 1, ?, ?, ?)'
        );
        $stmt->execute([
            hash('sha256', $token),
            $label,
            $canTemporary ? 1 : 0,
            $canPermanent ? 1 : 0,
            max(1, min(100000, $rateLimit)),
        ]);
        $id = (int) $pdo->lastInsertId();
        self::audit('admin_mint', $id, null, 'label=' . $label);
        $out = ['ok' => true, 'bearer_id' => $id, 'token' => $token];
        if ($canPermanent && $mintSigner) {
            $signer = self::mintSigner($label . '-signer');
            $out['signer_id'] = $signer['id'];
            $out['signer_key'] = $signer['key'];
        }
        return $out;
    }

    /** @return list<array<string,mixed>> */
    public static function listSigners(): array {
        $pdo = UrlShortenerDatabase::pdo();
        $rows = $pdo->query(
            'SELECT id, label, can_permanent, revoked_at, created_at
             FROM signer_keys ORDER BY id DESC'
        )->fetchAll();
        return array_map(static function (array $r): array {
            return [
                'id' => (int) $r['id'],
                'label' => (string) $r['label'],
                'can_permanent' => (int) ($r['can_permanent'] ?? 0) === 1,
                'revoked_at' => $r['revoked_at'],
                'active' => empty($r['revoked_at']),
                'created_at' => $r['created_at'],
            ];
        }, $rows);
    }

    /**
     * @return array{ok: true, id: int, key: string}|array{ok: false, error: string}
     */
    public static function mintSigner(string $label): array {
        $label = trim($label);
        if ($label === '') {
            return ['ok' => false, 'error' => 'missing_label'];
        }
        if (strlen($label) > 128) {
            return ['ok' => false, 'error' => 'label_too_long'];
        }
        $key = bin2hex(random_bytes(24));
        $pdo = UrlShortenerDatabase::pdo();
        $stmt = $pdo->prepare(
            'INSERT INTO signer_keys (key_hash, label, can_permanent) VALUES (?, ?, 1)'
        );
        $stmt->execute([hash('sha256', $key), $label]);
        $id = (int) $pdo->lastInsertId();
        self::audit('admin_mint_signer', null, null, 'signer_id=' . $id . ' label=' . $label);
        return ['ok' => true, 'id' => $id, 'key' => $key];
    }

    public static function revokeSigner(int $signerId): bool {
        $pdo = UrlShortenerDatabase::pdo();
        $stmt = $pdo->prepare(
            'UPDATE signer_keys SET revoked_at = NOW() WHERE id = ? AND revoked_at IS NULL'
        );
        $stmt->execute([$signerId]);
        if ($stmt->rowCount() < 1) {
            return false;
        }
        self::audit('admin_revoke_signer', null, null, 'signer_id=' . $signerId);
        return true;
    }

    public static function revokeBearer(int $bearerId): bool {
        $pdo = UrlShortenerDatabase::pdo();
        $stmt = $pdo->prepare(
            'UPDATE bearer_tokens SET revoked_at = NOW() WHERE id = ? AND revoked_at IS NULL'
        );
        $stmt->execute([$bearerId]);
        if ($stmt->rowCount() < 1) {
            return false;
        }
        self::audit('admin_revoke', $bearerId, null, 'revoked');
        return true;
    }

    /** @return array{links: int, audit: int} */
    public static function purgeExpired(): array {
        $pdo = UrlShortenerDatabase::pdo();
        $links = (int) $pdo->exec(
            'DELETE FROM links WHERE expires_at IS NOT NULL AND expires_at < NOW()'
        );
        $audit = (int) $pdo->exec(
            'DELETE FROM audit_log WHERE created_at < (NOW() - INTERVAL 90 DAY)'
        );
        self::audit('admin_purge', null, null, "links=$links audit=$audit");
        return ['links' => $links, 'audit' => $audit];
    }

    /**
     * @return array{ok: true, short_url: string, slug: string, qr_url: string, ttl: string, url?: string}|array{ok: false, error: string, message?: string}
     */
    public static function createLink(
        int $bearerId,
        string $rawUrl,
        int $ttlSeconds = 86400,
        string $signerKey = ''
    ): array {
        $bearer = self::findBearerById($bearerId);
        if ($bearer === null) {
            return ['ok' => false, 'error' => 'unknown_bearer'];
        }
        if ($bearer['revoked_at'] !== null && $bearer['revoked_at'] !== '') {
            return ['ok' => false, 'error' => 'bearer_revoked'];
        }
        if ((int) ($bearer['trusted'] ?? 0) !== 1) {
            return ['ok' => false, 'error' => 'bearer_untrusted'];
        }

        try {
            $normalized = self::normalizeUrl($rawUrl);
        } catch (InvalidArgumentException $e) {
            return ['ok' => false, 'error' => 'invalid_url', 'message' => $e->getMessage()];
        }
        $originHash = hash('sha256', $normalized);

        $cached = self::findActiveLink($bearerId, $originHash);
        if ($cached !== null) {
            return self::linkSuccess($normalized, $cached);
        }
        if (self::findExpiredLink($bearerId, $originHash) !== null) {
            return ['ok' => false, 'error' => 'ttl_expired'];
        }

        if ($ttlSeconds < 0) {
            $ttlSeconds = 86400;
        }

        $signerId = null;
        if ($ttlSeconds === 0) {
            $signer = self::findValidSigner($signerKey);
            if ($signer === null) {
                return ['ok' => false, 'error' => 'signer_required'];
            }
            if ((int) ($bearer['can_permanent'] ?? 0) !== 1) {
                return ['ok' => false, 'error' => 'bearer_no_permanent'];
            }
            $signerId = (int) $signer['id'];
        } elseif ((int) ($bearer['can_temporary'] ?? 0) !== 1) {
            return ['ok' => false, 'error' => 'bearer_no_temporary'];
        }

        $limit = max(1, (int) ($bearer['rate_limit_per_hour'] ?? 1000));
        if (self::countCreatesLastHour($bearerId) >= $limit) {
            return ['ok' => false, 'error' => 'rate_limited'];
        }

        $slug = self::allocateSlug($bearerId, $normalized);
        $expiresAt = $ttlSeconds <= 0 ? null : date('Y-m-d H:i:s', time() + $ttlSeconds);
        $pdo = UrlShortenerDatabase::pdo();
        $stmt = $pdo->prepare(
            'INSERT INTO links (bearer_id, signer_id, slug, origin, origin_hash, ttl_seconds, expires_at)
             VALUES (?, ?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([
            $bearerId,
            $signerId,
            $slug,
            $normalized,
            $originHash,
            max(0, $ttlSeconds),
            $expiresAt,
        ]);
        self::audit('admin_shorten', $bearerId, $slug, $ttlSeconds === 0 ? 'permanent' : 'created');

        $row = self::findLinkBySlug($slug);
        if ($row === null) {
            return ['ok' => false, 'error' => 'insert_failed'];
        }
        return self::linkSuccess($normalized, $row);
    }

    /** @param array<string,mixed> $row */
    private static function formatBearerRow(array $row): array {
        return [
            'id' => (int) $row['id'],
            'label' => (string) $row['label'],
            'trusted' => (int) ($row['trusted'] ?? 0) === 1,
            'can_temporary' => (int) ($row['can_temporary'] ?? 0) === 1,
            'can_permanent' => (int) ($row['can_permanent'] ?? 0) === 1,
            'rate_limit_per_hour' => (int) ($row['rate_limit_per_hour'] ?? 1000),
            'revoked_at' => $row['revoked_at'],
            'active' => empty($row['revoked_at']),
            'created_at' => $row['created_at'],
            'link_count' => (int) ($row['link_count'] ?? 0),
            'expired_link_count' => (int) ($row['expired_link_count'] ?? 0),
        ];
    }

    /** @return array<string,mixed>|null */
    private static function findValidSigner(string $signerKey): ?array {
        if (trim($signerKey) === '') {
            return null;
        }
        $pdo = UrlShortenerDatabase::pdo();
        $stmt = $pdo->prepare(
            'SELECT * FROM signer_keys WHERE key_hash = ? AND revoked_at IS NULL AND can_permanent = 1 LIMIT 1'
        );
        $stmt->execute([hash('sha256', $signerKey)]);
        $row = $stmt->fetch();
        return $row === false ? null : $row;
    }

    /** @return array<string,mixed>|null */
    private static function findBearerById(int $id): ?array {
        $pdo = UrlShortenerDatabase::pdo();
        $stmt = $pdo->prepare('SELECT * FROM bearer_tokens WHERE id = ? LIMIT 1');
        $stmt->execute([$id]);
        $row = $stmt->fetch();
        return $row === false ? null : $row;
    }

    /** @return array<string,mixed>|null */
    private static function findActiveLink(int $bearerId, string $originHash): ?array {
        $pdo = UrlShortenerDatabase::pdo();
        $stmt = $pdo->prepare(
            'SELECT * FROM links WHERE bearer_id = ? AND origin_hash = ? ORDER BY id DESC LIMIT 1'
        );
        $stmt->execute([$bearerId, $originHash]);
        $row = $stmt->fetch();
        if ($row === false || self::rowExpired($row)) {
            return null;
        }
        return $row;
    }

    /** @return array<string,mixed>|null */
    private static function findExpiredLink(int $bearerId, string $originHash): ?array {
        $pdo = UrlShortenerDatabase::pdo();
        $stmt = $pdo->prepare(
            'SELECT * FROM links WHERE bearer_id = ? AND origin_hash = ? ORDER BY id DESC LIMIT 1'
        );
        $stmt->execute([$bearerId, $originHash]);
        $row = $stmt->fetch();
        if ($row === false || !self::rowExpired($row)) {
            return null;
        }
        return $row;
    }

    /** @return array<string,mixed>|null */
    private static function findLinkBySlug(string $slug): ?array {
        $pdo = UrlShortenerDatabase::pdo();
        $stmt = $pdo->prepare('SELECT * FROM links WHERE slug = ? LIMIT 1');
        $stmt->execute([$slug]);
        $row = $stmt->fetch();
        return $row === false ? null : $row;
    }

    /** @param array<string,mixed> $row */
    private static function rowExpired(array $row): bool {
        $exp = $row['expires_at'] ?? null;
        if ($exp === null || $exp === '') {
            return false;
        }
        return strtotime((string) $exp) <= time();
    }

    private static function countCreatesLastHour(int $bearerId): int {
        $pdo = UrlShortenerDatabase::pdo();
        $stmt = $pdo->prepare(
            'SELECT COUNT(*) AS c FROM links WHERE bearer_id = ? AND created_at >= (NOW() - INTERVAL 1 HOUR)'
        );
        $stmt->execute([$bearerId]);
        $row = $stmt->fetch();
        return (int) ($row['c'] ?? 0);
    }

    private static function allocateSlug(int $bearerId, string $normalized): string {
        $material = $bearerId . "\x1f" . $normalized;
        $candidates = [substr(hash('sha256', $material), 0, 12)];
        for ($i = 1; $i <= 15; $i++) {
            $candidates[] = substr(hash('sha256', $material . "\x1e" . $i), 0, 12);
        }
        $pdo = UrlShortenerDatabase::pdo();
        foreach ($candidates as $slug) {
            $stmt = $pdo->prepare('SELECT 1 FROM links WHERE slug = ? LIMIT 1');
            $stmt->execute([$slug]);
            if ($stmt->fetch() === false) {
                return $slug;
            }
        }
        throw new RuntimeException('slug exhaustion');
    }

    /** @param array<string,mixed> $row */
    private static function linkSuccess(string $normalized, array $row): array {
        $slug = (string) $row['slug'];
        $base = UrlShortenerDatabase::publicBase();
        $exp = $row['expires_at'] ?? null;
        $ttl = '0';
        if ($exp !== null && $exp !== '') {
            $ttl = (string) max(0, strtotime((string) $exp) - time());
        }
        return [
            'ok' => true,
            'url' => $normalized,
            'short_url' => $base . '/' . $slug,
            'slug' => $slug,
            'qr_url' => $base . '/api/v1/qr/' . $slug . '.png',
            'ttl' => $ttl,
        ];
    }

    private static function normalizeUrl(string $raw): string {
        $url = trim($raw);
        if ($url === '') {
            throw new InvalidArgumentException('empty url');
        }
        if (strlen($url) > self::MAX_URL_LEN) {
            throw new InvalidArgumentException('url too long');
        }
        $parts = parse_url($url);
        if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
            throw new InvalidArgumentException('malformed url');
        }
        $scheme = strtolower((string) $parts['scheme']);
        if ($scheme !== 'http' && $scheme !== 'https') {
            throw new InvalidArgumentException('unsupported scheme');
        }
        $host = strtolower((string) $parts['host']);
        self::assertHostAllowed($host);
        $port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
        $path = $parts['path'] ?? '';
        $query = isset($parts['query']) ? '?' . $parts['query'] : '';
        $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
        return $scheme . '://' . $host . $port . $path . $query . $fragment;
    }

    private static function assertHostAllowed(string $host): void {
        if ($host === 'localhost' || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) {
            throw new InvalidArgumentException('blocked host');
        }
        if ($host === 'metadata.google.internal' || $host === 'metadata') {
            throw new InvalidArgumentException('blocked host');
        }
        if (filter_var($host, FILTER_VALIDATE_IP)) {
            self::assertIpAllowed($host);
            return;
        }
        $resolved = @gethostbyname($host);
        if ($resolved !== $host && filter_var($resolved, FILTER_VALIDATE_IP)) {
            self::assertIpAllowed($resolved);
        }
    }

    private static function assertIpAllowed(string $ip): void {
        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
            throw new InvalidArgumentException('blocked host');
        }
    }

    private static function audit(string $event, ?int $bearerId, ?string $slug, string $detail): void {
        $pdo = UrlShortenerDatabase::pdo();
        $ip = $_SERVER['REMOTE_ADDR'] ?? '';
        $stmt = $pdo->prepare(
            'INSERT INTO audit_log (event, bearer_id, slug, ip, detail) VALUES (?, ?, ?, ?, ?)'
        );
        $stmt->execute([$event, $bearerId, $slug, $ip, $detail]);
    }
}
