<?php

namespace App\Console\Commands;

use App\Mail\DormantAccountWarningMail;
use App\Models\User;
use App\Models\UserDevice;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;

/**
 * A confirmed account that has never had a package, removed after a year.
 *
 * The other sweep (PruneUnverifiedAccounts) is about registrations nobody
 * confirmed and takes five days. This one is about the opposite case: somebody
 * confirmed their address, looked around, never bought anything, and never came
 * back. A year later there is a login, an address and a password hash on file
 * for no purpose at all — which is a data-protection question, not tidiness.
 *
 * ## What "no package" means, and why it is drawn this wide
 *
 * No instance, no contract, no order, no invoice — ever, not merely "not now".
 * A customer who cancelled last month has invoices, and invoices have to be kept
 * for seven years (§ 132 BAO). Their login is part of how they reach those
 * invoices, so nothing with commercial history is touched here. Both links
 * between `users` and `customers` count, by id and by address, for the same
 * reason as in the other sweep.
 *
 * ## The clock is reset by signing in
 *
 * Measured from the LATEST of the registration and the last time any device was
 * seen (`user_devices.last_seen_at`) — so a person who signs in once a year
 * keeps their account, which is what the terms promise. There is no
 * `last_login_at` on users, and the device rows already carry exactly this.
 *
 * ## Nobody is deleted without being told
 *
 * A warning goes out a fortnight before, once, and `dormant_warned_at` records
 * it. An account is only removed when that warning is on file and old enough —
 * so a run that has never warned anybody deletes nobody, and a mail server that
 * was down that night delays the deletion instead of skipping the warning.
 */
class PruneDormantAccounts extends Command
{
    /** How long an account with no package is kept. */
    public const AFTER_DAYS = 365;

    /** How long before that the warning goes out. */
    public const WARN_DAYS_BEFORE = 14;

    protected $signature = 'clupilot:prune-dormant {--dry-run : Only say what would happen}';

    protected $description = 'Warn, then delete confirmed accounts that never had a package';

    public function handle(): int
    {
        $dry = (bool) $this->option('dry-run');

        $warned = $this->sendWarnings($dry);
        $removed = $this->remove($dry);

        $this->info($dry
            ? "would warn {$warned}, would remove {$removed}."
            : "{$warned} warned, {$removed} removed.");

        return self::SUCCESS;
    }

    /**
     * The fortnight's notice.
     *
     * Not called warn(): Command::warn() is the framework's "print a warning
     * line" and overriding it with something private breaks the class outright.
     */
    private function sendWarnings(bool $dry): int
    {
        $due = $this->dormant()
            ->whereNull('dormant_warned_at')
            ->get()
            ->filter(fn (User $user) => $this->dormantSince($user)
                ->lte(now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE)));

        foreach ($due as $user) {
            if ($dry) {
                $this->line('would warn: '.$user->email);

                continue;
            }

            try {
                $mail = new DormantAccountWarningMail($user, self::WARN_DAYS_BEFORE);
                Mail::mailer($mail->mailer)->to($user->email)->queue($mail);
            } catch (Throwable $e) {
                // Not stamped: the stamp is what permits the deletion, and
                // stamping a mail that never went out would delete an account
                // whose owner was never told.
                Log::warning('Could not warn a dormant account', [
                    'email' => $user->email,
                    'exception' => $e->getMessage(),
                ]);

                continue;
            }

            $user->forceFill(['dormant_warned_at' => now()])->save();
        }

        return $due->count();
    }

    /** The deletion itself, a fortnight after the warning at the earliest. */
    private function remove(bool $dry): int
    {
        $due = $this->dormant()
            ->whereNotNull('dormant_warned_at')
            ->where('dormant_warned_at', '<=', now()->subDays(self::WARN_DAYS_BEFORE))
            ->get()
            ->filter(fn (User $user) => $this->dormantSince($user)
                ->lte(now()->subDays(self::AFTER_DAYS)));

        foreach ($due as $user) {
            if ($dry) {
                $this->line('would remove: '.$user->email.' (dormant since '.$this->dormantSince($user)->toDateString().')');

                continue;
            }

            // Logged individually rather than as a count: an address that
            // disappears is the kind of thing somebody asks about later, and a
            // number in a log answers nothing.
            Log::info('Removing a dormant account', [
                'email' => $user->email,
                'registered_at' => $user->created_at?->toIso8601String(),
                'warned_at' => $user->dormant_warned_at?->toIso8601String(),
            ]);

            $user->delete();
        }

        return $due->count();
    }

    /**
     * Confirmed, and with no customer record behind it at all.
     *
     * Drawn at the customer record rather than at "has no live package on
     * record", and deliberately wider than it has to be:
     *
     *  - A customer record means somebody in the business knows this person —
     *    an operator entered them, or a checkout created them. Removing the
     *    login of a record we keep is half a deletion.
     *  - Anything commercial (an order, a contract, an instance, an invoice)
     *    hangs off a customer record, and invoices have to be kept for seven
     *    years (§ 132 BAO). Skipping every customer record skips all of that
     *    without having to enumerate it and without a new table sneaking past
     *    the enumeration later.
     *
     * What is left is exactly the case this exists for: somebody registered,
     * confirmed their address, never bought anything, and never came back.
     * Registration alone creates no customer record.
     *
     * Both links count, by id and by address — see Customer::emailTaken for why
     * one can exist without the other.
     *
     * @return \Illuminate\Database\Eloquent\Builder<User>
     */
    private function dormant()
    {
        return User::query()
            ->whereNotNull('email_verified_at')
            // Old enough to be worth looking at. The precise cut-off is applied
            // per user afterwards, because it depends on the devices — and this
            // one is <=, not <: a timestamp exactly at the threshold is stored to
            // the second, and a stricter pre-filter would drop the very account
            // the per-user check was about to catch.
            ->where('created_at', '<=', now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE))
            ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
                ->whereColumn('customers.user_id', 'users.id'))
            ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
                ->whereColumn('customers.email', 'users.email'));
    }

    /**
     * Since when this account has been doing nothing.
     *
     * The later of the registration and the last time any of its devices was
     * seen: signing in resets the clock, which is what the terms say.
     */
    private function dormantSince(User $user): Carbon
    {
        // Scoped by guard as well as by id: operator 1 is not customer 1 (R21),
        // and there is no devices() relation on User to hide that behind.
        $lastSeen = UserDevice::query()
            ->where('guard', 'web')
            ->where('authenticatable_id', $user->id)
            ->max('last_seen_at');

        $since = $user->created_at ?? now();

        return $lastSeen === null
            ? $since
            : Carbon::parse($lastSeen)->max($since);
    }
}
