<?php

namespace App\Support;

use App\Models\CronLog;
use Illuminate\Console\Events\CommandFinished;
use Illuminate\Console\Events\CommandStarting;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Str;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Symfony\Component\Console\Input\InputInterface;
use Throwable;

/**
 * Records cron execution timing into the central cron_logs table.
 *
 * Registered as a singleton so its in-memory state survives across the four events
 * it handles within a single command process. Two complementary hooks are needed:
 *
 *  - CommandStarting / CommandFinished fire ONCE per process for the top-level command
 *    (tenants:run's inner $this->call() bypasses the console Application, so nested
 *    commands never fire these). This gives the system row.
 *  - TenancyInitialized / TenancyEnded fire per tenant inside tenants:run (and inside
 *    self-looping commands), giving one child row per tenant slice.
 *
 * Every handler is fully guarded - logging must never break the command it observes.
 */
class CronLogger
{
    private bool $active = false;

    private ?int $topLevelId = null;

    private float $topLevelStart = 0.0;

    /** Raw top-level command name, e.g. "tenants:run" or "clear:audit". */
    private ?string $topLevelCommand = null;

    /** For tenants:run, the wrapped command name; otherwise null. */
    private ?string $innerCommand = null;

    /** @var array<int, mixed>|null */
    private ?array $arguments = null;

    /** @var array<string, mixed>|null */
    private ?array $options = null;

    /** @var list<array{tenant_id: ?string, start: float, started_at: \Illuminate\Support\Carbon}> */
    private array $tenantStack = [];

    public function commandStarting(CommandStarting $event): void
    {
        try {
            if (! $this->shouldLog() || $this->active) {
                return;
            }

            $name = $event->command;

            if ($name === null || $name === '' || $this->isIgnored($name)) {
                return;
            }

            [$arguments, $options] = $this->captureInput($event->input);

            $this->topLevelCommand = $name;
            $this->innerCommand = $name === 'tenants:run' ? ($arguments['commandname'] ?? null) : null;
            $this->arguments = $arguments;
            $this->options = $options;
            $this->topLevelStart = microtime(true);

            $log = CronLog::create([
                'command' => $this->displayCommand(),
                'tenant_id' => null,
                'arguments' => $arguments,
                'options' => $options,
                'status' => CronLog::STATUS_RUNNING,
                'hostname' => gethostname() ?: null,
                'started_at' => Date::now(),
            ]);

            $this->topLevelId = $log->id;
            $this->active = true;
        } catch (Throwable $e) {
            report($e);
        }
    }

    public function commandFinished(CommandFinished $event): void
    {
        try {
            if (! $this->active || $this->topLevelId === null) {
                return;
            }

            CronLog::whereKey($this->topLevelId)->update([
                'finished_at' => Date::now(),
                'duration_ms' => $this->elapsedMs($this->topLevelStart),
                'exit_code' => $event->exitCode,
                'status' => $event->exitCode === 0 ? CronLog::STATUS_COMPLETED : CronLog::STATUS_FAILED,
            ]);
        } catch (Throwable $e) {
            report($e);
        } finally {
            $this->reset();
        }
    }

    public function tenancyInitialized(TenancyInitialized $event): void
    {
        try {
            if (! $this->active) {
                return;
            }

            $this->tenantStack[] = [
                'tenant_id' => $event->tenancy->tenant?->getTenantKey(),
                'start' => microtime(true),
                'started_at' => Date::now(),
            ];
        } catch (Throwable $e) {
            report($e);
        }
    }

    public function tenancyEnded(TenancyEnded $event): void
    {
        try {
            if (! $this->active || $this->tenantStack === []) {
                return;
            }

            $frame = array_pop($this->tenantStack);

            CronLog::create([
                'parent_id' => $this->topLevelId,
                'command' => $this->innerCommand ?? $this->topLevelCommand,
                'tenant_id' => $frame['tenant_id'],
                'arguments' => $this->arguments,
                'options' => $this->options,
                'status' => CronLog::STATUS_COMPLETED,
                'hostname' => gethostname() ?: null,
                'started_at' => $frame['started_at'],
                'finished_at' => Date::now(),
                'duration_ms' => $this->elapsedMs($frame['start']),
            ]);
        } catch (Throwable $e) {
            report($e);
        }
    }

    private function shouldLog(): bool
    {
        return config('cron_logs.enabled') && app()->runningInConsole();
    }

    private function isIgnored(string $command): bool
    {
        foreach ((array) config('cron_logs.ignore', []) as $pattern) {
            if (Str::is($pattern, $command)) {
                return true;
            }
        }

        return false;
    }

    private function displayCommand(): string
    {
        if ($this->topLevelCommand === 'tenants:run' && $this->innerCommand !== null) {
            return 'tenants:run ' . $this->innerCommand;
        }

        return (string) $this->topLevelCommand;
    }

    /**
     * @return array{0: array<int, mixed>|null, 1: array<string, mixed>|null}
     */
    private function captureInput(InputInterface $input): array
    {
        try {
            $arguments = $input->getArguments();
        } catch (Throwable) {
            $arguments = [];
        }

        try {
            $options = $input->getOptions();
        } catch (Throwable) {
            $options = [];
        }

        unset($arguments['command']);

        return [$arguments ?: null, $options ?: null];
    }

    private function elapsedMs(float $start): int
    {
        // microtime(true) is wall-clock, not monotonic: a backwards clock step
        // mid-run (e.g. an NTP correction) can make this negative. duration_ms is
        // an unsigned column, so clamp to 0 rather than blowing up the insert.
        return max(0, (int) round((microtime(true) - $start) * 1000));
    }

    private function reset(): void
    {
        $this->active = false;
        $this->topLevelId = null;
        $this->topLevelStart = 0.0;
        $this->topLevelCommand = null;
        $this->innerCommand = null;
        $this->arguments = null;
        $this->options = null;
        $this->tenantStack = [];
    }
}
