<?php

declare(strict_types=1);

namespace apexl\Io\modules\user\services\twoFactorAuth;

use apexl\Config\ConfigurationInterface;
use apexl\Io\dto\LoaderCondition;
use apexl\Io\modules\user\Dtos\TwoFactorSetupDto;
use apexl\Io\modules\user\entities\TwoFactorMethodEntity;
use apexl\Io\modules\user\entities\userEntity;
use apexl\Io\modules\user\enums\TwoFactorMode;
use apexl\Io\modules\user\exceptions\InvalidTwoFactorCodeException;
use apexl\Io\modules\user\exceptions\TwoFactorException;
use apexl\Io\modules\user\exceptions\TwoFactorLockedException;
use DateInterval;
use DateTimeImmutable;
use Throwable;

/**
 * Orchestrates 2FA: mode/enrolment checks, method + secret lifecycle, the login
 * challenge (with brute-force lockout), recovery codes, and admin reset
 * (Plan 007 §5.1). Mirrors V1's TwoFactorAuthService shape rebuilt on V3
 * conventions; the platform mode now comes from config rather than a boolean.
 */
final readonly class TwoFactorAuthService
{
    public function __construct(
        private ConfigurationInterface $config,
        private TwoFactorMethodRegistry $methodRegistry,
        private TwoFactorSecretCipher $secretCipher,
        private RecoveryCodeService $recoveryCodeService,
    ) {}

    public function mode(): TwoFactorMode
    {
        return TwoFactorMode::fromConfigValue($this->config->get('auth.two_factor.mode'));
    }

    /**
     * Is 2FA actually live for this user right now? True only when the user has
     * confirmed 2FA (the flag) AND holds an active method — the belt-and-braces
     * both-must-hold check the enforcement layer keys on.
     */
    public function isEnabledForUser(userEntity $user): bool
    {
        return (bool) $user->two_factor_enabled && $this->hasActiveMethod($user);
    }

    public function hasActiveMethod(userEntity $user): bool
    {
        return $this->activeMethodFor($user) instanceof TwoFactorMethodEntity;
    }

    public function activeMethodFor(userEntity $user): ?TwoFactorMethodEntity
    {
        foreach ($this->methodsFor((int) $user->id) as $method) {
            if ($method->is_active) {
                return $method;
            }
        }

        return null;
    }

    /**
     * Begin (or restart) TOTP enrolment: generate a secret, persist it encrypted
     * on a fresh INACTIVE method row, and return the QR + manual secret for the
     * user to confirm. Any prior unconfirmed attempt is discarded.
     *
     * @throws TwoFactorException when the platform mode is off (no enrolment offered)
     */
    public function beginSetup(userEntity $user, ?string $label = null): TwoFactorSetupDto
    {
        if ($this->mode()->isOff()) {
            throw new TwoFactorException('Two-factor authentication is not enabled on this platform.');
        }

        $method = $this->methodRegistry->default();
        $userId = (int) $user->id;

        // Clear any half-finished (inactive) enrolment so only one pending row
        // ever exists — the completeSetup lookup depends on that.
        $this->deleteInactiveMethods($userId);

        $secret = $method->generateSecret();
        $now = new DateTimeImmutable();

        $entity = new TwoFactorMethodEntity();
        $entity->user_id = $userId;
        $entity->type = $method->getMethodName();
        $entity->is_active = false;
        $entity->failed_attempts = 0;
        $entity->locked_until = null;
        $entity->label = $label;
        $entity->created = $now;
        $entity->modified = $now;
        $this->secretCipher->store($entity, $secret);
        $entity->store();

        $accountLabel = $this->accountLabel($user);

        $this->audit('2fa.setup.begin', $userId);

        return new TwoFactorSetupDto(
            methodId: (string) $entity->id,
            type: $method->getMethodName(),
            secret: $secret,
            provisioningUri: $method->provisioningUri($accountLabel, $secret),
            qrCodeDataUri: $method->qrCodeDataUri($accountLabel, $secret),
        );
    }

    /**
     * Confirm enrolment: verify the first code against the pending method, and
     * only on success activate it, flag the user, and issue recovery codes.
     *
     * @return string[] one-time recovery codes to show ONCE
     * @throws InvalidTwoFactorCodeException|TwoFactorException
     */
    public function completeSetup(userEntity $user, string $code): array
    {
        $userId = (int) $user->id;
        $pending = $this->pendingMethodFor($userId)
            ?? throw new TwoFactorException('No pending two-factor setup to confirm. Start setup first.');

        $method = $this->methodRegistry->get($pending->type);
        $secret = $this->secretCipher->reveal($pending);

        if (!$method->verify($secret, $code, $this->discrepancy())) {
            $this->audit('2fa.setup.failed', $userId);
            throw new InvalidTwoFactorCodeException('That code is not valid. Please try again.');
        }

        $now = new DateTimeImmutable();
        $pending->is_active = true;
        $pending->failed_attempts = 0;
        $pending->locked_until = null;
        $pending->last_used_at = $now;
        $pending->modified = $now;
        $pending->store();

        $user->two_factor_enabled = true;
        if (!$user->two_factor_confirmed_at) {
            // userEntity has no cast for this column (INTEGER), so store a raw
            // unix timestamp rather than a DateTimeImmutable.
            $user->two_factor_confirmed_at = time();
        }
        $user->store();

        $recoveryCodes = $this->recoveryCodeService->regenerateForUser($userId);

        $this->audit('2fa.setup.complete', $userId);

        return $recoveryCodes;
    }

    /**
     * Verify a login-challenge code against the user's active method, applying
     * brute-force lockout.
     *
     * @throws TwoFactorLockedException when the method is (or becomes) locked
     * @throws TwoFactorException when the user has no active method
     */
    public function verifyChallengeCode(userEntity $user, string $code): bool
    {
        $method = $this->activeMethodFor($user)
            ?? throw new TwoFactorException('No active two-factor method for this user.');

        $now = new DateTimeImmutable();

        if ($method->isLocked($now)) {
            $this->audit('2fa.auth.locked', (int) $user->id);
            throw new TwoFactorLockedException('Too many attempts. Try again later.');
        }

        $secret = $this->secretCipher->reveal($method);

        if ($this->methodRegistry->get($method->type)->verify($secret, $code, $this->discrepancy())) {
            $method->failed_attempts = 0;
            $method->locked_until = null;
            $method->last_used_at = $now;
            $method->modified = $now;
            $method->store();

            $this->audit('2fa.auth.success', (int) $user->id);

            return true;
        }

        $this->registerFailure($method, $now, (int) $user->id);

        return false;
    }

    /**
     * Consume a recovery code (single-use) as an alternative to the TOTP code.
     */
    public function consumeRecoveryCode(userEntity $user, string $code): bool
    {
        $ok = $this->recoveryCodeService->consume((int) $user->id, $code);
        $this->audit($ok ? '2fa.recovery.success' : '2fa.recovery.failed', (int) $user->id);

        return $ok;
    }

    /**
     * Admin-assisted reset (Plan 007 §4): remove all of a user's methods +
     * recovery codes and clear their flags, forcing re-enrolment. The backstop
     * for a locked-out user in enforced mode.
     */
    public function reset(userEntity $target, ?int $actorId = null): void
    {
        $userId = (int) $target->id;

        foreach ($this->methodsFor($userId) as $method) {
            $method->delete();
        }
        $this->recoveryCodeService->deleteForUser($userId);

        $target->two_factor_enabled = false;
        $target->two_factor_confirmed_at = null;
        $target->store();

        $this->audit('2fa.admin.reset', $userId, ['actor' => $actorId]);
    }

    private function registerFailure(TwoFactorMethodEntity $method, DateTimeImmutable $now, int $userId): void
    {
        $method->failed_attempts = (int) $method->failed_attempts + 1;
        $method->modified = $now;

        $maxAttempts = max(1, (int) $this->config->get('auth.two_factor.lockout_max_attempts'));
        if ($method->failed_attempts >= $maxAttempts) {
            $minutes = max(1, (int) $this->config->get('auth.two_factor.lockout_minutes'));
            $method->locked_until = $now->add(new DateInterval(sprintf('PT%dM', $minutes)));
            $method->failed_attempts = 0; // reset the counter for the next window
            $method->store();

            $this->audit('2fa.auth.locked', $userId);

            throw new TwoFactorLockedException('Too many attempts. Try again later.');
        }

        $method->store();
        $this->audit('2fa.auth.failed', $userId);
    }

    private function pendingMethodFor(int $userId): ?TwoFactorMethodEntity
    {
        $pending = null;
        foreach ($this->methodsFor($userId) as $method) {
            if (!$method->is_active) {
                $pending = $method; // last (most recent) pending row wins
            }
        }

        return $pending;
    }

    private function deleteInactiveMethods(int $userId): void
    {
        foreach ($this->methodsFor($userId) as $method) {
            if (!$method->is_active) {
                $method->delete();
            }
        }
    }

    /**
     * @return TwoFactorMethodEntity[]
     */
    private function methodsFor(int $userId): array
    {
        return TwoFactorMethodEntity::findMultiple(
            [LoaderCondition::equals('user_id', $userId)],
            limit: 200,
        )->toArray();
    }

    private function discrepancy(): int
    {
        return max(0, (int) $this->config->get('auth.two_factor.verify_discrepancy'));
    }

    private function accountLabel(userEntity $user): string
    {
        return (string) ($user->email ?: sprintf('user-%s', $user->id));
    }

    /**
     * Structured audit trail (as V1 kept). Best-effort: never let a logging
     * failure break the auth flow, and degrade gracefully if io-logger is
     * absent in a given consumer.
     */
    private function audit(string $event, int $userId, array $context = []): void
    {
        if (!function_exists('logger')) {
            return;
        }

        try {
            logger()->info($event, ['user_id' => $userId] + $context);
        } catch (Throwable) {
            // Auditing is best-effort; swallow.
        }
    }
}
