<?php

declare(strict_types=1);

namespace App\Application\Admin;

use App\Domain\Audit\AuditAction;
use App\Domain\Audit\AuditEmitter;
use App\Domain\Category\Category;
use App\Domain\Policy\Policy;
use App\Domain\Reputation\BlocklistBuilder;
use App\Domain\Reputation\Decay;
use App\Domain\Time\Clock;
use App\Infrastructure\Category\CategoryRepository;
use App\Infrastructure\ManualBlock\ManualBlockRepository;
use App\Infrastructure\Policy\PolicyRepository;
use App\Infrastructure\Reputation\BlocklistCache;
use App\Infrastructure\Reputation\IpScoreRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Admin CRUD over policies and their thresholds, plus the per-policy
 * preview endpoint used by the UI.
 *
 * Threshold body shape: `{<category_slug>: <number>}`. The controller
 * resolves slugs to ids via CategoryRepository before persisting; unknown
 * slugs surface as a single validation error.
 *
 * RBAC split (per SPEC §6 / §M07): list/show/preview = Viewer, create/
 * update/delete = Admin. Enforced in `AppFactory` via `RbacMiddleware`.
 */
final class PoliciesController
{
    use AdminControllerSupport;

    public function __construct(
        private readonly PolicyRepository $policies,
        private readonly CategoryRepository $categories,
        private readonly BlocklistBuilder $blocklistBuilder,
        private readonly BlocklistCache $blocklistCache,
        private readonly AuditEmitter $audit,
        private readonly ManualBlockRepository $manualBlocks,
        private readonly Clock $clock,
        private readonly IpScoreRepository $ipScores,
        private readonly Connection $connection,
    ) {
    }

    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $policies = $this->policies->listAll();
        $slugMap = $this->slugByCategoryId();

