<?php

declare(strict_types=1);

namespace App\Application\Admin;

use App\Domain\Audit\AuditAction;
use App\Domain\Audit\AuditEmitter;
use App\Domain\Ip\Cidr;
use App\Domain\Ip\InvalidCidrException;
use App\Domain\Ip\InvalidIpException;
use App\Domain\Ip\IpAddress;
use App\Domain\ManualBlock\ManualBlock;
use App\Infrastructure\ManualBlock\ManualBlockRepository;
use App\Infrastructure\Reputation\BlocklistCache;
use App\Infrastructure\Reputation\CidrEvaluatorFactory;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Admin CRUD over `manual_blocks`. SPEC §6 RBAC:
 *  - Operator: create + delete
 *  - Viewer:   list + get
 *
 * Per-route role enforcement happens in `AppFactory` via `RbacMiddleware::require`;
 * this controller only sees authorized requests.
 *
 * CIDR canonicalization (recommendation (c) in M06.md): non-canonical input
 * such as `203.0.113.55/24` is silently normalized to `203.0.113.0/24` and
 * the response body echoes both `cidr` and `normalized_from`. Canonical
 * input omits the field. This is the behaviour the M06 acceptance script
 * tests for.
 */
final class ManualBlocksController
{
    use AdminControllerSupport;

    public function __construct(
        private readonly ManualBlockRepository $manualBlocks,
        private readonly CidrEvaluatorFactory $evaluator,
        private readonly BlocklistCache $blocklistCache,
        private readonly AuditEmitter $audit,
        private readonly Connection $connection,
    ) {
    }

    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $page = self::pagination($request);
        $params = $request->getQueryParams();
        $kind = null;
        if (isset($params['kind']) && is_string($params['kind']) && in_array($params['kind'], [ManualBlock::KIND_IP, ManualBlock::KIND_SUBNET], true)) {
            $kind = $params['kind'];
        }

        $rows = $this->manualBlocks->list($page['limit'], $page['offset'], ['kind' => $kind]);
        $total = $this->manualBlocks->count($kind);

