<?php

declare(strict_types=1);

namespace Laravel\Boost\Console;

use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Laravel\Boost\Concerns\DisplayHelper;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Agents\Agent;
use Laravel\Boost\Install\AgentsDetector;
use Laravel\Boost\Install\Cloud;
use Laravel\Boost\Install\GuidelineComposer;
use Laravel\Boost\Install\GuidelineConfig;
use Laravel\Boost\Install\GuidelineWriter;
use Laravel\Boost\Install\McpWriter;
use Laravel\Boost\Install\Nightwatch;
use Laravel\Boost\Install\RuleComposer;
use Laravel\Boost\Install\Sail;
use Laravel\Boost\Install\Skill;
use Laravel\Boost\Install\SkillComposer;
use Laravel\Boost\Install\SkillWriter;
use Laravel\Boost\Install\ThirdPartyPackage;
use Laravel\Boost\Rules\RuleRepository;
use Laravel\Boost\Skills\Remote\GitHubRepository;
use Laravel\Boost\Skills\Remote\GitHubSkillProvider;
use Laravel\Boost\Skills\Remote\RemoteSkill;
use Laravel\Boost\Support\Config;
use Laravel\Boost\Support\RenderFailures;
use Laravel\Prompts\Terminal;
use RuntimeException;
use Symfony\Component\Process\Exception\ProcessSignaledException;
use Symfony\Component\Process\Process;
use Throwable;

use function Laravel\Prompts\grid;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\note;

class InstallCommand extends Command
{
    use DisplayHelper;

    protected $signature = 'boost:install
        {--guidelines : Install AI guidelines}
        {--skills : Install agent skills}
        {--mcp : Install MCP server configuration}';

    /** @var Collection<int, Agent> */
    private Collection $selectedAgents;

    /** @var Collection<int, string> */
    private Collection $selectedBoostFeatures;

    /** @var Collection<int, string> */
    private Collection $selectedThirdPartyPackages;

    private string $projectName;

    /** @var array<non-empty-string> */
    private array $systemInstalledAgents = [];

    /** @var array<non-empty-string> */
    private array $projectInstalledAgents = [];

    private bool $enforceTests = true;

    /** @var array<int, string> */
    private array $installedSkillNames = [];

    const MIN_TEST_COUNT = 6;

    public function __construct(
        private readonly AgentsDetector $agentsDetector,
        private readonly Cloud $cloud,
        private readonly Config $config,
        private readonly Nightwatch $nightwatch,
        private readonly Sail $sail,
        private readonly Terminal $terminal
    ) {
        parent::__construct();
    }

    public function handle(): int
    {
        $this->terminal->initDimensions();
        $this->projectName = config('app.name');

        $this->displayBoostHeader('Install', $this->projectName);
        $this->discoverEnvironment();
        $this->collectInstallationPreferences();
        $this->performInstallation();

        $this->reportRenderFailures();

        $this->noteInferConventions();

        $this->outro();

        return self::SUCCESS;
    }

    protected function discoverEnvironment(): void
    {
        if ($this->config->getAgents() !== []) {
            return;
        }

        $this->systemInstalledAgents = $this->agentsDetector->discoverSystemInstalledAgents();
        $this->projectInstalledAgents = $this->agentsDetector->discoverProjectInstalledAgents(base_path());
    }

    protected function collectInstallationPreferences(): void
    {
        $this->selectedBoostFeatures = $this->selectBoostFeatures();

        $this->selectedThirdPartyPackages = $this->selectedBoostFeatures->contains('guidelines') || $this->selectedBoostFeatures->contains('skills')
            ? $this->selectThirdPartyPackages()
            : collect();

        $this->selectIntegrations();

        $this->selectedAgents = $this->selectAgents();
        $this->enforceTests = $this->selectedBoostFeatures->contains('guidelines') && $this->determineTestEnforcement();
    }

    protected function performInstallation(): void
    {
        app()->instance(GuidelineConfig::class, $this->buildGuidelineConfig());

        if ($this->selectedBoostFeatures->contains('guidelines')) {
            $this->installGuidelines();
        }

        if ($this->shouldInstallCloudSkill()) {
            $this->downloadCloudSkill();
        }

        if ($this->selectedBoostFeatures->contains('skills')) {
            $this->installSkills();
        }

        if ($this->selectedBoostFeatures->contains('mcp')) {
            $this->installMcpServerConfig();
        }

        $this->storeConfig();
    }

