<?php

declare(strict_types=1);

/**
 * APO-197 Card 2 — Audit event value object + sanitiser + repository.
 *
 * Every authenticated API call writes one row to `ai_audit_log`. The
 * audit_event_id (`aud_<24-hex>`) becomes the canonical reference echoed
 * back in the response envelope.
 *
 * Append-only: the repository exposes only insert + paginated reads. No
 * update/delete by design.
 */
final class MyronAuditEvent
{
    /**
     * @param array<string, mixed>|null $inputSanitised
     * @param array<string, mixed>|null $outputSanitised
     */
    public function __construct(
        public readonly string  $id,
        public readonly string  $auditEventId,
        public readonly ?string $integrationId,
        public readonly string  $actionId,
        public readonly string  $actionVerb,
        public readonly string  $actionDomain,
        public readonly string  $requestUri,
        public readonly string  $requestMethod,
        public readonly ?array  $inputSanitised,
        public readonly ?array  $outputSanitised,
        public readonly string  $outcome,
        public readonly ?string $errorCode,
        public readonly int     $httpStatus,
        public readonly int     $latencyMs,
        public readonly string  $clientIp,
        public readonly string  $userAgent,
        public readonly string  $createdAt,
    ) {
    }

    /**
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        return [
            'id'              => $this->id,
            'auditEventId'    => $this->auditEventId,
            'integrationId'   => $this->integrationId,
            'actionId'        => $this->actionId,
            'actionVerb'      => $this->actionVerb,
            'actionDomain'    => $this->actionDomain,
            'requestUri'      => $this->requestUri,
            'requestMethod'   => $this->requestMethod,
            'inputSanitised'  => $this->inputSanitised,
            'outputSanitised' => $this->outputSanitised,
            'outcome'         => $this->outcome,
            'errorCode'       => $this->errorCode,
            'httpStatus'      => $this->httpStatus,
            'latencyMs'       => $this->latencyMs,
            'clientIp'        => $this->clientIp,
            'userAgent'       => $this->userAgent,
            'createdAt'       => $this->createdAt,
        ];
    }
}

/**
 * Recursive JSON sanitiser. Redacts known-secret keys before persisting.
 *
 * Locked in full-parity-mcp-2026-05-10.md §4. Shape preserved; values
 * become `[REDACTED]` (or `[REDACTED:N bytes]` for contentBase64).
 */
final class MyronAuditSanitiser
{
    private const REDACTED_KEYS = [
        'password', 'currentpassword', 'newpassword', 'password_confirm', 'confirmation',
        'smtppassword', 'smtp_password', 'espapikey', 'esp_api_key', 'apikey', 'api_key',
        'secret', 'secretkey', 'secret_key', 'accesstoken', 'access_token',
        'refreshtoken', 'refresh_token', 'bearertoken', 'bearer_token',
    ];

