<?php

declare(strict_types=1);

namespace App\Application\Admin;

use App\Domain\Audit\AuditAction;
use App\Domain\Audit\AuditEmitter;
use App\Infrastructure\Consumer\ConsumerRepository;
use Doctrine\DBAL\Connection;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Admin CRUD over consumers. Mirrors ReportersController's shape; the
 * differences are `policy_id` (FK validated against `policies`) and the
 * delete-on-active-tokens consideration: the api_tokens FK is CASCADE,
 * so dropping a consumer revokes its tokens automatically. We still do
 * a soft delete by default to match reporters.
 */
final class ConsumersController
{
    use AdminControllerSupport;

    public function __construct(
        private readonly ConsumerRepository $consumers,
        private readonly AuditEmitter $audit,
        private readonly Connection $connection,
    ) {
    }

    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $page = self::pagination($request);
        $rows = $this->consumers->list($page['limit'], $page['offset']);
        $total = $this->consumers->count();

        return self::json($response, 200, [
            'data' => array_map(static fn ($c) => $c->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');
        }

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

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

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

        // SEC_REVIEW F52: strip C0/C1 control characters from
        // free-form input before it lands in `audit_log.target_label`
        // / `details_json`.
        $name = self::cleanString($body['name'] ?? null) ?? '';
        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 = is_string($body['description'])
                    ? self::stripControlChars($body['description'])
                    : null;
            }
        }

        $policyId = null;
        if (!array_key_exists('policy_id', $body) || !is_int($body['policy_id'])) {
            // Accept numeric string too — JSON usually has int but be lenient.
            if (isset($body['policy_id']) && is_string($body['policy_id']) && ctype_digit($body['policy_id'])) {
                $policyId = (int) $body['policy_id'];
            } else {
                $errors['policy_id'] = 'required, integer';
            }
        } else {
            $policyId = $body['policy_id'];
        }

        if ($policyId !== null && $policyId > 0 && !$this->consumers->policyExists($policyId)) {
            $errors['policy_id'] = 'unknown policy';
        }

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

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

        /** @var int $policyId */
        $userId = self::actingUserId($request);
        $auditCtx = self::auditContext($request);
        $id = $this->connection->transactional(function () use ($name, $description, $policyId, $userId, $auditCtx): int {
            $id = $this->consumers->create($name, $description, $policyId, $userId);
            $this->audit->emitOrThrow(
                AuditAction::CONSUMER_CREATED,
                'consumer',
                $id,
                ['name' => $name, 'policy_id' => $policyId, 'description' => $description],
                $auditCtx,
                $name,
            );

            return $id;
        });

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

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

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

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

        if (array_key_exists('name', $body)) {
            // SEC_REVIEW F52: see create() — same strip applied on update.
            $cleaned = self::cleanString($body['name']);
            if ($cleaned === null || $cleaned === '' || strlen($cleaned) > 128) {
                $errors['name'] = 'required, 1–128 chars';
            } else {
                $other = $this->consumers->findByName($cleaned);
                if ($other !== null && $other->id !== $id) {
                    $errors['name'] = 'already exists';
                } else {
                    $fields['name'] = $cleaned;
                }
            }
        }
        if (array_key_exists('description', $body)) {
            if ($body['description'] !== null && !is_string($body['description'])) {
                $errors['description'] = 'must be string or null';
            } else {
                $fields['description'] = is_string($body['description'])
                    ? self::stripControlChars($body['description'])
                    : $body['description'];
            }
        }
        if (array_key_exists('policy_id', $body)) {
            $newPolicy = null;
            if (is_int($body['policy_id'])) {
                $newPolicy = $body['policy_id'];
            } elseif (is_string($body['policy_id']) && ctype_digit($body['policy_id'])) {
                $newPolicy = (int) $body['policy_id'];
            }
            if ($newPolicy === null || $newPolicy <= 0) {
                $errors['policy_id'] = 'must be positive integer';
            } elseif (!$this->consumers->policyExists($newPolicy)) {
                $errors['policy_id'] = 'unknown policy';
            } else {
                $fields['policy_id'] = $newPolicy;
            }
        }
        if (array_key_exists('is_active', $body)) {
            if (!is_bool($body['is_active'])) {
                $errors['is_active'] = 'must be boolean';
            } else {
                $fields['is_active'] = $body['is_active'] ? 1 : 0;
            }
        }
        if (array_key_exists('audit_enabled', $body)) {
            if (!is_bool($body['audit_enabled'])) {
                $errors['audit_enabled'] = 'must be boolean';
            } else {
                $fields['audit_enabled'] = $body['audit_enabled'] ? 1 : 0;
            }
        }

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

        $beforeSnapshot = [
            'name' => $existing->name,
            'description' => $existing->description,
            'policy_id' => $existing->policyId,
            'is_active' => $existing->isActive ? 1 : 0,
            'audit_enabled' => $existing->auditEnabled ? 1 : 0,
        ];

        // SEC_REVIEW F41: dedicated alertable audit signal whenever
        // `audit_enabled` actually flips. The regular `consumer.updated`
        // row also fires (carrying the full field diff for context).
        $auditToggleTo = null;
        if (array_key_exists('audit_enabled', $fields)
            && $fields['audit_enabled'] !== ($existing->auditEnabled ? 1 : 0)
        ) {
            $auditToggleTo = $fields['audit_enabled'] === 1;
        }

        $auditCtx = self::auditContext($request);
        $this->connection->transactional(function () use ($id, $fields, $existing, $beforeSnapshot, $auditCtx, $auditToggleTo): void {
            $this->consumers->update($id, $fields);
            $updatedName = isset($fields['name']) ? (string) $fields['name'] : $existing->name;
            $this->audit->emitOrThrow(
                AuditAction::CONSUMER_UPDATED,
                'consumer',
                $id,
                ['name' => $existing->name, 'changes' => self::diffFields($beforeSnapshot, $fields)],
                $auditCtx,
                $updatedName,
            );
            if ($auditToggleTo !== null) {
                $this->audit->emitOrThrow(
                    AuditAction::CONSUMER_AUDIT_TOGGLED,
                    'consumer',
                    $id,
                    [
                        'name' => $existing->name,
                        'from' => $beforeSnapshot['audit_enabled'] === 1,
                        'to' => $auditToggleTo,
                    ],
                    $auditCtx,
                    $updatedName,
                );
            }
        });

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

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

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

        $auditCtx = self::auditContext($request);
        $this->connection->transactional(function () use ($id, $existing, $auditCtx): void {
            $this->consumers->softDelete($id);
            $this->audit->emitOrThrow(
                AuditAction::CONSUMER_DELETED,
                'consumer',
                $id,
                ['name' => $existing->name, 'soft' => true],
                $auditCtx,
                $existing->name,
            );
        });

        return $response->withStatus(204);
    }

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