    protected function reportRenderFailures(): void
    {
        $renderFailures = app(RenderFailures::class);

        if ($renderFailures->isEmpty()) {
            return;
        }

        $paths = $renderFailures->paths();
        $packages = $renderFailures->packages();

        $this->newLine();
        $this->warn(sprintf('Skipped %d %s that could not be rendered:', count($paths), Str::plural('file', $paths)));

        foreach ($paths as $path) {
            $this->line('  - '.str_replace(base_path().DIRECTORY_SEPARATOR, '', $path));
        }

        if ($packages !== []) {
            $this->warn('These ship Boost files built for an older Boost version, so Boost used its own where it had them. Update them with: composer update '.implode(' ', $packages));
        }
    }

    protected function noteInferConventions(): void
    {
        note('💡 Run the infer-conventions skill to record your app conventions and sharpen code generation.');
    }

    protected function outro(): void
    {
        $url = 'https://laravel.com/docs/boost';
        $link = $this->hyperlink($url, $url);
        $text = 'Enjoy the boost 🚀 Next steps: ';

        $this->displayOutro($text, $link, $this->terminal->cols());
    }

    /**
     * We shouldn't add an AI guideline enforcing test if they don't have a basic test setup.
     * This would likely just create headaches for them or be a waste of time as they
     * won't have the CI setup to make use of them anyway, so we're just wasting their
     * tokens/money by enforcing them.
     */
    protected function determineTestEnforcement(): bool
    {
        if (config('boost.enforce_tests') !== null) {
            return (bool) config('boost.enforce_tests');
        }

        if (! file_exists(base_path('vendor/bin/phpunit'))) {
            return false;
        }

        $process = new Process([PHP_BINARY, 'artisan', 'test', '--list-tests'], base_path());

        try {
            $process->run();
        } catch (ProcessSignaledException) {
            return false;
        }

        return Str::of($process->getOutput())
            ->trim()
            ->explode("\n")
            ->filter(fn ($line): bool => str_contains($line, '::'))
            ->count() >= self::MIN_TEST_COUNT;
    }

    /**
     * @return Collection<int, string>
     */
    protected function selectBoostFeatures(): Collection
    {
        $featureLabels = collect([
            'guidelines' => 'AI Guidelines',
            'skills' => 'Agent Skills',
            'mcp' => 'Boost MCP Server Configuration',
        ]);

        $explicit = $featureLabels->keys()->filter(fn ($feature) => $this->option($feature));

        if ($explicit->isNotEmpty()) {
            return $explicit->values();
        }

        $configValues = collect([
            'guidelines' => $this->config->getGuidelines(),
            'skills' => $this->config->hasSkills(),
            'mcp' => $this->config->getMcp(),
        ]);

        $defaults = $configValues->filter()->keys()->whenEmpty(fn () => $featureLabels->keys());

        if (! $this->input->isInteractive()) {
            return $defaults->values();
        }

        return collect(multiselect(
            label: 'Which Boost features would you like to configure?',
            options: $featureLabels->all(),
            default: $defaults->all(),
            required: true,
            hint: 'This will override the current guidelines, skills, and MCP configuration',
        ));
    }

    /**
     * @return Collection<int, string>
     */
    protected function selectThirdPartyPackages(): Collection
    {
        $packages = ThirdPartyPackage::discover();

        if ($packages->isEmpty()) {
            return collect();
        }

        $defaults = collect($this->config->getPackages())
            ->filter(fn (string $name) => $packages->has($name))
            ->values();

        if (! $this->input->isInteractive()) {
            return $defaults;
        }

        return collect(multiselect(
            label: 'Which third-party AI guidelines/skills would you like to install?',
            options: $packages->mapWithKeys(fn (ThirdPartyPackage $pkg, string $name): array => [
                $name => $pkg->displayLabel(),
            ])->toArray(),
            default: $defaults->all(),
            scroll: 10,
            hint: 'You can add or remove them later by running this command again',
        ));
    }

