<?php

declare(strict_types=1);

namespace App\Application\Admin;

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

/**
 * Admin CRUD over reporters. Validation is hand-rolled — small surface,
 * no need for a third-party validator. Errors flow through the uniform
 * `validation_failed` envelope per the M04 task.
 *
 * Soft delete (DELETE) flips `is_active=false`. Hard delete is refused
 * with 409 if reports exist; otherwise the row is removed (a stale
 * unused reporter is fine to drop).
 */
final class ReportersController
{
    use AdminControllerSupport;

    public function __construct(
        private readonly ReporterRepository $reporters,
        private readonly AuditEmitter $audit,
        private readonly Connection $connection,
    ) {
    }

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

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

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

        return self::json($response, 200, $reporter->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` to defeat log-injection and terminal-
        // escape attacks on log viewers.
        $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;
            }
        }

        $trustWeight = 1.0;
        if (array_key_exists('trust_weight', $body)) {
            if (!is_numeric($body['trust_weight'])) {
                $errors['trust_weight'] = 'must be numeric';
            } else {
                $trustWeight = (float) $body['trust_weight'];
                if ($trustWeight < 0.0 || $trustWeight > 2.0) {
                    $errors['trust_weight'] = 'must be between 0.0 and 2.0';
                }
            }
        }

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

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

        $userId = self::actingUserId($request);
        $auditCtx = self::auditContext($request);
        $id = $this->connection->transactional(function () use ($name, $description, $trustWeight, $userId, $auditCtx): int {
            $id = $this->reporters->create($name, $description, $trustWeight, $userId);
            $this->audit->emitOrThrow(
                AuditAction::REPORTER_CREATED,
                'reporter',
                $id,
                ['name' => $name, 'trust_weight' => $trustWeight, 'description' => $description],
                $auditCtx,
                $name,
            );

            return $id;
        });

        $created = $this->reporters->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->reporters->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->reporters->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'])
                    : null;
            }
        }
        if (array_key_exists('trust_weight', $body)) {
            if (!is_numeric($body['trust_weight'])) {
                $errors['trust_weight'] = 'must be numeric';
            } else {
                $weight = (float) $body['trust_weight'];
                if ($weight < 0.0 || $weight > 2.0) {
                    $errors['trust_weight'] = 'must be between 0.0 and 2.0';
                } else {
                    $fields['trust_weight'] = number_format($weight, 2, '.', '');
                }
            }
        }
        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,
            'trust_weight' => number_format($existing->trustWeight, 2, '.', ''),
            '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 `reporter.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->reporters->update($id, $fields);
            $updatedName = isset($fields['name']) ? (string) $fields['name'] : $existing->name;
            $this->audit->emitOrThrow(
                AuditAction::REPORTER_UPDATED,
                'reporter',
                $id,
                ['name' => $existing->name, 'changes' => self::diffFields($beforeSnapshot, $fields)],
                $auditCtx,
                $updatedName,
            );
            if ($auditToggleTo !== null) {
                $this->audit->emitOrThrow(
                    AuditAction::REPORTER_AUDIT_TOGGLED,
                    'reporter',
                    $id,
                    [
                        'name' => $existing->name,
                        'from' => $beforeSnapshot['audit_enabled'] === 1,
                        'to' => $auditToggleTo,
                    ],
                    $auditCtx,
                    $updatedName,
                );
            }
        });

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

        $auditCtx = self::auditContext($request);

        if ($this->reporters->reportCount($id) > 0) {
            // SPEC: refuse hard delete when reports exist; flip to inactive.
            $this->connection->transactional(function () use ($id, $existing, $auditCtx): void {
                $this->reporters->softDelete($id);
                $this->audit->emitOrThrow(
                    AuditAction::REPORTER_DELETED,
                    'reporter',
                    $id,
                    ['name' => $existing->name, 'soft' => true, 'reason' => 'has_reports'],
                    $auditCtx,
                    $existing->name,
                );
            });

            return self::json($response, 409, [
                'error' => 'has_reports',
                'message' => 'Reporter has historical reports; flagged inactive instead of deleted.',
            ]);
        }

        $this->connection->transactional(function () use ($id, $existing, $auditCtx): void {
            $this->reporters->softDelete($id);
            $this->audit->emitOrThrow(
                AuditAction::REPORTER_DELETED,
                'reporter',
                $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;
    }
}
