<?php

namespace classes;

use modules\subusers\helpers\subusers_permission_node_key;
use objects\logs_o;
use Throwable;

require_once __DIR__ . '/security_schema_bootstrap.php';

class security_policy_service
{
    private const FIREWALL_ACTIONS = ['allow', 'watch', 'block'];
    private const FIREWALL_TARGET_TYPES = ['ip', 'cidr', 'customer', 'user', 'route'];
    private const INCIDENT_STATUSES = ['open', 'acknowledged', 'resolved', 'false_positive'];
    private const INCIDENT_SEVERITIES = ['low', 'medium', 'high', 'critical'];

    private const DEFAULT_RULES = [
        'failed_login_attempts' => [
            'label' => 'Failed login attempts',
            'description' => 'Observe repeated failed sign-in attempts for the same principal.',
            'threshold_count' => 5,
            'window_seconds' => 900,
            'subject_type' => 'login_principal',
        ],
        'bookings_created' => [
            'label' => 'Booking creations',
            'description' => 'Observe high booking creation volume for a customer.',
            'threshold_count' => 25,
            'window_seconds' => 86400,
            'subject_type' => 'customer',
        ],
        'vehicles_created' => [
            'label' => 'Vehicle creations',
            'description' => 'Observe high vehicle creation volume for a customer.',
            'threshold_count' => 20,
            'window_seconds' => 86400,
            'subject_type' => 'customer',
        ],
        'requests_per_ip' => [
            'label' => 'Requests per IP',
            'description' => 'Observe high API request volume from one source IP.',
            'threshold_count' => 300,
            'window_seconds' => 60,
            'subject_type' => 'ip',
        ],
        'requests_per_customer' => [
            'label' => 'Requests per customer',
            'description' => 'Observe high API request volume for one customer context.',
            'threshold_count' => 600,
            'window_seconds' => 60,
            'subject_type' => 'customer',
        ],
    ];

    public function summary(): array
    {
        $this->ensureReady();

        return [
            'settings' => $this->settings(),
            'firewall' => [
                'active_rules' => $this->countRows('security_firewall_rules', "enabled = 1 AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())"),
                'block_rules' => $this->countRows('security_firewall_rules', "action = 'block' AND enabled = 1 AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())"),
                'watch_rules' => $this->countRows('security_firewall_rules', "action = 'watch' AND enabled = 1 AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())"),
            ],
            'incidents' => [
                'open' => $this->countRows('security_incidents', "status = 'open'"),
                'acknowledged' => $this->countRows('security_incidents', "status = 'acknowledged'"),
                'resolved' => $this->countRows('security_incidents', "status = 'resolved'"),
                'recent' => $this->listIncidents(['limit' => 10])['incidents'],
            ],
        ];
    }

    public function settings(): array
    {
        $this->ensureReady();

        global $db;
        $rows = $db->fetch_all($db->query('SELECT * FROM security_policy_rules ORDER BY rule_key ASC'));
        $byKey = [];
        foreach ($rows as $row) {
            $byKey[(string)$row['rule_key']] = $this->publicPolicyRule($row);
        }

        $rules = [];
        foreach (self::DEFAULT_RULES as $key => $descriptor) {
            $rules[] = [
                ...$descriptor,
                ...($byKey[$key] ?? []),
                'rule_key' => $key,
                'mode' => 'observe',
            ];
        }

        return [
            'mode' => 'observe',
            'rules' => $rules,
            'available_rule_keys' => array_keys(self::DEFAULT_RULES),
            'exemption_permission' => 'superuser_security_limits_exempt',
        ];
    }

    public function updateSettings(array $payload, ?int $actorUserId = null): array
    {
        $this->ensureReady();

        $rules = $payload['rules'] ?? null;
        if (!is_array($rules)) {
            throw new \InvalidArgumentException('Missing settings rules.');
        }

        foreach ($rules as $rule) {
            if (!is_array($rule)) {
                throw new \InvalidArgumentException('Invalid policy rule payload.');
            }
            $this->upsertPolicyRule($rule, $actorUserId);
        }

        $this->recordSecurityChange('SECURITY_SETTINGS_UPDATED', 'Updated security observe settings.', $actorUserId);

        return $this->settings();
    }

    public function listFirewallRules(array $filters = []): array
    {
        $this->ensureReady();

        global $db;
        $where = ['deleted_at IS NULL'];
        if (isset($filters['enabled']) && $filters['enabled'] !== '') {
            $where[] = 'enabled = ' . ($this->toBool($filters['enabled'], false) ? '1' : '0');
        }
        if (!empty($filters['action'])) {
            $where[] = "action = '" . $db->escape_string((string)$filters['action']) . "'";
        }
        if (!empty($filters['target_type'])) {
            $where[] = "target_type = '" . $db->escape_string((string)$filters['target_type']) . "'";
        }
        if (!empty($filters['search'])) {
            $search = $db->escape_string((string)$filters['search']);
            $where[] = "(target_value LIKE '%{$search}%' OR route_pattern LIKE '%{$search}%' OR reason LIKE '%{$search}%')";
        }

        $limit = $this->clampLimit($filters['limit'] ?? 100, 1, 500);
        $result = $db->query(
            'SELECT * FROM security_firewall_rules WHERE ' . implode(' AND ', $where)
            . ' ORDER BY priority ASC, id DESC LIMIT ' . $limit
        );

        return [
            'rules' => array_map(fn(array $row): array => $this->publicFirewallRule($row), $db->fetch_all($result)),
        ];
    }

