<?php

declare(strict_types=1);

namespace App\Services\Auth;

use App\Mail\EmailVerificationCodeMail;
use App\Models\EmailVerification;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Captcha\CaptchaVerifier;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;

/**
 * Оркестрация самозаписи (G1/SP1): register / confirm / resend.
 *
 * Все обращения к tenants/users/email_verifications — через BYPASSRLS-подключение
 * pgsql_supplier: публичные роуты не выставляют app.current_tenant_id, и под RLS
 * (роль crm_app_user) SELECT/INSERT по этим таблицам не прошёл бы.
 *
 * Гонка дублей: в схеме нет глобального UNIQUE(users.email) (только
 * UNIQUE(tenant_id,email)), поэтому «проверка-потом-вставка» сериализуется
 * advisory-локом по email внутри транзакции — два параллельных register на один
 * новый email не создадут два тенанта (лок снимается на commit/rollback).
 */
class RegistrationService
{
    private const DB_CONNECTION = 'pgsql_supplier';

    private const CODE_TTL_MINUTES = 15;

    private const MAX_FAILED_ATTEMPTS = 5;

    private const START_BALANCE_RUB = '300.00';

    public function __construct(private readonly CaptchaVerifier $captcha) {}

    /**
     * @return array{status:string, user:User, verification:EmailVerification, dev_code:?string}
     */
    public function register(string $email, string $password, ?string $captchaToken, ?string $ip): array
    {
        if (! $this->captcha->verify($captchaToken, $ip)) {
            throw new RegistrationException('captcha_failed');
        }

        $email = mb_strtolower(trim($email));
        $conn = DB::connection(self::DB_CONNECTION);

        // Сериализация одновременных регистраций одного email (TOCTOU-защита, см. docblock).
        // Письмо отправляем ПОСЛЕ commit — не держим SMTP внутри транзакции.
        $issued = $this->atomic(function () use ($conn, $email, $password) {
            $conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]);

            $existing = User::on(self::DB_CONNECTION)->where('email', $email)->first();
            if ($existing && $existing->is_active) {
                throw new RegistrationException('email_taken');
            }

            $user = $existing ?: $this->createPendingTenantOwner($email, $password);

            return $this->createCodeRecord($user);
        });

        $this->sendCode($issued['user']->email, $issued['plain']);

        return [
            'status' => 'pending_email_confirm',
            'user' => $issued['user'],
            'verification' => $issued['record'],
            'dev_code' => $issued['dev_code'],
        ];
    }

    public function confirm(string $email, string $code): User
    {
        $email = mb_strtolower(trim($email));
        $user = User::on(self::DB_CONNECTION)->where('email', $email)->first();
        if (! $user) {
            throw new RegistrationException('not_found');
        }

        $record = EmailVerification::on(self::DB_CONNECTION)
            ->where('user_id', $user->id)
            ->whereNull('verified_at')
            ->orderByDesc('id')
            ->first();

        if (! $record || ! $record->isUsable()) {
            $reason = $record === null ? 'not_found'
                : ($record->isExpired() ? 'expired' : 'too_many_attempts');
            throw new RegistrationException($reason);
        }

        if (! Hash::check($code, (string) $record->code_hash)) {
            // increment ВНЕ транзакции: счётчик должен пережить 422 (откат сбросил
            // бы failed_attempts и сломал лимит 5 попыток).
            $record->increment('failed_attempts');
            throw new RegistrationException(
                'invalid_code',
                max(0, self::MAX_FAILED_ATTEMPTS - $record->failed_attempts),
            );
        }

        // Успех — атомарно: пометка кода + активация владельца + статус/баланс тенанта.
        $this->atomic(function () use ($record, $user): void {
            $record->update(['verified_at' => now()]);
            // FN-3 (приёмка 22.06): email подтверждён кодом из письма — фиксируем
            // факт верификации, иначе users.email_verified_at оставался NULL.
            // forceFill: email_verified_at вне $fillable (guarded), mass-assign его гасит.
            $user->forceFill(['is_active' => true, 'email_verified_at' => now()])->save();
            Tenant::on(self::DB_CONNECTION)->where('id', $user->tenant_id)->update([
                'status' => 'active',
                'balance_rub' => self::START_BALANCE_RUB,
            ]);
        });

        return $user->fresh();
    }

    /** @return ?string dev-код (только local/testing), иначе null. Anti-enumeration: тихо для active/missing. */
    public function resend(string $email): ?string
    {
        $email = mb_strtolower(trim($email));
        $conn = DB::connection(self::DB_CONNECTION);

        $issued = $this->atomic(function () use ($conn, $email) {
            $conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]);

            $user = User::on(self::DB_CONNECTION)->where('email', $email)->first();
            if ($user && ! $user->is_active) {
                return $this->createCodeRecord($user);
            }

            return null;
        });

        if ($issued === null) {
            return null;
        }

        $this->sendCode($issued['user']->email, $issued['plain']);

        return $issued['dev_code'];
    }

    /**
     * Выполнить $work атомарно на pgsql_supplier.
     *
     * Прод-путь: соединение НЕ в транзакции → открываем свою (`transaction()`),
     * advisory xact-lock держится до commit/rollback — корректная сериализация.
     *
     * Если PDO УЖЕ в транзакции (внешний caller обернул нас ИЛИ тест-харнес
     * SharesSupplierPdo делит уже-открытый PDO под DatabaseTransactions) —
     * участвуем в существующей транзакции без вложенного beginTransaction:
     * pgsql_supplier-connection не отслеживает уровень внешней транзакции, и
     * `transaction()` попытался бы `PDO::beginTransaction()` поверх открытой →
     * «There is already an active transaction». Это nested-transaction-safety,
     * не тест-специфичная ветка: повторный вызов внутри открытой транзакции
     * корректно переиспользует её.
     *
     * @template T
     *
     * @param  callable():T  $work
     * @return T
     */
    private function atomic(callable $work): mixed
    {
        $conn = DB::connection(self::DB_CONNECTION);

        if ($conn->getPdo()->inTransaction()) {
            return $work();
        }

        return $conn->transaction($work);
    }

    private function createPendingTenantOwner(string $email, string $password): User
    {
        $tenant = Tenant::on(self::DB_CONNECTION)->create([
            'subdomain' => $this->generateSubdomain($email),
            'organization_name' => $email,   // плейсхолдер; уточняется в SP2 (реквизиты)
            'contact_email' => $email,
            'balance_rub' => 0,
            'is_trial' => true,
        ]);
        // tenants.status НЕ в $fillable модели Tenant (колонка DEFAULT 'active') —
        // выставляем явно, минуя mass-assignment; иначе самозапись активировала бы
        // тенанта до подтверждения почты (баг: tenant создавался 'active').
        $tenant->status = 'pending_email_confirm';
        $tenant->save();

        return User::on(self::DB_CONNECTION)->create([
            'tenant_id' => $tenant->id,
            'email' => $email,
            'password_hash' => Hash::make($password),
            // §6: не подставляем фейковое «Новый клиент» — приветствие падает на
            // нейтральное «коллега», аватар/имя берутся из email; настоящее имя
            // клиент укажет в профиле (PATCH /me требует непустое имя).
            'first_name' => '',
            'last_name' => '',
            'is_active' => false,
            'totp_enabled' => false,
        ]);
    }

    /**
     * @return array{user:User, record:EmailVerification, plain:string, dev_code:?string}
     */
    private function createCodeRecord(User $user): array
    {
        // Гасим прежние непогашенные коды этого пользователя (делаем неюзабельными).
        EmailVerification::on(self::DB_CONNECTION)
            ->where('user_id', $user->id)
            ->whereNull('verified_at')
            ->update(['failed_attempts' => self::MAX_FAILED_ATTEMPTS]);

        $plain = (string) random_int(100_000, 999_999);

        $record = EmailVerification::on(self::DB_CONNECTION)->create([
            'user_id' => $user->id,
            'email' => $user->email,
            'token' => (string) Str::uuid(),
            'code_hash' => Hash::make($plain),
            'failed_attempts' => 0,
            'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES),
        ]);

        return [
            'user' => $user,
            'record' => $record,
            'plain' => $plain,
            'dev_code' => app()->environment('local', 'testing') ? $plain : null,
        ];
    }

    private function sendCode(string $email, string $plain): void
    {
        // Письмо ставим в очередь (не держим SMTP в HTTP-пути) и НЕ валим самозапись
        // при сбое доставки: запись кода уже создана, клиент может «отправить повторно».
        // Email в лог не пишем (ПДн §5.2) — только факт и текст ошибки.
        try {
            Mail::to($email)->queue(new EmailVerificationCodeMail($plain, $email));
        } catch (\Throwable $e) {
            Log::warning('register: не удалось поставить письмо с кодом в очередь: '.$e->getMessage());
        }
    }

    private function generateSubdomain(string $email): string
    {
        $base = Str::of($email)->before('@')->lower()->replaceMatches('/[^a-z0-9]/', '')->value();
        if ($base === '') {
            $base = 'client';
        }
        $base = Str::limit($base, 50, '');

        $candidate = $base;
        $i = 0;
        while (Tenant::on(self::DB_CONNECTION)->where('subdomain', $candidate)->exists()) {
            $i++;
            $candidate = $base.$i;
        }

        return Str::limit($candidate, 63, '');
    }
}