    protected function selectIntegrations(): void
    {
        $integrations = collect([
            'cloud' => [
                'label' => 'Laravel Cloud',
                'available' => $this->selectedBoostFeatures->contains('skills'),
                'default' => $this->config->getCloud(),
            ],
            'nightwatch' => [
                'label' => 'Laravel Nightwatch',
                'available' => $this->nightwatch->isInstalled(),
                'default' => $this->config->getNightwatch(),
            ],
            'sail' => [
                'label' => 'Laravel Sail',
                'available' => $this->sail->isInstalled(),
                'default' => $this->sail->isActive() || $this->config->getSail(),
            ],
        ])->filter(fn (array $integration): bool => $integration['available']);

        if ($integrations->isEmpty()) {
            return;
        }

        $defaults = $integrations->filter(fn (array $integration): bool => $integration['default'])->keys()->all();

        if (! $this->input->isInteractive()) {
            $this->selectedBoostFeatures->push(...$defaults);

            return;
        }

        $selected = multiselect(
            label: 'Which integrations would you like to configure for Boost?',
            options: $integrations->map(fn (array $integration): string => $integration['label'])->all(),
            default: $defaults,
            hint: 'Selected integrations will have their MCP servers or skills automatically configured',
        );

        $this->selectedBoostFeatures->push(...$selected);
    }

    /**
     * @return Collection<int, Agent>
     */
    protected function selectAgents(): Collection
    {
        $allAgents = $this->agentsDetector->getAgents();

        if ($allAgents->isEmpty()) {
            return collect();
        }

        $featureInterfaces = [
            'guidelines' => SupportsGuidelines::class,
            'skills' => SupportsSkills::class,
            'mcp' => SupportsMcp::class,
        ];

        $filteredAgents = $allAgents->filter(
            fn (Agent $agent): bool => $this->selectedBoostFeatures->contains(
                fn ($feature): bool => isset($featureInterfaces[$feature]) && $agent instanceof $featureInterfaces[$feature])
        )->keyBy(fn (Agent $agent): string => $agent->name());

        if ($filteredAgents->isEmpty()) {
            return collect();
        }

        $options = $filteredAgents
            ->mapWithKeys(fn (Agent $agent): array => [$agent->name() => $agent->displayName()])
            ->sort();

        $defaults = collect($this->config->getAgents())
            ->filter(fn (string $name) => $filteredAgents->has($name))
            ->whenEmpty(fn () => collect([...$this->projectInstalledAgents, ...$this->systemInstalledAgents])
                ->unique()
                ->filter(fn (string $name) => $filteredAgents->has($name))
            )
            ->values();

        if (! $this->input->isInteractive()) {
            return $defaults
                ->map(fn (string $name) => $filteredAgents->get($name))
                ->values();
        }

        $selected = multiselect(
            label: 'Which AI agents would you like to configure?',
            options: $options->all(),
            default: $defaults->all(),
            scroll: $options->count(),
            required: true,
        );

        return collect($selected)
            ->map(fn (string $name) => $filteredAgents->get($name))
            ->values();
    }

    /**
     * @return Collection<int, Agent&SupportsMcp>
     */
    protected function agentsWithMcp(): Collection
    {
        return $this->selectedAgents->filter(fn (Agent $a): bool => $a instanceof SupportsMcp);
    }

    /**
     * @return Collection<int, Agent&SupportsGuidelines>
     */
    protected function agentsWithGuidelines(): Collection
    {
        return $this->selectedAgents->filter(fn (Agent $a): bool => $a instanceof SupportsGuidelines);
    }

    /**
     * @return Collection<int, Agent&SupportsSkills>
     */
    protected function agentsWithSkills(): Collection
    {
        return $this->selectedAgents->filter(fn (Agent $a): bool => $a instanceof SupportsSkills);
    }