    public function createFirewallRule(array $payload, ?int $actorUserId = null): array
    {
        $this->ensureReady();

        global $db;
        $rule = $this->normalizeFirewallPayload($payload);
        $metadata = $this->jsonEncode($rule['metadata'] ?? []);
        $expiresAt = $this->nullableDateSql($rule['expires_at']);
        $routePattern = $this->nullableStringSql($rule['route_pattern']);
        $reason = $this->nullableStringSql($rule['reason']);
        $actor = $actorUserId === null ? 'NULL' : (string)$actorUserId;

        $db->query(
            "INSERT INTO security_firewall_rules
                (action, target_type, target_value, route_pattern, priority, reason, enabled, expires_at, metadata_json, created_by)
             VALUES (
                '" . $db->escape_string($rule['action']) . "',
                '" . $db->escape_string($rule['target_type']) . "',
                '" . $db->escape_string($rule['target_value']) . "',
                {$routePattern},
                " . (int)$rule['priority'] . ",
                {$reason},
                " . ($rule['enabled'] ? 1 : 0) . ",
                {$expiresAt},
                '" . $db->escape_string($metadata) . "',
                {$actor}
             )"
        );

        $created = $this->getFirewallRule((int)$db->insert_id());
        $this->recordSecurityChange('SECURITY_FIREWALL_RULE_CREATED', 'Created firewall rule #' . $created['id'], $actorUserId);
        return $created;
    }

    public function updateFirewallRule(int $id, array $payload, ?int $actorUserId = null): array
    {
        $this->ensureReady();
        $existing = $this->getFirewallRule($id);

        global $db;
        $rule = $this->normalizeFirewallPayload([...$existing, ...$payload], true);
        $metadata = $this->jsonEncode($rule['metadata'] ?? []);

        $db->query(
            "UPDATE security_firewall_rules
             SET action = '" . $db->escape_string($rule['action']) . "',
                 target_type = '" . $db->escape_string($rule['target_type']) . "',
                 target_value = '" . $db->escape_string($rule['target_value']) . "',
                 route_pattern = " . $this->nullableStringSql($rule['route_pattern']) . ",
                 priority = " . (int)$rule['priority'] . ",
                 reason = " . $this->nullableStringSql($rule['reason']) . ",
                 enabled = " . ($rule['enabled'] ? 1 : 0) . ",
                 expires_at = " . $this->nullableDateSql($rule['expires_at']) . ",
                 metadata_json = '" . $db->escape_string($metadata) . "'
             WHERE id = " . (int)$id . " AND deleted_at IS NULL"
        );

        $updated = $this->getFirewallRule($id);
        $this->recordSecurityChange('SECURITY_FIREWALL_RULE_UPDATED', 'Updated firewall rule #' . $id, $actorUserId);
        return $updated;
    }

    public function deleteFirewallRule(int $id, ?int $actorUserId = null): array
    {
        $this->ensureReady();
        $this->getFirewallRule($id);

        global $db;
        $db->query('UPDATE security_firewall_rules SET deleted_at = NOW(), enabled = 0 WHERE id = ' . (int)$id);
        $this->recordSecurityChange('SECURITY_FIREWALL_RULE_DELETED', 'Deleted firewall rule #' . $id, $actorUserId);
        return ['deleted' => true, 'id' => $id];
    }

    public function getFirewallRule(int $id): array
    {
        $this->ensureReady();

        global $db;
        $result = $db->query('SELECT * FROM security_firewall_rules WHERE id = ' . (int)$id . ' AND deleted_at IS NULL LIMIT 1');
        $row = $result ? $result->fetch_assoc() : null;
        if (!is_array($row)) {
            throw new \RuntimeException('Firewall rule not found.');
        }
        return $this->publicFirewallRule($row);
    }

