<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Controllers;

use App\Auth\SessionGuard;
use App\Http\Request;
use App\Http\Response;
use App\Http\View;
use App\Repositories\UserRepository;
use App\Services\AuditLogger;
use PDO;
use Throwable;

/**
 * /users — admin-only page for promoting / demoting admin status, and for
 * tombstoning / restoring users (R01-N23 — privacy / right-to-be-forgotten).
 *
 * Guardrails (server-side, all surface as ?error= codes):
 *   - You cannot demote yourself.
 *   - You cannot demote the last remaining admin.
 *   - You cannot tombstone yourself.
 *   - You cannot tombstone the last remaining admin (tombstoning forces
 *     is_admin=0, so it's a demotion in disguise).
 *
 * No delete — users are never deleted; they'd orphan audit rows that
 * reference them by id. Tombstoning is the soft alternative: the row stays,
 * but the live UI hides email + display name behind `(former user)`.
 * Audit rows keep the original email verbatim — the trail is the
 * authoritative record and erasure laws permit retention for legal /
 * security purposes.
 */
final class UserController
{
    public function __construct(
        private readonly PDO            $pdo,
        private readonly UserRepository $users,
        private readonly AuditLogger    $audit,
        private readonly View           $view,
    ) {
    }

    /** GET /users */
    public function index(Request $req): Response
    {
        $actor = SessionGuard::requireAdmin($this->users);
        if ($actor instanceof Response) {
            return $actor;
        }

        return Response::html($this->view->render('users/index', [
            'title'       => 'Users',
            'currentUser' => $actor,
            'csrfToken'   => SessionGuard::csrfToken(),
            'users'       => $this->users->all(),
            'flash'       => $req->queryString('flash'),
            'error'       => $req->queryString('error'),
        ]));
    }

    /** POST /users/{id} — form — toggle is_admin. */
    public function update(Request $req, array $params): Response
    {
        $actor = SessionGuard::requireAdminForm($req, $this->users);
        if ($actor instanceof Response) {
            return $actor;
        }

        $id = (int) $params['id'];
        $target = $this->users->find($id);
        if ($target === null) {
            return Response::redirect('/users?error=not_found');
        }

        // Checkbox is absent from $_POST when unchecked.
        $targetIsAdmin = isset($req->post['is_admin'])
            && (string) $req->post['is_admin'] !== '0';

        if ($targetIsAdmin === $target->isAdmin) {
            return Response::redirect('/users?flash=noop');
        }

        $error = self::demoteGuardrail(
            actorId:      $actor->id,
            targetId:     $target->id,
            targetWasAdmin: $target->isAdmin,
            targetWillBeAdmin: $targetIsAdmin,
            totalAdmins:  $this->users->countAdmins(),
        );
        if ($error !== null) {
            return Response::redirect('/users?error=' . $error);
        }

        $this->pdo->beginTransaction();
        try {
            $result = $this->users->setAdmin($id, $targetIsAdmin);
            $this->audit->recordForRequest(
                'UPDATE', 'user', $id,
                $result['before']->toAuditSnapshot(),
                $result['after']->toAuditSnapshot(),
                $req, $actor,
            );
            $this->pdo->commit();
        } catch (Throwable) {
            $this->pdo->rollBack();
            return Response::redirect('/users?error=db_error');
        }

        return Response::redirect('/users?flash=' . ($targetIsAdmin ? 'promoted' : 'demoted'));
    }

    /**
     * Pure guardrail check for admin demotions. Returns null when the action
     * is allowed, or a short error code for the redirect query string.
     *
     *  self_demote  — the actor is trying to demote themselves
     *  last_admin   — demoting the only remaining admin
     *
     * Extracted so tests can hit the rules without SessionGuard/PDO setup.
     */
    public static function demoteGuardrail(
        int $actorId,
        int $targetId,
        bool $targetWasAdmin,
        bool $targetWillBeAdmin,
        int $totalAdmins,
    ): ?string {
        // Only enforce when the change is an admin demotion.
        if (!$targetWasAdmin || $targetWillBeAdmin) {
            return null;
        }
        if ($targetId === $actorId) {
            return 'self_demote';
        }
        if ($totalAdmins <= 1) {
            return 'last_admin';
        }
        return null;
    }

    /**
     * Pure guardrail check for tombstone toggles (R01-N23).
     *
     *  self_tombstone — the actor is trying to tombstone themselves
     *  last_admin     — tombstoning forces is_admin=0; if the target is
     *                   currently the only remaining admin, this would
     *                   strand the org without one
     *  noop           — already in the requested state
     *
     * Returns null when the action is allowed.
     */
    public static function tombstoneGuardrail(
        int $actorId,
        int $targetId,
        bool $targetIsTombstoned,
        bool $targetIsAdmin,
        bool $tombstone,
        int $totalAdmins,
    ): ?string {
        if ($tombstone === $targetIsTombstoned) {
            return 'noop';
        }
        if ($tombstone && $targetId === $actorId) {
            return 'self_tombstone';
        }
        if ($tombstone && $targetIsAdmin && $totalAdmins <= 1) {
            return 'last_admin';
        }
        return null;
    }

    /**
     * POST /users/{id}/tombstone — toggle tombstoned_at via a hidden
     * `action` field (`tombstone` or `restore`). The form lives in
     * `views/users/index.twig`.
     */
    public function tombstone(Request $req, array $params): Response
    {
        $actor = SessionGuard::requireAdminForm($req, $this->users);
        if ($actor instanceof Response) {
            return $actor;
        }

        $id = (int) $params['id'];
        $target = $this->users->find($id);
        if ($target === null) {
            return Response::redirect('/users?error=not_found');
        }

        $action = $req->postString('action');
        if ($action !== 'tombstone' && $action !== 'restore') {
            return Response::redirect('/users?error=bad_action');
        }
        $tombstone = ($action === 'tombstone');

        $err = self::tombstoneGuardrail(
            actorId:           $actor->id,
            targetId:          $target->id,
            targetIsTombstoned: $target->isTombstoned(),
            targetIsAdmin:     $target->isAdmin,
            tombstone:         $tombstone,
            totalAdmins:       $this->users->countAdmins(),
        );
        if ($err === 'noop') {
            return Response::redirect('/users?flash=noop');
        }
        if ($err !== null) {
            return Response::redirect('/users?error=' . $err);
        }

        $this->pdo->beginTransaction();
        try {
            $result = $this->users->setTombstoned($id, $tombstone);
            $this->audit->recordForRequest(
                $tombstone ? 'TOMBSTONE' : 'RESTORE',
                'user',
                $id,
                $result['before']->toAuditSnapshot(),
                $result['after']->toAuditSnapshot(),
                $req,
                $actor,
            );
            $this->pdo->commit();
        } catch (Throwable) {
            $this->pdo->rollBack();
            return Response::redirect('/users?error=db_error');
        }

        return Response::redirect('/users?flash=' . ($tombstone ? 'tombstoned' : 'restored'));
    }
}