        return self::json($response, 200, [
            'items' => array_map(static fn (ManualBlock $b) => $b->toArray(), $rows),
            'page' => $page['page'],
            'limit' => $page['limit'],
            'total' => $total,
        ]);
    }

    /**
     * @param array{id: string} $args
     */
    public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        $id = self::parseId($args['id']);
        if ($id === null) {
            return self::error($response, 404, 'not_found');
        }

        $entry = $this->manualBlocks->findById($id);
        if ($entry === null) {
            return self::error($response, 404, 'not_found');
        }

        return self::json($response, 200, $entry->toArray());
    }

    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $body = self::jsonBody($request);
        $errors = [];

        $kind = $body['kind'] ?? null;
        if (!in_array($kind, [ManualBlock::KIND_IP, ManualBlock::KIND_SUBNET], true)) {
            return self::validationFailed($response, ['kind' => 'required, "ip" or "subnet"']);
        }

        $reason = null;
        if (array_key_exists('reason', $body)) {
            if ($body['reason'] !== null && !is_string($body['reason'])) {
                $errors['reason'] = 'must be string or null';
            } else {
                // SEC_REVIEW F52: strip C0/C1 control characters before
                // the reason lands in `audit_log.target_label` /
                // `details_json` to defeat log-injection and terminal-
                // escape attacks on log viewers.
                $reason = is_string($body['reason'])
                    ? self::stripControlChars($body['reason'])
                    : null;
            }
        }

        $expiresAt = null;
        if (array_key_exists('expires_at', $body) && $body['expires_at'] !== null) {
            if (!is_string($body['expires_at'])) {
                $errors['expires_at'] = 'must be ISO-8601 string or null';
            } else {
                $parsed = self::parseTimestamp($body['expires_at']);
                if ($parsed === null) {
                    $errors['expires_at'] = 'must be ISO-8601 (e.g. 2026-12-31T23:59:59Z)';
                } else {
                    $expiresAt = $parsed;
                }
            }
        }

        if ($kind === ManualBlock::KIND_IP) {
            if (!isset($body['ip']) || !is_string($body['ip']) || $body['ip'] === '') {
                $errors['ip'] = 'required for kind=ip';
            }
            if (isset($body['cidr'])) {
                $errors['cidr'] = 'forbidden for kind=ip';
            }
            if ($errors !== []) {
                return self::validationFailed($response, $errors);
            }

            try {
                /** @var string $ipText */
                $ipText = $body['ip'];
                $ip = IpAddress::fromString($ipText);
            } catch (InvalidIpException $e) {
                return self::validationFailed($response, ['ip' => $e->getMessage()]);
            }

            $userId = self::actingUserId($request);
            $auditCtx = self::auditContext($request);
            $id = $this->connection->transactional(function () use ($ip, $reason, $expiresAt, $userId, $auditCtx): int {
                $id = $this->manualBlocks->createIp($ip, $reason, $expiresAt, $userId);
                $this->audit->emitOrThrow(
                    AuditAction::MANUAL_BLOCK_CREATED,
                    'manual_block',
                    $id,
                    ['kind' => 'ip', 'ip' => $ip->text(), 'reason' => $reason, 'expires_at' => $expiresAt?->format('c')],
                    $auditCtx,
                    $ip->text(),
                );

                return $id;
            });

            $this->evaluator->invalidate();
            $this->blocklistCache->invalidateAll();
            // Eagerly rebuild so any overlap with the allowlist surfaces as
            // a WARNING log entry while we're still in this request — gives
            // admins immediate feedback on a suspicious configuration.
            $this->evaluator->get();
            $created = $this->manualBlocks->findById($id);
            if ($created === null) {
                return self::error($response, 500, 'create_failed');
            }

            return self::json($response, 201, $created->toArray());
        }

        // kind=subnet
        if (!isset($body['cidr']) || !is_string($body['cidr']) || $body['cidr'] === '') {
            $errors['cidr'] = 'required for kind=subnet';
        }
        if (isset($body['ip'])) {
            $errors['ip'] = 'forbidden for kind=subnet';
        }
        if ($errors !== []) {
            return self::validationFailed($response, $errors);
        }

        try {
            /** @var string $cidrInput */
            $cidrInput = $body['cidr'];
            $cidr = Cidr::fromString($cidrInput);
        } catch (InvalidCidrException $e) {
            return self::validationFailed($response, ['cidr' => $e->getMessage()]);
        }

        $userId = self::actingUserId($request);
        $auditCtx = self::auditContext($request);
        $id = $this->connection->transactional(function () use ($cidr, $reason, $expiresAt, $userId, $auditCtx): int {
            $id = $this->manualBlocks->createSubnet($cidr, $reason, $expiresAt, $userId);
            $this->audit->emitOrThrow(
                AuditAction::MANUAL_BLOCK_CREATED,
                'manual_block',
                $id,
                ['kind' => 'subnet', 'cidr' => $cidr->text(), 'reason' => $reason, 'expires_at' => $expiresAt?->format('c')],
                $auditCtx,
                $cidr->text(),
            );

            return $id;
        });

        $this->evaluator->invalidate();
        $this->blocklistCache->invalidateAll();
        $this->evaluator->get();
        $created = $this->manualBlocks->findById($id);
        if ($created === null) {
            return self::error($response, 500, 'create_failed');
        }

        $payload = $created->toArray();
        if ($cidrInput !== $cidr->text()) {
            $payload['normalized_from'] = $cidrInput;
        }

        return self::json($response, 201, $payload);
    }

    /**
     * @param array{id: string} $args
     */
    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        $id = self::parseId($args['id']);
        if ($id === null) {
            return self::error($response, 404, 'not_found');
        }
        $existing = $this->manualBlocks->findById($id);
        if ($existing === null) {
            return self::error($response, 404, 'not_found');
        }

        $label = $existing->kind === ManualBlock::KIND_IP
            ? $existing->ip?->text()
            : $existing->cidr?->text();
        $auditCtx = self::auditContext($request);

        $this->connection->transactional(function () use ($id, $existing, $label, $auditCtx): void {
            $this->manualBlocks->delete($id);
            $this->audit->emitOrThrow(
                AuditAction::MANUAL_BLOCK_DELETED,
                'manual_block',
                $id,
                ['kind' => $existing->kind, 'ip' => $existing->ip?->text(), 'cidr' => $existing->cidr?->text(), 'reason' => $existing->reason],
                $auditCtx,
                $label,
            );
        });

        $this->evaluator->invalidate();
        $this->blocklistCache->invalidateAll();

        return $response->withStatus(204);
    }

    private static function parseId(string $raw): ?int
    {
        return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
    }

    private static function parseTimestamp(string $iso): ?DateTimeImmutable
    {
        try {
            return new DateTimeImmutable($iso, new DateTimeZone('UTC'));
        } catch (\Exception) {
            return null;
        }
    }
}