    protected function installGuidelines(): void
    {
        $guidelinesAgents = $this->agentsWithGuidelines();
        $guidelineConfig = $this->buildGuidelineConfig();
        $composer = app(GuidelineComposer::class)->config($guidelineConfig);

        $this->syncRuleFiles($composer);

        $guidelines = $composer->guidelines();
        $composedAiGuidelines = $composer->compose();

        $this->installFeature(
            agents: $guidelinesAgents,
            emptyMessage: 'No agents are selected for guideline installation.',
            headerMessage: sprintf('Adding %d guidelines to your selected agents', $guidelines->count()),
            nameResolver: fn (Agent $agent): string => $agent->displayName(),
            processor: fn (Agent&SupportsGuidelines $agent): int => (new GuidelineWriter($agent))->write($composedAiGuidelines),
            featureName: 'guidelines',
            beforeProcess: fn () => grid($guidelines->map(fn ($guideline, string $key): string => $key.($guideline['custom'] ? '*' : ''))->sort()->values()->toArray()),
            withDelay: true,
        );
    }

    protected function syncRuleFiles(GuidelineComposer $composer): void
    {
        $repository = app(RuleRepository::class);

        if (! config('boost.rules.enabled', true) || ! config('boost.rules.scoped_guidelines', false)) {
            rescue(fn () => $repository->clearManaged(), report: false);

            return;
        }

        try {
            $written = $repository->syncManaged((new RuleComposer($composer))->composeManaged());
        } catch (Throwable) {
            try {
                $repository->clearManaged();
            } catch (Throwable $cleanupError) {
                throw new RuntimeException(
                    'Failed to write path-scoped rules and could not clear .ai/rules/boost. '
                    .'Resolve the directory (it may be locked) and re-run boost:install.',
                    0,
                    $cleanupError,
                );
            }

            $composer->withoutRuleExtraction();

            $this->warn('Could not write path-scoped rules to .ai/rules/boost — keeping them inline in the guidelines instead.');

            return;
        }

        if ($written !== []) {
            $this->info(sprintf('Extracted %d path-scoped %s to .ai/rules/boost', count($written), Str::plural('rule file', count($written))));
        }
    }

    protected function installSkills(): void
    {
        $skillsAgents = $this->agentsWithSkills();
        $skillsComposer = app(SkillComposer::class)->config($this->buildGuidelineConfig());
        $skills = $skillsComposer->skills();

        $this->installedSkillNames = $skills->keys()->toArray();

        /** @var Collection<int, SupportsSkills&Agent> $skillsAgents */
        $this->installFeature(
            agents: $skillsAgents,
            emptyMessage: 'No agents are selected for skill installation.',
            headerMessage: sprintf('Syncing %d skills for skills-capable agents', $skills->count()),
            nameResolver: fn (SupportsSkills&Agent $agent): string => $agent->displayName(),
            processor: fn (SupportsSkills&Agent $agent): array => (new SkillWriter($agent))->sync($skills, $this->config->getSkills()),
            featureName: 'skills',
            beforeProcess: $skills->isNotEmpty()
                ? fn () => grid($skills->map(fn (Skill $skill): string => $skill->displayName())->sort()->values()->toArray())
                : null,
        );
    }

    protected function buildGuidelineConfig(): GuidelineConfig
    {
        $guidelineConfig = new GuidelineConfig;
        $guidelineConfig->enforceTests = $this->enforceTests;
        $guidelineConfig->hasAnApi = false;
        $guidelineConfig->aiGuidelines = $this->selectedThirdPartyPackages->values()->toArray();
        $guidelineConfig->usesSail = $this->shouldUseSail();
        $guidelineConfig->hasSkills = $this->selectedBoostFeatures->contains('skills');
        $guidelineConfig->hasMcp = $this->selectedBoostFeatures->contains('mcp') || ($this->isExplicitFlagMode() && $this->config->getMcp());

        return $guidelineConfig;
    }

    protected function shouldInstallCloudSkill(): bool
    {
        return $this->selectedBoostFeatures->contains('cloud');
    }

    protected function downloadCloudSkill(): void
    {
        try {
            $repository = GitHubRepository::fromInput($this->cloud->skillRepo().'/'.$this->cloud->skillPath());
            $provider = new GitHubSkillProvider($repository);
            $skill = $provider->discoverSkills()->get($this->cloud->skillName());

            if (! $skill instanceof RemoteSkill) {
                return;
            }

            $provider->downloadSkill($skill, base_path('.ai/skills/'.$this->cloud->skillName()));
        } catch (Exception $exception) {
            $this->warn('Failed to download Cloud skill: '.$exception->getMessage());
            $this->line('You can install it later with: php artisan boost:add-skill '.$this->cloud->skillRepo());
        }
    }