    /**
     * @param mixed $value
     * @return mixed
     */
    public function sanitise($value)
    {
        if (is_array($value)) {
            $out = [];
            foreach ($value as $k => $v) {
                $keyNorm = strtolower(str_replace(['-', '_', ' '], '', (string) $k));
                if ($k === 'contentBase64' || $keyNorm === 'contentbase64') {
                    $bytesNote = is_string($v) ? '[REDACTED:base64-payload~' . strlen($v) . 'chars]' : '[REDACTED]';
                    $out[$k] = $bytesNote;
                    continue;
                }
                if (in_array($keyNorm, self::REDACTED_KEYS, true)) {
                    $out[$k] = '[REDACTED]';
                    continue;
                }
                // Tokens that look like myr_ai_* are always redacted.
                if (is_string($v) && str_starts_with($v, MyronAiTokenCodec::TOKEN_PREFIX_LITERAL)) {
                    $out[$k] = '[REDACTED:ai-token]';
                    continue;
                }
                $out[$k] = $this->sanitise($v);
            }
            return $out;
        }
        return $value;
    }
}

/**
 * Append-only repository for `ai_audit_log`.
 *
 * No update/delete methods exposed by design — audit records are immutable
 * once persisted. Retention sweeping (if added later) would be a separate
 * privileged operation in a different repository.
 */
final class MyronAuditLogRepository
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly MyronAuditSanitiser $sanitiser,
    ) {
    }

    /**
     * @param array<string, mixed>|null $input
     * @param array<string, mixed>|null $output
     */
    public function append(
        string $auditEventId,
        ?string $integrationId,
        string $actionId,
        string $actionVerb,
        string $actionDomain,
        string $requestUri,
        string $requestMethod,
        ?array $input,
        ?array $output,
        string $outcome,
        ?string $errorCode,
        int $httpStatus,
        int $latencyMs,
        string $clientIp,
        string $userAgent,
        string $licensePlan = '',
        string $licenseStatus = '',
        string $entitlementDecision = ''
    ): void {
        $id = myronGenerateId('aud');
        $stmt = $this->pdo->prepare(
            'INSERT INTO ai_audit_log (
                id, audit_event_id, integration_id, action_id, action_verb, action_domain,
                request_uri, request_method, input_sanitised, output_sanitised,
                outcome, error_code, http_status, latency_ms, client_ip, user_agent,
                license_plan, license_status, entitlement_decision, created_at
            ) VALUES (
                :id, :audit_event_id, :integration_id, :action_id, :action_verb, :action_domain,
                :request_uri, :request_method, :input_sanitised, :output_sanitised,
                :outcome, :error_code, :http_status, :latency_ms, :client_ip, :user_agent,
                :license_plan, :license_status, :entitlement_decision, :created_at
            )'
        );
        $sanitisedInput = $input === null ? null : json_encode($this->sanitiser->sanitise($input), JSON_UNESCAPED_SLASHES);
        $sanitisedOutput = $output === null ? null : json_encode($this->sanitiser->sanitise($output), JSON_UNESCAPED_SLASHES);
        $stmt->execute([
            ':id'              => $id,
            ':audit_event_id'  => $auditEventId,
            ':integration_id'  => $integrationId,
            ':action_id'       => $actionId,
            ':action_verb'     => substr($actionVerb, 0, 120),
            ':action_domain'   => substr($actionDomain, 0, 80),
            ':request_uri'     => substr($requestUri, 0, 255),
            ':request_method'  => substr($requestMethod, 0, 10),
            ':input_sanitised' => $sanitisedInput,
            ':output_sanitised'=> $sanitisedOutput,
            ':outcome'         => $outcome,
            ':error_code'      => $errorCode,
            ':http_status'     => $httpStatus,
            ':latency_ms'      => $latencyMs,
            ':client_ip'       => substr($clientIp, 0, 45),
            ':user_agent'      => substr($userAgent, 0, 255),
            ':license_plan'    => substr($licensePlan, 0, 40),
            ':license_status'  => substr($licenseStatus, 0, 40),
            ':entitlement_decision' => substr($entitlementDecision, 0, 40),
            ':created_at'      => myronNow(),
        ]);
    }

    /**
     * @param array<string, mixed> $filters Supported keys: integrationId, actionVerb,
     *                                       outcome, fromDate, toDate.
     * @return list<MyronAuditEvent>
     */
    public function list(int $limit = 50, int $offset = 0, array $filters = []): array
    {
        $where = [];
        $params = [];
        if (isset($filters['integrationId']) && trim((string) $filters['integrationId']) !== '') {
            $where[] = 'integration_id = :integration_id';
            $params[':integration_id'] = trim((string) $filters['integrationId']);
        }
        if (isset($filters['actionVerb']) && trim((string) $filters['actionVerb']) !== '') {
            $where[] = 'action_verb = :action_verb';
            $params[':action_verb'] = trim((string) $filters['actionVerb']);
        }
        if (isset($filters['outcome']) && trim((string) $filters['outcome']) !== '') {
            $where[] = 'outcome = :outcome';
            $params[':outcome'] = trim((string) $filters['outcome']);
        }
        if (isset($filters['fromDate']) && trim((string) $filters['fromDate']) !== '') {
            $where[] = 'created_at >= :from_date';
            $params[':from_date'] = trim((string) $filters['fromDate']);
        }
        if (isset($filters['toDate']) && trim((string) $filters['toDate']) !== '') {
            $where[] = 'created_at <= :to_date';
            $params[':to_date'] = trim((string) $filters['toDate']);
        }
        $sql = 'SELECT * FROM ai_audit_log';
        if ($where !== []) {
            $sql .= ' WHERE ' . implode(' AND ', $where);
        }
        $sql .= ' ORDER BY created_at DESC LIMIT ' . max(1, min(500, $limit)) . ' OFFSET ' . max(0, $offset);
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
        return array_map([$this, 'hydrate'], $rows ?: []);
    }

    public function findByAuditEventId(string $auditEventId): ?MyronAuditEvent
    {
        $stmt = $this->pdo->prepare('SELECT * FROM ai_audit_log WHERE audit_event_id = :id LIMIT 1');
        $stmt->execute([':id' => trim($auditEventId)]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return is_array($row) ? $this->hydrate($row) : null;
    }

    /**
     * Count rows for an integration within a time window. Used by the
     * rate limiter.
     */
    public function countSince(string $integrationId, string $sinceTimestamp): int
    {
        $stmt = $this->pdo->prepare(
            'SELECT COUNT(*) FROM ai_audit_log WHERE integration_id = :integration_id AND created_at >= :since'
        );
        $stmt->execute([
            ':integration_id' => trim($integrationId),
            ':since'          => $sinceTimestamp,
        ]);
        return (int) $stmt->fetchColumn();
    }

    public function countAiEventsSince(string $sinceTimestamp): int
    {
        $stmt = $this->pdo->prepare(
            'SELECT COUNT(*) FROM ai_audit_log WHERE created_at >= :since'
        );
        $stmt->execute([':since' => $sinceTimestamp]);
        return (int) $stmt->fetchColumn();
    }

    /**
     * @param array<string, mixed> $row
     */
    private function hydrate(array $row): MyronAuditEvent
    {
        $input = $row['input_sanitised'] ?? null;
        $output = $row['output_sanitised'] ?? null;
        return new MyronAuditEvent(
            id:              (string) ($row['id'] ?? ''),
            auditEventId:    (string) ($row['audit_event_id'] ?? ''),
            integrationId:   isset($row['integration_id']) && $row['integration_id'] !== null ? (string) $row['integration_id'] : null,
            actionId:        (string) ($row['action_id'] ?? ''),
            actionVerb:      (string) ($row['action_verb'] ?? ''),
            actionDomain:    (string) ($row['action_domain'] ?? ''),
            requestUri:      (string) ($row['request_uri'] ?? ''),
            requestMethod:   (string) ($row['request_method'] ?? ''),
            inputSanitised:  is_string($input) ? (json_decode($input, true) ?: []) : null,
            outputSanitised: is_string($output) ? (json_decode($output, true) ?: []) : null,
            outcome:         (string) ($row['outcome'] ?? ''),
            errorCode:       isset($row['error_code']) && $row['error_code'] !== null ? (string) $row['error_code'] : null,
            httpStatus:      (int) ($row['http_status'] ?? 0),
            latencyMs:       (int) ($row['latency_ms'] ?? 0),
            clientIp:        (string) ($row['client_ip'] ?? ''),
            userAgent:       (string) ($row['user_agent'] ?? ''),
            createdAt:       (string) ($row['created_at'] ?? ''),
        );
    }
}

/**
 * Audit event ID generator.
 */
final class MyronAuditEventId
{
    public static function generate(): string
    {
        return 'aud_' . bin2hex(random_bytes(12));
    }
}
