<?php

namespace App\Console\Commands;

use App\Models\MediaFile;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\AuditLogger;
use App\Services\MediaStorage;
use Illuminate\Console\Command;

/**
 * Sweep tenant rows that have no users left. Catches state where a user
 * was deleted via a path that bypassed `UsersController::purgeUser` —
 * Playwright tests that exit mid-run, raw `User::find()->delete()` from
 * tinker, manual SQL, etc. Each orphan tenant carries its own projects /
 * screens / media which would otherwise sit unowned forever.
 *
 * Why safe: every real signup attaches a user to its tenant in a
 * transaction, and the admin "Delete user" flow deletes the tenant atomically
 * via `purgeUser`. A tenant with zero users older than the safety window
 * (default 7 days) cannot have arrived at that state through any normal
 * happy-path code — it's always a bypassed cleanup.
 *
 * Cleanup mirrors `purgeUser`'s storage half:
 *   1. Walk media_files for every project under the tenant, delete via
 *      MediaStorage so cloud-stored bytes are removed too.
 *   2. `$tenant->delete()` — FK cascade nukes projects → screens.
 *
 * Self-hosted installs have a single `tenant_id=null` user/project graph,
 * so this command is a no-op there: no tenant rows means nothing to sweep.
 */
class TenantsPurgeOrphansCommand extends Command
{
    protected $signature = 'tenants:purge-orphans
                            {--dry-run : Print what would be deleted without writing}
                            {--days=7 : Skip tenants newer than N days (safety window)}
                            {--max-time=55 : Stop after N seconds — keeps the run under shared-hosting exec limits}';

    protected $description = 'Delete tenants that have no users left, plus their projects + media.';

    public function handle(): int
    {
        $dryRun = (bool) $this->option('dry-run');
        $days = max(0, (int) $this->option('days'));
        $maxTime = max(5, (int) $this->option('max-time'));

        @set_time_limit($maxTime + 5);
        $deadline = microtime(true) + $maxTime;
        $cutoff = now()->subDays($days);

        $stats = [
            'tenants_purged' => 0,
            'projects_purged' => 0,
            'media_deleted' => 0,
            'errors' => 0,
            'bytes_freed' => 0,
        ];
        $stoppedEarly = false;

        // Stream candidates in id order. cursor() keeps memory bounded for
        // installs where Playwright cruft has stacked up.
        // Orphaned = NO pivot members AND no active-pointer users. The
        // `tenant_user` pivot is the membership source of truth; `users` (the
        // `users.tenant_id` active-workspace pointer) is NOT membership. An
        // invited member who switches their active workspace to another team
        // still OWNS their personal workspace via the pivot — checking only
        // `users` wrongly flagged it orphaned and the sweep deleted it (incl.
        // its projects + media). Requiring `members` to also be empty fixes it.
        $candidates = Tenant::query()
            ->whereDoesntHave('members')
            ->whereDoesntHave('users')
            ->where(function ($q) use ($cutoff) {
                $q->where('created_at', '<', $cutoff)
                    ->orWhereNull('created_at');
            })
            ->orderBy('id');

        foreach ($candidates->cursor() as $tenant) {
            if (microtime(true) >= $deadline) {
                $stoppedEarly = true;
                break;
            }

            $projectIds = Project::query()
                ->where('tenant_id', $tenant->id)
                ->pluck('id')
                ->all();
            $mediaCount = $projectIds !== []
                ? MediaFile::query()
                    ->where('owner_type', 'project')
                    ->whereIn('owner_id', $projectIds)
                    ->count()
                : 0;

            if ($dryRun) {
                $this->line(sprintf(
                    '[dry-run] would purge: tenant#%d "%s" — %d project(s), %d media, created=%s',
                    $tenant->id,
                    (string) $tenant->name,
                    count($projectIds),
                    $mediaCount,
                    $tenant->created_at?->toDateString() ?? 'unknown',
                ));
                $stats['tenants_purged']++;
                $stats['projects_purged'] += count($projectIds);
                $stats['media_deleted'] += $mediaCount;

                continue;
            }

            try {
                $bytesFreed = $this->purgeTenant($tenant, $projectIds);
                $stats['tenants_purged']++;
                $stats['projects_purged'] += count($projectIds);
                $stats['media_deleted'] += $mediaCount;
                $stats['bytes_freed'] += $bytesFreed;
            } catch (\Throwable $e) {
                $stats['errors']++;
                $this->warn(sprintf(
                    'tenant#%d — %s',
                    $tenant->id,
                    $e->getMessage(),
                ));
            }
        }

        $this->newLine();
        $this->line(sprintf('Tenants purged: %d', $stats['tenants_purged']));
        $this->line(sprintf('Projects purged: %d', $stats['projects_purged']));
        $this->line(sprintf('Media deleted: %d', $stats['media_deleted']));
        if (! $dryRun) {
            $this->line(sprintf('Bytes freed: %s', self::formatBytes($stats['bytes_freed'])));
            $this->line(sprintf('Errors: %d', $stats['errors']));
        }
        if ($stoppedEarly) {
            $this->warn('Stopped at deadline — re-run to continue.');
        }

        return self::SUCCESS;
    }

    /**
     * Storage cleanup mirrors `UsersController::purgeUser`. Returns the
     * total size_bytes of project media we just dropped.
     *
     * @param  array<int, int>  $projectIds
     */
    private function purgeTenant(Tenant $tenant, array $projectIds): int
    {
        $bytesFreed = 0;

        // Per-project media via the registry. Batch envelope collapses N
        // audit rows into one summary line.
        if ($projectIds !== []) {
            MediaStorage::beginBatch('media.tenant.purge');
            try {
                MediaFile::query()
                    ->where('owner_type', 'project')
                    ->whereIn('owner_id', $projectIds)
                    ->get()
                    ->each(function (MediaFile $row) use (&$bytesFreed): void {
                        $bytesFreed += (int) $row->size_bytes;
                        MediaStorage::delete($row->path);
                    });
            } finally {
                MediaStorage::endBatch("Purged orphan tenant #{$tenant->id} project media");
            }
        }

        // Tenant goes last; FK cascades nuke projects → screens.
        $tenantId = $tenant->id;
        $tenantName = (string) $tenant->name;
        $tenant->delete();

        AuditLogger::record(
            'tenant.purgeOrphan',
            null,
            "Purged orphan tenant #{$tenantId} \"{$tenantName}\"",
            [
                'tenant_id' => $tenantId,
                'tenant_name' => $tenantName,
                'project_count' => count($projectIds),
                'bytes_freed' => $bytesFreed,
            ],
        );

        return $bytesFreed;
    }

    private static function formatBytes(int $bytes): string
    {
        if ($bytes >= 1073741824) {
            return number_format($bytes / 1073741824, 2).' GB';
        }
        if ($bytes >= 1048576) {
            return number_format($bytes / 1048576, 2).' MB';
        }
        if ($bytes >= 1024) {
            return number_format($bytes / 1024, 2).' KB';
        }

        return $bytes.' B';
    }
}