    protected function storeConfig(): void
    {
        $explicitMode = $this->isExplicitFlagMode();

        if (! $explicitMode) {
            $this->config->flush();
            $this->config->setAgents($this->selectedAgents->map(fn (Agent $agent): string => $agent->name())->values()->toArray());
            $this->config->setPackages($this->selectedThirdPartyPackages->values()->toArray());
        } elseif ($this->selectedBoostFeatures->contains('guidelines') || $this->selectedBoostFeatures->contains('skills')) {
            $this->config->setPackages($this->selectedThirdPartyPackages->values()->toArray());
        }

        if ($this->selectedBoostFeatures->contains('guidelines')) {
            $this->config->setGuidelines(true);
        }

        if ($this->selectedBoostFeatures->contains('skills')) {
            $this->config->setSkills($this->installedSkillNames);
        }

        if ($this->selectedBoostFeatures->contains('skills')) {
            $this->config->setCloud($this->selectedBoostFeatures->contains('cloud'));
        }

        if ($this->selectedBoostFeatures->contains('mcp')) {
            $this->config->setMcp(true);
            $this->config->setSail($this->shouldUseSail());
            $this->config->setNightwatch($this->shouldInstallNightwatchMcp());
        }
    }

    protected function shouldInstallNightwatchMcp(): bool
    {
        return $this->selectedBoostFeatures->contains('nightwatch');
    }

    protected function shouldUseSail(): bool
    {
        if ($this->selectedBoostFeatures->contains('mcp')) {
            return $this->selectedBoostFeatures->contains('sail');
        }

        return $this->config->getSail();
    }

    protected function isExplicitFlagMode(): bool
    {
        if ($this->option('guidelines')) {
            return true;
        }

        if ($this->option('skills')) {
            return true;
        }

        return (bool) $this->option('mcp');
    }

    protected function installMcpServerConfig(): void
    {
        $this->installFeature(
            agents: $this->agentsWithMcp(),
            emptyMessage: 'No agents are selected for MCP installation.',
            headerMessage: 'Installing MCP servers to your selected Agents',
            nameResolver: fn (Agent $agent): string => $agent->displayName(),
            processor: fn (Agent&SupportsMcp $agent): int => (new McpWriter($agent))->write(
                $this->shouldUseSail() ? $this->sail : null,
                $this->shouldInstallNightwatchMcp() ? $this->nightwatch : null
            ),
            featureName: 'MCP servers',
            withDelay: true,
        );
    }

    /**
     * @template T
     *
     * @param  Collection<int, T>  $agents
     * @param  callable(T): string  $nameResolver
     * @param  callable(T): mixed  $processor
     * @param  ?callable(): void  $beforeProcess
     */
    protected function installFeature(
        Collection $agents,
        string $emptyMessage,
        string $headerMessage,
        callable $nameResolver,
        callable $processor,
        string $featureName,
        ?callable $beforeProcess = null,
        bool $withDelay = false,
    ): void {
        if ($agents->isEmpty()) {
            $this->info($emptyMessage);

            return;
        }

        $this->newLine();
        $this->info($headerMessage);

        if ($beforeProcess !== null) {
            $beforeProcess();
        }

        $this->newLine();

        if ($withDelay) {
            usleep(750000);
        }

        $failed = [];
        $nameMap = $agents->map(fn ($agent): string => $nameResolver($agent));
        $longestName = $nameMap->map(fn (string $name) => Str::length($name))->max() ?? 0;

        foreach ($agents as $index => $agent) {
            $name = $nameMap[$index];
            $this->output->write('  '.str_pad($name, $longestName).'... ');

            try {
                $processor($agent);
                $this->line($this->green('✓'));
            } catch (Exception $e) {
                $failed[$name] = $e->getMessage();
                $this->line($this->red('✗'));
            }
        }

        if ($failed !== []) {
            $this->newLine();
            $this->error(sprintf('✗ Failed to install %s to %d agent%s:',
                $featureName,
                count($failed),
                count($failed) === 1 ? '' : 's'
            ));

            foreach ($failed as $agentName => $error) {
                $this->line("  - {$agentName}: {$error}");
            }
        }

        $this->newLine();
    }
}