    public function listIncidents(array $filters = []): array
    {
        $this->ensureReady();

        global $db;
        $where = ['1 = 1'];
        foreach (['status', 'type', 'severity'] as $field) {
            if (!empty($filters[$field])) {
                $where[] = $field . " = '" . $db->escape_string((string)$filters[$field]) . "'";
            }
        }
        if (!empty($filters['search'])) {
            $search = $db->escape_string((string)$filters['search']);
            $where[] = "(title LIKE '%{$search}%' OR source_ip LIKE '%{$search}%' OR route_path LIKE '%{$search}%')";
        }

        $limit = $this->clampLimit($filters['limit'] ?? 50, 1, 200);
        $result = $db->query(
            'SELECT * FROM security_incidents WHERE ' . implode(' AND ', $where)
            . ' ORDER BY FIELD(status, "open", "acknowledged", "resolved", "false_positive"), last_seen_at DESC LIMIT ' . $limit
        );

        return [
            'incidents' => array_map(fn(array $row): array => $this->publicIncident($row), $db->fetch_all($result)),
        ];
    }

    public function incidentDetail(int $id): array
    {
        $this->ensureReady();
        $incident = $this->getIncident($id);
        $incident['notes'] = $this->incidentNotes($id);
        return $incident;
    }

    public function updateIncident(int $id, array $payload, ?int $actorUserId = null): array
    {
        $this->ensureReady();
        $this->getIncident($id);

        $status = trim((string)($payload['status'] ?? ''));
        if (!in_array($status, self::INCIDENT_STATUSES, true)) {
            throw new \InvalidArgumentException('Invalid incident status.');
        }

        global $db;
        $resolvedBy = in_array($status, ['resolved', 'false_positive'], true) && $actorUserId !== null ? (string)$actorUserId : 'NULL';
        $resolvedAt = in_array($status, ['resolved', 'false_positive'], true) ? 'NOW()' : 'NULL';
        $db->query(
            "UPDATE security_incidents
             SET status = '" . $db->escape_string($status) . "',
                 resolved_by = {$resolvedBy},
                 resolved_at = {$resolvedAt}
             WHERE id = " . (int)$id
        );

        $this->recordSecurityChange('SECURITY_INCIDENT_UPDATED', 'Updated security incident #' . $id . ' to ' . $status, $actorUserId);
        return $this->incidentDetail($id);
    }

    public function addIncidentNote(int $id, string $note, ?int $actorUserId = null): array
    {
        $this->ensureReady();
        $this->getIncident($id);

        $note = trim($note);
        if ($note === '') {
            throw new \InvalidArgumentException('Incident note cannot be empty.');
        }
        if (strlen($note) > 4000) {
            throw new \InvalidArgumentException('Incident note is too long.');
        }

        global $db;
        $actor = $actorUserId === null ? 'NULL' : (string)$actorUserId;
        $db->query(
            "INSERT INTO security_incident_notes (incident_id, note, created_by)
             VALUES (" . (int)$id . ", '" . $db->escape_string($note) . "', {$actor})"
        );

        $this->recordSecurityChange('SECURITY_INCIDENT_NOTE_CREATED', 'Added note to security incident #' . $id, $actorUserId);
        return $this->incidentDetail($id);
    }

    public function inspectRequest(string $routeTemplate, string $method): void
    {
        $this->ensureReady();
        $context = $this->requestContext($routeTemplate, $method);
        $this->inspectFirewall($context);
        $this->observePolicyEvent('requests_per_ip', 'ip', (string)$context['source_ip'], $context);

        if (!empty($context['customer_number'])) {
            $this->observePolicyEvent(
                'requests_per_customer',
                'customer',
                (string)$context['customer_number'],
                $context
            );
        }
    }

    public function observeLoginFailure(string $principalType, string|int $identifier, array $metadata = []): void
    {
        $this->safeObservePolicyEvent(
            'failed_login_attempts',
            'login_principal',
            $principalType . ':' . (string)$identifier,
            $this->requestContext('/auth/login', 'POST', $metadata)
        );
    }

    public function observeBookingCreated(int $customerNumber, array $metadata = []): void
    {
        $this->safeObservePolicyEvent(
            'bookings_created',
            'customer',
            (string)$customerNumber,
            $this->requestContext('/order-bookings', 'POST', ['customer_number' => $customerNumber, ...$metadata])
        );
    }

    public function observeVehicleCreated(int $customerNumber, array $metadata = []): void
    {
        $this->safeObservePolicyEvent(
            'vehicles_created',
            'customer',
            (string)$customerNumber,
            $this->requestContext('/vehicles', 'POST', ['customer_number' => $customerNumber, ...$metadata])
        );
    }

    public function recordSecurityChange(string $action, string $message, ?int $actorUserId = null): void
    {
        try {
            (new logs_o())->add('security', 'global', 1, $actorUserId ?? 0, $action, $message);
            $this->upsertIncident([
                'incident_key' => 'security_change:' . sha1($action . ':' . $message . ':' . date('Y-m-d H:i')),
                'type' => 'security_change',
                'severity' => 'low',
                'title' => $message,
                'user_id' => $actorUserId,
                'metadata' => ['action' => $action],
            ]);
        } catch (Throwable) {
            // Audit logging must not block the requested mutation.
        }
    }

