<?php

namespace App\Domain\Identity\Services;

use App\Domain\Identity\Models\Person;
use App\Domain\Identity\Models\PortalInvitation;
use App\Domain\Identity\Models\Role;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Helpers\CredentialNormalizer;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

/**
 * Issues and consumes the invitations that give a member a portal account.
 *
 * Two things are load-bearing here and both are about the token:
 *
 *  - Only its SHA-256 is stored. A raw token in a database row is a password
 *    in a database row.
 *  - Consumption is one conditional UPDATE, not read-then-write. Two taps on
 *    the same link — which happens constantly on a phone — would otherwise
 *    both pass a `consumed_at IS NULL` check and both create an account.
 */
class PortalInvitationService
{
    /** Long enough that guessing is not a strategy; expiry is a second line. */
    private const TOKEN_BYTES = 32;

    private const LIFETIME_HOURS = 72;

    /**
     * @return array{invitation: PortalInvitation, token: string}
     *         The raw token is returned exactly once and never stored.
     */
    public function issue(Person $person, User $actor, ?Participant $participant = null, string $channel = 'link', ?string $ip = null): array
    {
        if (! in_array($channel, ['link', 'whatsapp', 'email'], true)) {
            throw new DomainException('قناة إرسال غير معروفة');
        }

        return DB::transaction(function () use ($person, $actor, $participant, $channel, $ip) {
            // One live invitation per person: re-inviting revokes the previous
            // link rather than leaving two working links in the wild. Enforced
            // by a partial unique index as well, so a race cannot beat it.
            PortalInvitation::where('academy_id', $person->academy_id)
                ->where('person_id', $person->id)
                ->whereNull('consumed_at')
                ->whereNull('revoked_at')
                ->update(['revoked_at' => now()]);

            $token = Str::random(8) . bin2hex(random_bytes(self::TOKEN_BYTES));

            $invitation = PortalInvitation::create([
                'academy_id' => $person->academy_id,
                'person_id' => $person->id,
                'participant_id' => $participant?->id,
                'token_hash' => hash('sha256', $token),
                'channel' => $channel,
                'sent_to' => $channel === 'email' ? $person->email : $person->phone,
                'expires_at' => now()->addHours(self::LIFETIME_HOURS),
                'created_by' => $actor->id,
                'created_ip' => $ip,
            ]);

            return ['invitation' => $invitation, 'token' => $token];
        });
    }

    public function revoke(PortalInvitation $invitation): void
    {
        PortalInvitation::where('id', $invitation->id)
            ->whereNull('consumed_at')
            ->whereNull('revoked_at')
            ->update(['revoked_at' => now()]);
    }

    /**
     * Look the token up without consuming it, so the activation form can be
     * shown before a password is typed.
     *
     * Returns null for missing, expired, revoked and already-used alike: the
     * caller must render one message for all four, or the response tells an
     * attacker which tokens exist.
     */
    public function findLive(string $token): ?PortalInvitation
    {
        if ($token === '') {
            return null;
        }

        return PortalInvitation::withoutGlobalScopes()
            ->where('token_hash', hash('sha256', $token))
            ->whereNull('consumed_at')
            ->whereNull('revoked_at')
            ->where('expires_at', '>', now())
            ->first();
    }