        return self::json($response, 200, [
            'items' => array_map(static fn (Policy $p) => $p->toArray($slugMap), $policies),
            'total' => count($policies),
        ]);
    }

    /**
     * @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');
        }
        $policy = $this->policies->findById($id);
        if ($policy === null) {
            return self::error($response, 404, 'not_found');
        }

        return self::json($response, 200, $policy->toArray($this->slugByCategoryId()));
    }

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

        $name = isset($body['name']) && is_string($body['name']) ? trim($body['name']) : '';
        if ($name === '' || strlen($name) > 128) {
            $errors['name'] = 'required, 1–128 chars';
        }

        $description = null;
        if (array_key_exists('description', $body)) {
            if ($body['description'] !== null && !is_string($body['description'])) {
                $errors['description'] = 'must be string or null';
            } else {
                $description = $body['description'];
            }
        }

        $includeManualBlocks = true;
        if (array_key_exists('include_manual_blocks', $body)) {
            if (!is_bool($body['include_manual_blocks'])) {
                $errors['include_manual_blocks'] = 'must be boolean';
            } else {
                $includeManualBlocks = $body['include_manual_blocks'];
            }
        }

        $thresholds = [];
        if (array_key_exists('thresholds', $body)) {
            if (!is_array($body['thresholds'])) {
                $errors['thresholds'] = 'must be object {category_slug: number}';
            } else {
                $resolved = $this->resolveThresholds($body['thresholds']);
                if (is_string($resolved)) {
                    $errors['thresholds'] = $resolved;
                } else {
                    $thresholds = $resolved;
                }
            }
        }

        if (!isset($errors['name']) && $name !== '' && $this->policies->findByName($name) !== null) {
            $errors['name'] = 'already exists';
        }

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

        $auditCtx = self::auditContext($request);
        $id = $this->connection->transactional(function () use ($name, $description, $includeManualBlocks, $thresholds, $auditCtx): int {
            $id = $this->policies->create($name, $description, $includeManualBlocks, $thresholds);
            $this->audit->emitOrThrow(
                AuditAction::POLICY_CREATED,
                'policy',
                $id,
                ['name' => $name, 'include_manual_blocks' => $includeManualBlocks, 'threshold_count' => count($thresholds)],
                $auditCtx,
                $name,
            );

            return $id;
        });

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

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

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

        $body = self::jsonBody($request);
        $errors = [];
        $fields = [];

        if (array_key_exists('name', $body)) {
            if (!is_string($body['name']) || trim($body['name']) === '' || strlen(trim($body['name'])) > 128) {
                $errors['name'] = 'required, 1–128 chars';
            } else {
                $name = trim($body['name']);
                $other = $this->policies->findByName($name);
                if ($other !== null && $other->id !== $id) {
                    $errors['name'] = 'already exists';
                } else {
                    $fields['name'] = $name;
                }
            }
        }
        if (array_key_exists('description', $body)) {
            if ($body['description'] !== null && !is_string($body['description'])) {
                $errors['description'] = 'must be string or null';
            } else {
                $fields['description'] = $body['description'];
            }
        }
        if (array_key_exists('include_manual_blocks', $body)) {
            if (!is_bool($body['include_manual_blocks'])) {
                $errors['include_manual_blocks'] = 'must be boolean';
            } else {
                $fields['include_manual_blocks'] = $body['include_manual_blocks'] ? 1 : 0;
            }
        }

        $thresholds = null;
        if (array_key_exists('thresholds', $body)) {
            if (!is_array($body['thresholds'])) {
                $errors['thresholds'] = 'must be object {category_slug: number}';
            } else {
                $resolved = $this->resolveThresholds($body['thresholds']);
                if (is_string($resolved)) {
                    $errors['thresholds'] = $resolved;
                } else {
                    $thresholds = $resolved;
                }
            }
        }

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

        $slugByCategoryId = $this->slugByCategoryId();

        $beforeSnapshot = [
            'name' => $existing->name,
            'description' => $existing->description,
            'include_manual_blocks' => $existing->includeManualBlocks ? 1 : 0,
        ];

        $beforeThresholds = self::thresholdsBySlug($existing->thresholds, $slugByCategoryId);

        $changes = self::diffFields($beforeSnapshot, $fields);
        if ($thresholds !== null) {
            $afterThresholds = self::thresholdsBySlug($thresholds, $slugByCategoryId);
            if ($beforeThresholds !== $afterThresholds) {
                $changes['thresholds'] = ['from' => $beforeThresholds, 'to' => $afterThresholds];
            }
        }

        $auditCtx = self::auditContext($request);
        $this->connection->transactional(function () use ($id, $fields, $thresholds, $existing, $changes, $auditCtx): void {
            if ($fields !== []) {
                $this->policies->update($id, $fields);
            }
            if ($thresholds !== null) {
                $this->policies->replaceThresholds($id, $thresholds);
            }
            $updatedName = isset($fields['name']) ? (string) $fields['name'] : $existing->name;
            $this->audit->emitOrThrow(
                AuditAction::POLICY_UPDATED,
                'policy',
                $id,
                ['name' => $existing->name, 'changes' => $changes],
                $auditCtx,
                $updatedName,
            );
        });

        $this->blocklistCache->invalidate($id);
        $updated = $this->policies->findById($id);
        if ($updated === null) {
            return self::error($response, 500, 'update_failed');
        }

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

    /**
     * @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->policies->findById($id);
        if ($existing === null) {
            return self::error($response, 404, 'not_found');
        }

        $consumers = $this->policies->consumersUsing($id);
        if ($consumers !== []) {
            return self::json($response, 409, [
                'error' => 'policy_in_use',
                'consumers' => $consumers,
            ]);
        }

        $auditCtx = self::auditContext($request);
        $this->connection->transactional(function () use ($id, $existing, $auditCtx): void {
            $this->policies->delete($id);
            $this->audit->emitOrThrow(
                AuditAction::POLICY_DELETED,
                'policy',
                $id,
                ['name' => $existing->name],
                $auditCtx,
                $existing->name,
            );
        });

        $this->blocklistCache->invalidate($id);

        return $response->withStatus(204);
    }

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

        $blocklist = $this->blocklistBuilder->build($policy);
        $sample = array_slice($blocklist->entries, 0, 50);

        $catBySlug = [];
        foreach ($this->categories->listAll() as $cat) {
            /** @var Category $cat */
            $catBySlug[$cat->slug] = $cat;
        }
        $thresholdBySlug = [];
        $slugByCategoryId = $this->slugByCategoryId();
        foreach ($policy->thresholds as $catId => $threshold) {
            $slug = $slugByCategoryId[$catId] ?? null;
            if ($slug !== null) {
                $thresholdBySlug[$slug] = $threshold;
            }
        }

        $manualExpiryByKey = [];
        foreach ($this->manualBlocks->listIps() as $mb) {
            if ($mb->ip !== null) {
                $manualExpiryByKey['ip:' . $mb->ip->text()] = $mb->expiresAt;
            }
        }
        foreach ($this->manualBlocks->listSubnets() as $mb) {
            if ($mb->cidr !== null) {
                $manualExpiryByKey['cidr:' . $mb->cidr->text()] = $mb->expiresAt;
            }
        }

        $now = $this->clock->now();
        $sampleOut = [];
        foreach ($sample as $entry) {
            $expiresAt = null;
            $estimated = false;
            if ($entry->reason === 'scored') {
                $maxDays = null;
                foreach ($entry->categoryScores as $slug => $score) {
                    if (!isset($thresholdBySlug[$slug], $catBySlug[$slug])) {
                        continue;
                    }
                    $cat = $catBySlug[$slug];
                    $days = Decay::daysUntilThreshold(
                        $cat->decayFunction,
                        $cat->decayParam,
                        $score,
                        $thresholdBySlug[$slug],
                    );
                    if ($days === null) {
                        continue;
                    }
                    if ($maxDays === null || $days > $maxDays) {
                        $maxDays = $days;
                    }
                }
                if ($maxDays !== null) {
                    $seconds = (int) round($maxDays * 86400.0);
                    $expiresAt = $now->modify(($seconds >= 0 ? '+' : '') . $seconds . ' seconds');
                    $estimated = true;
                }
            } elseif ($entry->reason === 'manual') {
                $key = ($entry->isCidr ? 'cidr:' : 'ip:') . $entry->ipOrCidr;
                $expiresAt = $manualExpiryByKey[$key] ?? null;
            }
            $sampleOut[] = [
                'ip_or_cidr' => $entry->ipOrCidr,
                'reason' => $entry->reason,
                'expires_at' => self::isoOrNull($expiresAt),
                'expires_estimated' => $estimated,
            ];
        }

        return self::json($response, 200, [
            'count' => $blocklist->count(),
            'sample' => $sampleOut,
            'generated_at' => $blocklist->generatedAt->format('Y-m-d\TH:i:s\Z'),
            'policy' => $policy->name,
        ]);
    }

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

        $distribution = $this->ipScores->scoreDistribution(5.0);
        $byCategory = $this->ipScores->scoreDistributionByCategory(5.0);

        $slugByCategoryId = $this->slugByCategoryId();

        $thresholds = [];
        foreach ($policy->thresholds as $catId => $threshold) {
            $slug = $slugByCategoryId[$catId] ?? null;
            $thresholds[] = [
                'category_id' => $catId,
                'category_slug' => $slug,
                'threshold' => $threshold,
            ];
        }

        $categories = [];
        foreach ($byCategory['categories'] as $catId => $catData) {
            $categories[] = [
                'category_id' => $catId,
                'category_slug' => $slugByCategoryId[$catId] ?? null,
                'buckets' => $catData['buckets'],
                'max_score' => $catData['max_score'],
            ];
        }

        return self::json($response, 200, [
            'buckets' => $distribution['buckets'],
            'max_score' => $distribution['max_score'],
            'bucket_size' => $distribution['bucket_size'],
            'thresholds' => $thresholds,
            'categories' => $categories,
            'policy' => $policy->name,
        ]);
    }

    private static function isoOrNull(?DateTimeImmutable $dt): ?string
    {
        return $dt?->format('Y-m-d\TH:i:s\Z');
    }

    /**
     * Convert `[category_id => threshold]` to `[category_slug => threshold]` for
     * audit-log readability, sorted by slug so before/after diffs compare cleanly.
     *
     * @param array<int, float> $byId
     * @param array<int, string> $slugByCategoryId
     * @return array<string, float>
     */
    private static function thresholdsBySlug(array $byId, array $slugByCategoryId): array
    {
        $out = [];
        foreach ($byId as $catId => $threshold) {
            $slug = $slugByCategoryId[$catId] ?? ('category_' . $catId);
            $out[$slug] = $threshold;
        }
        ksort($out);

        return $out;
    }

    /**
     * Resolve a body-supplied `{slug: number}` map to `[category_id => float]`.
     * Returns the integer-keyed map on success, or a single human-readable
     * error string suitable for `validation_failed.details.thresholds`.
     *
     * @param array<int|string, mixed> $raw
     * @return array<int, float>|string
     */
    private function resolveThresholds(array $raw): array|string
    {
        if ($raw === []) {
            return [];
        }
        $idBySlug = [];
        foreach ($this->categories->listAll() as $cat) {
            /** @var Category $cat */
            $idBySlug[$cat->slug] = $cat->id;
        }

        $out = [];
        foreach ($raw as $slug => $value) {
            if (!is_string($slug) || $slug === '') {
                return 'keys must be category slugs';
            }
            if (!isset($idBySlug[$slug])) {
                return sprintf('unknown category slug: %s', $slug);
            }
            if (is_int($value)) {
                $value = (float) $value;
            }
            if (!is_float($value) || $value < 0.0) {
                return sprintf('threshold for %s must be non-negative number', $slug);
            }
            $out[$idBySlug[$slug]] = $value;
        }

        return $out;
    }

    /**
     * @return array<int, string>
     */
    private function slugByCategoryId(): array
    {
        $out = [];
        foreach ($this->categories->listAll() as $cat) {
            /** @var Category $cat */
            $out[$cat->id] = $cat->slug;
        }

        return $out;
    }

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