    private function ensureReady(): void
    {
        security_schema_bootstrap::ensureTables();
        $this->seedDefaultPolicyRules();
    }

    private function seedDefaultPolicyRules(): void
    {
        static $seeded = false;
        if ($seeded) {
            return;
        }

        global $db;
        foreach (self::DEFAULT_RULES as $key => $rule) {
            $db->query(
                "INSERT IGNORE INTO security_policy_rules
                    (rule_key, enabled, threshold_count, window_seconds, mode, exempt_permission_nodes_json)
                 VALUES (
                    '" . $db->escape_string($key) . "',
                    1,
                    " . (int)$rule['threshold_count'] . ",
                    " . (int)$rule['window_seconds'] . ",
                    'observe',
                    '[]'
                 )"
            );
        }

        $seeded = true;
    }

    private function upsertPolicyRule(array $input, ?int $actorUserId): void
    {
        global $db;

        $key = trim((string)($input['rule_key'] ?? ''));
        if (!isset(self::DEFAULT_RULES[$key])) {
            throw new \InvalidArgumentException('Unknown security policy rule: ' . $key);
        }

        $enabled = $this->toBool($input['enabled'] ?? true, true);
        $threshold = (int)($input['threshold_count'] ?? $input['threshold'] ?? 0);
        $window = (int)($input['window_seconds'] ?? 0);
        if ($threshold < 1 || $threshold > 1000000) {
            throw new \InvalidArgumentException('Invalid threshold for ' . $key . '.');
        }
        if ($window < 30 || $window > 2678400) {
            throw new \InvalidArgumentException('Invalid window for ' . $key . '.');
        }

        $exemptions = $this->normalizePermissionList($input['exempt_permission_nodes'] ?? []);
        $actor = $actorUserId === null ? 'NULL' : (string)$actorUserId;
        $json = $db->escape_string($this->jsonEncode($exemptions));
        $keySql = $db->escape_string($key);

        $db->query(
            "INSERT INTO security_policy_rules
                (rule_key, enabled, threshold_count, window_seconds, mode, exempt_permission_nodes_json, updated_by)
             VALUES ('{$keySql}', " . ($enabled ? 1 : 0) . ", {$threshold}, {$window}, 'observe', '{$json}', {$actor})
             ON DUPLICATE KEY UPDATE
                enabled = VALUES(enabled),
                threshold_count = VALUES(threshold_count),
                window_seconds = VALUES(window_seconds),
                mode = 'observe',
                exempt_permission_nodes_json = VALUES(exempt_permission_nodes_json),
                updated_by = VALUES(updated_by)"
        );
    }

    private function inspectFirewall(array $context): void
    {
        global $response;

        $matches = [];
        foreach ($this->activeFirewallRules() as $rule) {
            if ($this->firewallRuleMatches($rule, $context)) {
                $matches[] = $rule;
            }
        }

        if ($matches === []) {
            return;
        }

        foreach ($matches as $rule) {
            if ($rule['action'] === 'allow') {
                return;
            }
        }

        foreach ($matches as $rule) {
            if ($rule['action'] === 'watch') {
                $this->createFirewallIncident($rule, $context);
            }
        }

        foreach ($matches as $rule) {
            if ($rule['action'] === 'block') {
                $this->createFirewallIncident($rule, $context);
                $response->error([
                    'message' => 'Request blocked by security firewall.',
                    'firewall_rule_id' => (int)$rule['id'],
                ], 403);
            }
        }
    }

    private function activeFirewallRules(): array
    {
        global $db;
        $result = $db->query(
            "SELECT * FROM security_firewall_rules
             WHERE enabled = 1
               AND deleted_at IS NULL
               AND (expires_at IS NULL OR expires_at > NOW())
             ORDER BY priority ASC, id ASC"
        );
        return $db->fetch_all($result);
    }

    private function firewallRuleMatches(array $rule, array $context): bool
    {
        $routePattern = trim((string)($rule['route_pattern'] ?? ''));
        if ($routePattern !== '' && !$this->routeMatches($routePattern, (string)$context['route_path'], (string)$context['route_template'])) {
            return false;
        }

        $target = trim((string)$rule['target_value']);
        return match ((string)$rule['target_type']) {
            'ip' => $target === (string)$context['source_ip'],
            'cidr' => $this->ipInCidr((string)$context['source_ip'], $target),
            'customer' => $target !== '' && $target === (string)($context['customer_number'] ?? ''),
            'user' => $target !== '' && $target === (string)($context['user_id'] ?? ''),
            'route' => $this->routeMatches($target, (string)$context['route_path'], (string)$context['route_template']),
            default => false,
        };
    }

