<?php

declare(strict_types=1);

namespace App\Application\Admin;

use App\Domain\Audit\AuditContext;
use App\Domain\Auth\AuthenticatedPrincipal;
use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Tiny helpers shared by admin controllers — response shaping, JSON body
 * parsing, validation envelopes, pagination params. Kept as a trait so
 * controllers stay simple and DI-free.
 */
trait AdminControllerSupport
{
    /**
     * @return array<string, mixed>
     */
    private static function jsonBody(ServerRequestInterface $request): array
    {
        $parsed = $request->getParsedBody();
        if (is_array($parsed)) {
            return $parsed;
        }
        $raw = (string) $request->getBody();
        if ($raw === '') {
            return [];
        }
        $decoded = json_decode($raw, true);

        return is_array($decoded) ? $decoded : [];
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function json(ResponseInterface $response, int $status, array $payload): ResponseInterface
    {
        $response = $response->withStatus($status)->withHeader('Content-Type', 'application/json');
        $response->getBody()->write((string) json_encode($payload));

        return $response;
    }

    /**
     * @param array<string, string> $details
     */
    private static function validationFailed(ResponseInterface $response, array $details): ResponseInterface
    {
        return self::json($response, 400, [
            'error' => 'validation_failed',
            'details' => $details,
        ]);
    }

    private static function error(ResponseInterface $response, int $status, string $error): ResponseInterface
    {
        return self::json($response, $status, ['error' => $error]);
    }

    /**
     * @return array{limit: int, offset: int, page: int}
     */
    private static function pagination(ServerRequestInterface $request, int $defaultLimit = 50, int $maxLimit = 200): array
    {
        $params = $request->getQueryParams();
        $page = isset($params['page']) && ctype_digit((string) $params['page']) ? max(1, (int) $params['page']) : 1;
        $limit = isset($params['limit']) && ctype_digit((string) $params['limit']) ? (int) $params['limit'] : $defaultLimit;
        $limit = max(1, min($maxLimit, $limit));

        return ['limit' => $limit, 'offset' => ($page - 1) * $limit, 'page' => $page];
    }

    private static function actingUserId(ServerRequestInterface $request): ?int
    {
        $principal = $request->getAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL);

        return $principal instanceof AuthenticatedPrincipal ? $principal->userId : null;
    }

    /**
     * Pull the request-scoped AuditContext (set by AuditContextMiddleware).
     * Falls back to a minimal system context if the middleware didn't run
     * (test setups that bypass the middleware stack).
     */
    private static function auditContext(ServerRequestInterface $request): AuditContext
    {
        $ctx = $request->getAttribute(AuditContextMiddleware::ATTR_AUDIT_CONTEXT);

        return $ctx instanceof AuditContext ? $ctx : AuditContext::system();
    }

    /**
     * Compute a `{field: {from, to}}` diff between two snapshots, restricted
     * to keys that actually appear in `$after`. The audit log uses this on
     * update events so a reader sees both the previous value and the new
     * value side by side instead of just a list of changed field names.
     *
     * Equality is loose: scalar comparisons after string-coercion. Values
     * that round-trip identically through `(string)$x` are not reported.
     *
     * @param array<string, mixed> $before snapshot before mutation
     * @param array<string, mixed> $after  fields submitted for change (only these keys are compared)
     * @return array<string, array{from: mixed, to: mixed}>
     */
    private static function diffFields(array $before, array $after): array
    {
        $changes = [];
        foreach ($after as $key => $newValue) {
            $oldValue = $before[$key] ?? null;
            if (self::scalarEquals($oldValue, $newValue)) {
                continue;
            }
            $changes[$key] = ['from' => $oldValue, 'to' => $newValue];
        }

        return $changes;
    }

    private static function scalarEquals(mixed $a, mixed $b): bool
    {
        if ($a === $b) {
            return true;
        }
        if ($a === null || $b === null) {
            return false;
        }
        if (is_scalar($a) && is_scalar($b)) {
            // Catches "1.00" vs 1.0, true vs 1, etc. — the kinds of
            // round-trip differences that aren't really changes.
            return (string) $a === (string) $b;
        }

        return false;
    }

    /**
     * SEC_REVIEW F52: strip C0/C1 control characters from a free-form
     * string field before it's stored or fed into the audit log. NULs,
     * newlines, ANSI escape sequences and DEL otherwise land in
     * `audit_log.target_label` and `details_json` — Twig auto-escapes
     * for browser display, but log-injection (`\n[CRIT] fake event`)
     * and terminal-escape attacks on log viewers remain.
     *
     * Strips 0x00..0x1F (C0) and 0x7F..0x9F (DEL + C1) by default.
     * Reserved for future multi-line `description` fields: callers
     * can pass `$allowNewlines=true` to keep CR/LF/tab.
     */
    private static function stripControlChars(string $value, bool $allowNewlines = false): string
    {
        $pattern = $allowNewlines
            ? '/[\x00-\x08\x0b-\x1f\x7f-\x9f]/u'
            : '/[\x00-\x1f\x7f-\x9f]/u';

        return (string) preg_replace($pattern, '', $value);
    }

    /**
     * Convenience wrapper for the very common
     * `trim($body['x']) + strip` pattern on free-form string inputs
     * (`name`, `reason`, `slug`-shaped fields). Returns `null` for
     * non-string / null inputs so the caller can branch on
     * presence; returns the cleaned string otherwise.
     */
    private static function cleanString(mixed $value, bool $allowNewlines = false): ?string
    {
        if (!is_string($value)) {
            return null;
        }

        return self::stripControlChars(trim($value), $allowNewlines);
    }
}
