<?php

declare(strict_types=1);

namespace App\Application\Admin;

use App\Infrastructure\Audit\AuditRepository;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * `GET /api/v1/admin/audit-log` — paginated, filterable audit list.
 *
 * Filters: actor_kind, actor_id, action, entity_type, entity_id, from, to.
 * Pagination: page (1-indexed), page_size (default 50, max 200).
 *
 * RBAC: Viewer — every signed-in user can see audit. Per SPEC §7 RBAC matrix.
 */
final class AuditController
{
    use AdminControllerSupport;

    private const ALLOWED_ACTOR_KINDS = ['user', 'admin-token', 'reporter', 'consumer', 'system'];
    private const ALLOWED_ACTOR_VIA = ['oidc', 'local', 'admin-token', 'service', 'reporter', 'consumer', 'system'];

    // SEC_REVIEW F31: cap free-form filter strings so a caller can't push
    // multi-megabyte garbage through the query planner. Audit values written
    // by the system are well under these limits (action names ~30 chars,
    // entity types/ids short identifiers); 128 is comfortable headroom.
    private const MAX_FILTER_LENGTH = 128;

    // SEC_REVIEW F47: charset gate for the *_kind columns the audit
    // filters touch (`subject_kind`, `entity_type`). Every known kind
    // is a short snake/kebab identifier (`reporter`, `consumer`,
    // `admin-token`, `manual_block`, `oidc_role_mapping`); reject
    // anything that isn't a-z0-9 plus `_-` so a caller can't push
    // bytes that wouldn't match any real `target_type` / `actor_kind`
    // column value through the prepared statement.
    private const KIND_PATTERN = '/^[a-z0-9][a-z0-9_-]*$/';

    // SEC_REVIEW F31: cap pagination depth so a Viewer cannot force
    // `LIMIT n OFFSET huge` deep scans. 10 000 rows is well past any
    // human-driven browse and bounds the worst-case scan cost.
    private const MAX_OFFSET = 10000;

    public function __construct(private readonly AuditRepository $audit)
    {
    }

    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $params = $request->getQueryParams();
        $errors = [];

        $pageSize = isset($params['page_size']) && ctype_digit((string) $params['page_size'])
            ? (int) $params['page_size']
            : 50;
        $pageSize = max(1, min(200, $pageSize));
        $page = isset($params['page']) && ctype_digit((string) $params['page']) ? max(1, (int) $params['page']) : 1;

        $filters = [];

        if (isset($params['actor_kind']) && is_string($params['actor_kind']) && $params['actor_kind'] !== '') {
            if (!in_array($params['actor_kind'], self::ALLOWED_ACTOR_KINDS, true)) {
                $errors['actor_kind'] = 'must be one of: ' . implode(', ', self::ALLOWED_ACTOR_KINDS);
            } else {
                $filters['actor_kind'] = $params['actor_kind'];
            }
        }

        if (isset($params['actor_id']) && (string) $params['actor_id'] !== '') {
            if (!ctype_digit((string) $params['actor_id'])) {
                $errors['actor_id'] = 'must be a positive integer';
            } else {
                $filters['actor_id'] = (int) $params['actor_id'];
            }
        }

        if (isset($params['actor_via']) && is_string($params['actor_via']) && $params['actor_via'] !== '') {
            if (!in_array($params['actor_via'], self::ALLOWED_ACTOR_VIA, true)) {
                $errors['actor_via'] = 'must be one of: ' . implode(', ', self::ALLOWED_ACTOR_VIA);
            } else {
                $filters['actor_via'] = $params['actor_via'];
            }
        }

        if (isset($params['action']) && is_string($params['action']) && $params['action'] !== '') {
            if (strlen($params['action']) > self::MAX_FILTER_LENGTH) {
                $errors['action'] = 'must be at most ' . self::MAX_FILTER_LENGTH . ' characters';
            } else {
                $filters['action'] = $params['action'];
            }
        }