    private function observePolicyEvent(string $ruleKey, string $subjectType, string $subjectKey, array $context): void
    {
        $rule = $this->policyRule($ruleKey);
        if (!$rule || !(bool)$rule['enabled']) {
            return;
        }
        if ($this->isExempt($rule, $context)) {
            return;
        }

        global $db;
        $metadata = $this->jsonEncode($context['metadata'] ?? []);
        $db->query(
            "INSERT INTO security_policy_events
                (rule_key, subject_type, subject_key, route_path, route_template, method, source_ip, customer_number, user_id, subuser_id, metadata_json)
             VALUES (
                '" . $db->escape_string($ruleKey) . "',
                '" . $db->escape_string($subjectType) . "',
                '" . $db->escape_string($subjectKey) . "',
                " . $this->nullableStringSql($context['route_path'] ?? null) . ",
                " . $this->nullableStringSql($context['route_template'] ?? null) . ",
                " . $this->nullableStringSql($context['method'] ?? null) . ",
                " . $this->nullableStringSql($context['source_ip'] ?? null) . ",
                " . $this->nullableIntSql($context['customer_number'] ?? null) . ",
                " . $this->nullableIntSql($context['user_id'] ?? null) . ",
                " . $this->nullableIntSql($context['subuser_id'] ?? null) . ",
                '" . $db->escape_string($metadata) . "'
             )"
        );

        $count = $this->countPolicyEvents($ruleKey, $subjectType, $subjectKey, (int)$rule['window_seconds']);
        if ($count >= (int)$rule['threshold_count']) {
            $this->upsertIncident([
                'incident_key' => 'policy:' . $ruleKey . ':' . $subjectType . ':' . sha1($subjectKey),
                'type' => 'policy_threshold',
                'severity' => $this->severityForPolicyCount($count, (int)$rule['threshold_count']),
                'title' => $this->policyIncidentTitle($ruleKey, $subjectKey, $count, (int)$rule['window_seconds']),
                'source_ip' => $context['source_ip'] ?? null,
                'customer_number' => $context['customer_number'] ?? null,
                'user_id' => $context['user_id'] ?? null,
                'subuser_id' => $context['subuser_id'] ?? null,
                'route_path' => $context['route_path'] ?? null,
                'route_template' => $context['route_template'] ?? null,
                'method' => $context['method'] ?? null,
                'related_rule_id' => (int)$rule['id'],
                'metadata' => [
                    'rule_key' => $ruleKey,
                    'subject_type' => $subjectType,
                    'subject_key' => $subjectKey,
                    'count' => $count,
                    'threshold_count' => (int)$rule['threshold_count'],
                    'window_seconds' => (int)$rule['window_seconds'],
                    'mode' => 'observe',
                ],
            ]);
        }
    }

    private function safeObservePolicyEvent(string $ruleKey, string $subjectType, string $subjectKey, array $context): void
    {
        try {
            $this->ensureReady();
            $this->observePolicyEvent($ruleKey, $subjectType, $subjectKey, $context);
        } catch (Throwable $throwable) {
            error_log('[security-policy] observe failed: ' . $throwable->getMessage());
        }
    }

    private function policyRule(string $ruleKey): ?array
    {
        global $db;
        $result = $db->query(
            "SELECT * FROM security_policy_rules WHERE rule_key = '" . $db->escape_string($ruleKey) . "' LIMIT 1"
        );
        $row = $result ? $result->fetch_assoc() : null;
        return is_array($row) ? $row : null;
    }

    private function countPolicyEvents(string $ruleKey, string $subjectType, string $subjectKey, int $windowSeconds): int
    {
        global $db;
        $cutoff = date('Y-m-d H:i:s', time() - max(30, $windowSeconds));
        $result = $db->query(
            "SELECT COUNT(*) AS count
             FROM security_policy_events
             WHERE rule_key = '" . $db->escape_string($ruleKey) . "'
               AND subject_type = '" . $db->escape_string($subjectType) . "'
               AND subject_key = '" . $db->escape_string($subjectKey) . "'
               AND created_at >= '" . $db->escape_string($cutoff) . "'"
        );
        $row = $result ? $result->fetch_assoc() : ['count' => 0];
        return (int)($row['count'] ?? 0);
    }

    private function isExempt(array $rule, array $context): bool
    {
        $exemptions = $this->decodeJsonArray($rule['exempt_permission_nodes_json'] ?? '[]');
        if ($exemptions === []) {
            return false;
        }

        try {
            $auth = new authentication();
            $subuser = $auth->get_subuser();
            if ($subuser !== false) {
                $customerNumber = isset($context['customer_number']) ? (int)$context['customer_number'] : null;
                foreach ($exemptions as $permission) {
                    $node = subusers_permission_node_key::tryFrom((string)$permission);
                    if ($node !== null && $customerNumber !== null && $subuser->hasPermission($node, $customerNumber)) {
                        return true;
                    }
                }
            }

            $user = $auth->get_user();
            if ($user !== false) {
                foreach ($exemptions as $permission) {
                    if ($user->hasPermission((string)$permission)) {
                        return true;
                    }
                }
            }
        } catch (Throwable) {
            return false;
        }

        return false;
    }