    /**
     * Consume the invitation and give the person a portal account.
     *
     * The claim is a conditional UPDATE whose WHERE clause carries every
     * condition — unconsumed, unrevoked, unexpired. Zero affected rows means
     * somebody else got there first, and the second request creates nothing.
     */
    public function activate(string $token, string $password, ?string $email = null): User
    {
        return DB::transaction(function () use ($token, $password, $email) {
            $hash = hash('sha256', $token);

            $invitation = PortalInvitation::withoutGlobalScopes()
                ->where('token_hash', $hash)
                ->lockForUpdate()
                ->first();

            $claimed = PortalInvitation::withoutGlobalScopes()
                ->where('token_hash', $hash)
                ->whereNull('consumed_at')
                ->whereNull('revoked_at')
                ->where('expires_at', '>', now())
                ->update(['consumed_at' => now()]);

            if ($claimed === 0 || ! $invitation) {
                throw new DomainException('رابط التفعيل غير صالح أو انتهت صلاحيته');
            }

            $person = Person::withoutGlobalScopes()->findOrFail($invitation->person_id);
            $user = $this->resolveOrCreateUser($person, $invitation->academy_id, $email);

            $user->password = Hash::make($password);
            $user->status = 'active';
            $user->failed_login_attempts = 0;
            $user->locked_until = null;
            $user->save();

            PortalInvitation::withoutGlobalScopes()
                ->where('id', $invitation->id)
                ->update(['consumed_by' => $user->id]);

            // people.user_id is the canonical link; guardians.user_id is a
            // redundant second one that has to be kept in step.
            if (! $person->user_id) {
                $person->forceFill(['user_id' => $user->id])->save();
            }

            DB::table('guardians')
                ->where('person_id', $person->id)
                ->whereNull('user_id')
                ->update(['user_id' => $user->id]);

            return $user;
        });
    }

    private function resolveOrCreateUser(Person $person, int $academyId, ?string $email): User
    {
        $existing = User::withoutGlobalScopes()
            ->where('academy_id', $academyId)
            ->where(function ($q) use ($person) {
                $q->where('person_id', $person->id);

                if ($person->user_id) {
                    $q->orWhere('id', $person->user_id);
                }
            })
            ->first();

        if ($existing) {
            if ($email && $existing->email_is_synthetic) {
                $this->assignRealEmail($existing, $email);
            }

            $this->ensureMemberRole($existing, $academyId, $person);

            return $existing;
        }

        $user = new User();
        $user->academy_id = $academyId;
        $user->person_id = $person->id;
        $user->name = $person->name ?: $person->name_ar;
        $user->name_ar = $person->name_ar;
        $user->phone = CredentialNormalizer::phone($person->phone);
        $user->status = 'active';

        // users.email is NOT NULL UNIQUE and cannot be relaxed (see the
        // migration's note), and most guardians have no email. A .invalid
        // address is guaranteed by RFC 2606 never to resolve, so it can never
        // bounce off the mail server — and email_is_synthetic is what every
        // mail path checks before trying to send.
        if ($email && ! User::withoutGlobalScopes()->where('email', CredentialNormalizer::email($email))->exists()) {
            $user->email = CredentialNormalizer::email($email);
            $user->email_is_synthetic = false;
        } else {
            $user->email = 'p' . ($person->uuid ?: Str::uuid()) . '@portal.invalid';
            $user->email_is_synthetic = true;
        }

        $user->password = Hash::make(Str::random(40));
        $user->save();

        $this->ensureMemberRole($user, $academyId, $person);

        return $user;
    }

    private function assignRealEmail(User $user, string $email): void
    {
        $normalized = CredentialNormalizer::email($email);

        if (! $normalized) {
            return;
        }

        $taken = User::withoutGlobalScopes()
            ->where('email', $normalized)
            ->where('id', '!=', $user->id)
            ->exists();

        if (! $taken) {
            $user->email = $normalized;
            $user->email_is_synthetic = false;
        }
    }

    /**
     * A member who plays gets `player`; a member who only has children gets
     * `parent`. The distinction matters because the parent role's scope
     * resolves through a Guardian row, and a player has none.
     */
    private function ensureMemberRole(User $user, int $academyId, Person $person): void
    {
        if ($user->role_id) {
            return;
        }

        $playsForHimself = Participant::withoutGlobalScopes()
            ->where('academy_id', $academyId)
            ->where('person_id', $person->id)
            ->exists();

        $slug = $playsForHimself ? 'player' : 'parent';

        $role = Role::withoutGlobalScopes()
            ->where('academy_id', $academyId)
            ->where('slug', $slug)
            ->first();

        if (! $role) {
            return;
        }

        $user->role_id = $role->id;
        $user->save();

        $alreadyAttached = DB::table('role_user')
            ->where('user_id', $user->id)
            ->where('role_id', $role->id)
            ->exists();

        if (! $alreadyAttached) {
            DB::table('role_user')->insert([
                'user_id' => $user->id,
                'role_id' => $role->id,
                'created_at' => now(),
            ]);
        }
    }
}