        if (isset($params['entity_type']) && is_string($params['entity_type']) && $params['entity_type'] !== '') {
            if (strlen($params['entity_type']) > self::MAX_FILTER_LENGTH) {
                $errors['entity_type'] = 'must be at most ' . self::MAX_FILTER_LENGTH . ' characters';
            } elseif (preg_match(self::KIND_PATTERN, $params['entity_type']) !== 1) {
                $errors['entity_type'] = 'must match `[a-z0-9][a-z0-9_-]*`';
            } else {
                $filters['entity_type'] = $params['entity_type'];
            }
        }

        if (isset($params['entity_id']) && is_string($params['entity_id']) && $params['entity_id'] !== '') {
            if (strlen($params['entity_id']) > self::MAX_FILTER_LENGTH) {
                $errors['entity_id'] = 'must be at most ' . self::MAX_FILTER_LENGTH . ' characters';
            } else {
                $filters['entity_id'] = $params['entity_id'];
            }
        }

        // Subject filter (used by reporter/consumer detail pages):
        // both halves must be present together — a kind without an id
        // would silently widen the result set and is almost always a bug.
        $subjectKind = isset($params['subject_kind']) && is_string($params['subject_kind']) ? $params['subject_kind'] : '';
        $subjectId = isset($params['subject_id']) && is_string($params['subject_id']) ? $params['subject_id'] : '';
        if ($subjectKind !== '' || $subjectId !== '') {
            if ($subjectKind === '' || $subjectId === '') {
                $errors['subject'] = 'subject_kind and subject_id must be supplied together';
            } elseif (strlen($subjectKind) > self::MAX_FILTER_LENGTH || strlen($subjectId) > self::MAX_FILTER_LENGTH) {
                $errors['subject'] = 'subject_kind and subject_id must each be at most ' . self::MAX_FILTER_LENGTH . ' characters';
            } elseif (preg_match(self::KIND_PATTERN, $subjectKind) !== 1) {
                // SEC_REVIEW F47: subject_kind is matched against
                // `target_type` / `actor_kind` columns whose real values
                // are all short snake/kebab identifiers.
                $errors['subject'] = 'subject_kind must match `[a-z0-9][a-z0-9_-]*`';
            } else {
                $filters['subject_kind'] = $subjectKind;
                $filters['subject_id'] = $subjectId;
            }
        }

        if (isset($params['from']) && is_string($params['from']) && $params['from'] !== '') {
            $normalized = self::normalizeIsoTimestamp($params['from']);
            if ($normalized === null) {
                $errors['from'] = 'must be ISO-8601 (e.g. 2026-04-01T00:00:00Z)';
            } else {
                $filters['from'] = $normalized;
            }
        }

        if (isset($params['to']) && is_string($params['to']) && $params['to'] !== '') {
            $normalized = self::normalizeIsoTimestamp($params['to']);
            if ($normalized === null) {
                $errors['to'] = 'must be ISO-8601 (e.g. 2026-04-30T23:59:59Z)';
            } else {
                $filters['to'] = $normalized;
            }
        }

        $offset = ($page - 1) * $pageSize;
        if ($offset > self::MAX_OFFSET) {
            // SEC_REVIEW F31: deep-pagination guard. Past MAX_OFFSET we
            // refuse rather than silently reset, so a client that wanted
            // an old row notices and switches to a `to=` cursor instead.
            $errors['page'] = 'pagination depth exceeded; use `to=` to cursor into older rows';
        }

        if ($errors !== []) {
            return self::validationFailed($response, $errors);
        }

        $result = $this->audit->search($filters, $pageSize, $offset);

        return self::json($response, 200, [
            'items' => $result['items'],
            'page' => $page,
            'page_size' => $pageSize,
            'total' => $result['total'],
        ]);
    }

    /**
     * Convert an ISO-8601 input into the `Y-m-d H:i:s` form used by the
     * audit table. Tolerates `Z`, `+00:00`, fractional seconds, or no TZ
     * (assumed UTC).
     */
    private static function normalizeIsoTimestamp(string $value): ?string
    {
        try {
            $dt = new \DateTimeImmutable($value, new \DateTimeZone('UTC'));
        } catch (\Exception) {
            return null;
        }

        return $dt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
    }
}