    private function createFirewallIncident(array $rule, array $context): void
    {
        $this->upsertIncident([
            'incident_key' => 'firewall:' . (int)$rule['id'] . ':' . sha1((string)$context['source_ip'] . ':' . (string)($context['customer_number'] ?? '') . ':' . (string)$context['route_path']),
            'type' => 'firewall_' . (string)$rule['action'],
            'severity' => $rule['action'] === 'block' ? 'high' : 'medium',
            'title' => ucfirst((string)$rule['action']) . ' firewall rule matched: ' . (string)$rule['target_type'] . ' ' . (string)$rule['target_value'],
            'source_ip' => $context['source_ip'] ?? null,
            'customer_number' => $context['customer_number'] ?? null,
            'user_id' => $context['user_id'] ?? null,
            'subuser_id' => $context['subuser_id'] ?? null,
            'route_path' => $context['route_path'] ?? null,
            'route_template' => $context['route_template'] ?? null,
            'method' => $context['method'] ?? null,
            'related_firewall_rule_id' => (int)$rule['id'],
            'metadata' => [
                'action' => $rule['action'],
                'target_type' => $rule['target_type'],
                'target_value' => $rule['target_value'],
                'reason' => $rule['reason'] ?? null,
            ],
        ]);
    }

    private function upsertIncident(array $incident): void
    {
        global $db;

        $metadata = $this->jsonEncode($incident['metadata'] ?? []);
        $key = $db->escape_string((string)$incident['incident_key']);
        $type = $db->escape_string((string)$incident['type']);
        $severity = in_array(($incident['severity'] ?? 'medium'), self::INCIDENT_SEVERITIES, true)
            ? (string)$incident['severity']
            : 'medium';
        $title = $db->escape_string(substr((string)$incident['title'], 0, 255));

        $db->query(
            "INSERT INTO security_incidents
                (incident_key, type, severity, status, title, source_ip, customer_number, user_id, subuser_id,
                 route_path, route_template, method, related_rule_id, related_firewall_rule_id, metadata_json)
             VALUES (
                '{$key}',
                '{$type}',
                '" . $db->escape_string($severity) . "',
                'open',
                '{$title}',
                " . $this->nullableStringSql($incident['source_ip'] ?? null) . ",
                " . $this->nullableIntSql($incident['customer_number'] ?? null) . ",
                " . $this->nullableIntSql($incident['user_id'] ?? null) . ",
                " . $this->nullableIntSql($incident['subuser_id'] ?? null) . ",
                " . $this->nullableStringSql($incident['route_path'] ?? null) . ",
                " . $this->nullableStringSql($incident['route_template'] ?? null) . ",
                " . $this->nullableStringSql($incident['method'] ?? null) . ",
                " . $this->nullableIntSql($incident['related_rule_id'] ?? null) . ",
                " . $this->nullableIntSql($incident['related_firewall_rule_id'] ?? null) . ",
                '" . $db->escape_string($metadata) . "'
             )
             ON DUPLICATE KEY UPDATE
                severity = VALUES(severity),
                title = VALUES(title),
                status = IF(status IN ('resolved', 'false_positive'), status, status),
                occurrence_count = occurrence_count + 1,
                last_seen_at = NOW(),
                metadata_json = VALUES(metadata_json)"
        );
    }

    private function requestContext(string $routeTemplate, string $method, array $metadata = []): array
    {
        $path = explode('?', $_SERVER['REQUEST_URI'] ?? $routeTemplate)[0];
        $context = [
            'route_template' => $routeTemplate,
            'route_path' => $path,
            'method' => strtoupper($method),
            'source_ip' => $this->sourceIp(),
            'customer_number' => $metadata['customer_number'] ?? null,
            'user_id' => null,
            'subuser_id' => null,
            'metadata' => $metadata,
        ];

        try {
            $auth = new authentication();
            $subuser = $auth->get_subuser();
            if ($subuser !== false) {
                $context['subuser_id'] = (int)$subuser->id;
                $target = $auth->get_subuser_customer_number_target();
                if ($target !== false && $target !== null) {
                    $context['customer_number'] = (int)$target;
                }
            }

            $user = $auth->get_user();
            if ($user !== false) {
                $context['user_id'] = (int)$user->id;
                if ($context['customer_number'] === null && isset($user->customer_number)) {
                    $context['customer_number'] = (int)$user->customer_number->value();
                }
            }
        } catch (Throwable) {
            // Missing or invalid auth should not stop observation.
        }

        return $context;
    }

    private function sourceIp(): string
    {
        $forwarded = trim((string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
        if ($forwarded !== '') {
            $parts = array_map('trim', explode(',', $forwarded));
            if (($parts[0] ?? '') !== '') {
                return substr($parts[0], 0, 64);
            }
        }

        return substr((string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'), 0, 64);
    }

    private function routeMatches(string $pattern, string $routePath, string $routeTemplate): bool
    {
        $pattern = trim($pattern);
        if ($pattern === '') {
            return false;
        }
        if ($pattern === $routePath || $pattern === $routeTemplate) {
            return true;
        }
        if (str_ends_with($pattern, '*')) {
            $prefix = rtrim(substr($pattern, 0, -1), '*');
            return str_starts_with($routePath, $prefix) || str_starts_with($routeTemplate, $prefix);
        }
        return false;
    }

    private function ipInCidr(string $ip, string $cidr): bool
    {
        if (!str_contains($cidr, '/')) {
            return false;
        }
        [$network, $bits] = explode('/', $cidr, 2);
        $ipLong = ip2long($ip);
        $networkLong = ip2long($network);
        $bitsInt = (int)$bits;
        if ($ipLong === false || $networkLong === false || $bitsInt < 0 || $bitsInt > 32) {
            return false;
        }
        $mask = -1 << (32 - $bitsInt);
        return ($ipLong & $mask) === ($networkLong & $mask);
    }

    private function normalizeFirewallPayload(array $payload, bool $partial = false): array
    {
        $action = trim((string)($payload['action'] ?? ''));
        $targetType = trim((string)($payload['target_type'] ?? ''));
        $targetValue = trim((string)($payload['target_value'] ?? ''));
        if (!in_array($action, self::FIREWALL_ACTIONS, true)) {
            throw new \InvalidArgumentException('Invalid firewall action.');
        }
        if (!in_array($targetType, self::FIREWALL_TARGET_TYPES, true)) {
            throw new \InvalidArgumentException('Invalid firewall target type.');
        }
        if ($targetValue === '') {
            throw new \InvalidArgumentException('Firewall target value is required.');
        }
        if ($targetType === 'ip' && filter_var($targetValue, FILTER_VALIDATE_IP) === false) {
            throw new \InvalidArgumentException('Invalid firewall IP target.');
        }
        if ($targetType === 'cidr' && !$this->isValidCidr($targetValue)) {
            throw new \InvalidArgumentException('Invalid firewall CIDR target.');
        }

        return [
            'action' => $action,
            'target_type' => $targetType,
            'target_value' => $targetValue,
            'route_pattern' => trim((string)($payload['route_pattern'] ?? '')) ?: null,
            'priority' => max(1, min(10000, (int)($payload['priority'] ?? 100))),
            'reason' => trim((string)($payload['reason'] ?? '')) ?: null,
            'enabled' => $this->toBool($payload['enabled'] ?? true, true),
            'expires_at' => $this->normalizeDate($payload['expires_at'] ?? null),
            'metadata' => is_array($payload['metadata'] ?? null) ? $payload['metadata'] : [],
        ];
    }

    private function isValidCidr(string $cidr): bool
    {
        if (!str_contains($cidr, '/')) {
            return false;
        }
        [$ip, $bits] = explode('/', $cidr, 2);
        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false
            && ctype_digit($bits)
            && (int)$bits >= 0
            && (int)$bits <= 32;
    }

    private function getIncident(int $id): array
    {
        global $db;
        $result = $db->query('SELECT * FROM security_incidents WHERE id = ' . (int)$id . ' LIMIT 1');
        $row = $result ? $result->fetch_assoc() : null;
        if (!is_array($row)) {
            throw new \RuntimeException('Security incident not found.');
        }
        return $this->publicIncident($row);
    }

    private function incidentNotes(int $incidentId): array
    {
        global $db;
        $result = $db->query(
            'SELECT * FROM security_incident_notes WHERE incident_id = ' . (int)$incidentId . ' ORDER BY created_at ASC, id ASC'
        );
        return array_map(static fn(array $row): array => [
            'id' => (int)$row['id'],
            'incident_id' => (int)$row['incident_id'],
            'note' => (string)$row['note'],
            'created_by' => isset($row['created_by']) ? (int)$row['created_by'] : null,
            'created_at' => $row['created_at'] ?? null,
        ], $db->fetch_all($result));
    }

    private function publicPolicyRule(array $row): array
    {
        return [
            'id' => (int)$row['id'],
            'rule_key' => (string)$row['rule_key'],
            'enabled' => (bool)$row['enabled'],
            'threshold_count' => (int)$row['threshold_count'],
            'window_seconds' => (int)$row['window_seconds'],
            'mode' => 'observe',
            'exempt_permission_nodes' => $this->decodeJsonArray($row['exempt_permission_nodes_json'] ?? '[]'),
            'updated_by' => isset($row['updated_by']) ? (int)$row['updated_by'] : null,
            'updated_at' => $row['updated_at'] ?? null,
        ];
    }

    private function publicFirewallRule(array $row): array
    {
        return [
            'id' => (int)$row['id'],
            'action' => (string)$row['action'],
            'target_type' => (string)$row['target_type'],
            'target_value' => (string)$row['target_value'],
            'route_pattern' => $row['route_pattern'] ?? null,
            'priority' => (int)$row['priority'],
            'reason' => $row['reason'] ?? null,
            'enabled' => (bool)$row['enabled'],
            'expires_at' => $row['expires_at'] ?? null,
            'metadata' => $this->decodeJsonArray($row['metadata_json'] ?? '{}'),
            'created_by' => isset($row['created_by']) ? (int)$row['created_by'] : null,
            'created_at' => $row['created_at'] ?? null,
            'updated_at' => $row['updated_at'] ?? null,
        ];
    }

    private function publicIncident(array $row): array
    {
        return [
            'id' => (int)$row['id'],
            'incident_key' => (string)$row['incident_key'],
            'type' => (string)$row['type'],
            'severity' => (string)$row['severity'],
            'status' => (string)$row['status'],
            'title' => (string)$row['title'],
            'source_ip' => $row['source_ip'] ?? null,
            'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
            'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null,
            'subuser_id' => isset($row['subuser_id']) ? (int)$row['subuser_id'] : null,
            'route_path' => $row['route_path'] ?? null,
            'route_template' => $row['route_template'] ?? null,
            'method' => $row['method'] ?? null,
            'related_rule_id' => isset($row['related_rule_id']) ? (int)$row['related_rule_id'] : null,
            'related_firewall_rule_id' => isset($row['related_firewall_rule_id']) ? (int)$row['related_firewall_rule_id'] : null,
            'occurrence_count' => (int)$row['occurrence_count'],
            'metadata' => $this->decodeJsonArray($row['metadata_json'] ?? '{}'),
            'first_seen_at' => $row['first_seen_at'] ?? null,
            'last_seen_at' => $row['last_seen_at'] ?? null,
            'resolved_by' => isset($row['resolved_by']) ? (int)$row['resolved_by'] : null,
            'resolved_at' => $row['resolved_at'] ?? null,
            'created_at' => $row['created_at'] ?? null,
            'updated_at' => $row['updated_at'] ?? null,
        ];
    }

    private function countRows(string $table, string $where): int
    {
        global $db;
        $result = $db->query("SELECT COUNT(*) AS count FROM {$table} WHERE {$where}");
        $row = $result ? $result->fetch_assoc() : ['count' => 0];
        return (int)($row['count'] ?? 0);
    }

    private function normalizePermissionList(mixed $value): array
    {
        if (!is_array($value)) {
            return [];
        }
        $permissions = [];
        foreach ($value as $permission) {
            $permission = trim((string)$permission);
            if ($permission !== '' && preg_match('/^[A-Za-z0-9_:-]+$/', $permission)) {
                $permissions[] = $permission;
            }
        }
        sort($permissions);
        return array_values(array_unique($permissions));
    }

    private function clampLimit(mixed $value, int $min, int $max): int
    {
        return max($min, min($max, (int)$value));
    }

    private function toBool(mixed $value, bool $default): bool
    {
        if (is_bool($value)) {
            return $value;
        }
        if ($value === null) {
            return $default;
        }
        $normalized = strtolower(trim((string)$value));
        if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
            return true;
        }
        if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
            return false;
        }
        return $default;
    }

    private function normalizeDate(mixed $value): ?string
    {
        $value = trim((string)($value ?? ''));
        if ($value === '') {
            return null;
        }
        $timestamp = strtotime($value);
        if ($timestamp === false) {
            throw new \InvalidArgumentException('Invalid date value.');
        }
        return date('Y-m-d H:i:s', $timestamp);
    }

    private function nullableStringSql(mixed $value): string
    {
        global $db;
        if ($value === null || $value === '') {
            return 'NULL';
        }
        return "'" . $db->escape_string(substr((string)$value, 0, 1024)) . "'";
    }

    private function nullableDateSql(mixed $value): string
    {
        return $this->nullableStringSql($value);
    }

    private function nullableIntSql(mixed $value): string
    {
        if ($value === null || $value === '') {
            return 'NULL';
        }
        return (string)(int)$value;
    }

    private function jsonEncode(mixed $value): string
    {
        $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        return is_string($json) ? $json : '{}';
    }

    private function decodeJsonArray(mixed $value): array
    {
        $decoded = json_decode((string)$value, true);
        return is_array($decoded) ? $decoded : [];
    }

    private function severityForPolicyCount(int $count, int $threshold): string
    {
        if ($count >= $threshold * 4) {
            return 'critical';
        }
        if ($count >= $threshold * 2) {
            return 'high';
        }
        return 'medium';
    }

    private function policyIncidentTitle(string $ruleKey, string $subjectKey, int $count, int $windowSeconds): string
    {
        $label = self::DEFAULT_RULES[$ruleKey]['label'] ?? $ruleKey;
        return $label . ' threshold reached for ' . $subjectKey . ' (' . $count . ' in ' . $windowSeconds . 's)';
    }
}
