<?php

declare(strict_types=1);

namespace App\Tag;

use App\Index\IndexMetaManager;
use Psr\Log\LoggerInterface;

final readonly class TagVectorIndexBuilder
{
    private const GRACEFUL_TERMINATION_SECONDS = 2;

    public function __construct(
        private string $pythonBin,
        private string $scriptPath,
        private string $tagsNdjsonPath,
        private string $vectorTagsIndexPath,
        private string $embeddingModel,
        private int $timeoutSeconds,
        private LoggerInterface $agentLogger,
        private IndexMetaManager $metaManager,
    ) {
    }

    public function build(): void
    {
        $this->assertPreconditions();

        $tmpIndex = $this->vectorTagsIndexPath . '.tmp';
        $tmpMeta = $tmpIndex . '.meta.json';
        $finalIndex = $this->vectorTagsIndexPath;
        $finalMeta = $finalIndex . '.meta.json';

        $this->ensureTargetDirectoryExists($finalIndex);
        $this->cleanupTemporaryArtifacts($tmpIndex, $tmpMeta);

        if (!$this->hasEmbeddableTags()) {
            $this->agentLogger->info('[tags] no embeddable tags found, removing stale tag index artifacts.');
            $this->removeFileIfExists($finalIndex);
            $this->removeFileIfExists($finalMeta);
            $this->commitRuntime(false);

            return;
        }

        $cmd = $this->buildCommand($tmpIndex);

        $this->agentLogger->info('[tags] build tag vector index', [
            'cmd' => $cmd,
            'timeout' => $this->timeoutSeconds,
            'embedding_model' => $this->embeddingModel,
        ]);

        try {
            $result = $this->runCommand($cmd);

            if ($result['exit'] !== 0) {
                $this->agentLogger->error('[tags] tag vector ingest failed', [
                    'exit' => $result['exit'],
                    'stdout' => $result['stdout'],
                    'stderr' => $result['stderr'],
                ]);

                throw new \RuntimeException('Tag vector ingest failed (exit=' . $result['exit'] . ')');
            }

            if (!$this->isUsableArtifact($tmpIndex) || !$this->isUsableArtifact($tmpMeta)) {
                throw new \RuntimeException('Tag vector ingest produced incomplete artifacts.');
            }

            $this->atomicReplace($tmpIndex, $finalIndex);
            $this->atomicReplace($tmpMeta, $finalMeta);
            $this->commitRuntime(true);

            $this->agentLogger->info('[tags] tag vector index build completed + runtime committed', [
                'index' => $finalIndex,
                'meta' => $finalMeta,
            ]);
        } catch (\Throwable $e) {
            $this->cleanupTemporaryArtifacts($tmpIndex, $tmpMeta);
            throw $e;
        }
    }

    private function assertPreconditions(): void
    {
        if (!is_file($this->tagsNdjsonPath)) {
            throw new \RuntimeException('tags.ndjson missing: ' . $this->tagsNdjsonPath);
        }

        if (!is_file($this->scriptPath)) {
            throw new \RuntimeException('Tag ingest script missing: ' . $this->scriptPath);
        }

        if (trim($this->pythonBin) === '') {
            throw new \RuntimeException('Python binary must not be empty.');
        }

        if ($this->timeoutSeconds < 1) {
            throw new \RuntimeException('Tag vector timeout must be >= 1 second.');
        }
    }

    private function buildCommand(string $tmpIndex): string
    {
        return sprintf(
            '%s %s %s %s 2>&1',
            escapeshellarg($this->pythonBin),
            escapeshellarg($this->scriptPath),
            escapeshellarg($this->tagsNdjsonPath),
            escapeshellarg($tmpIndex),
        );
    }

    private function ensureTargetDirectoryExists(string $finalIndexPath): void
    {
        $dir = dirname($finalIndexPath);

        if (is_dir($dir)) {
            return;
        }

        if (!@mkdir($dir, 0775, true) && !is_dir($dir)) {
            throw new \RuntimeException('Unable to create tag vector directory: ' . $dir);
        }
    }

    private function hasEmbeddableTags(): bool
    {
        $fh = @fopen($this->tagsNdjsonPath, 'rb');

        if ($fh === false) {
            throw new \RuntimeException('Unable to read tags NDJSON: ' . $this->tagsNdjsonPath);
        }

        try {
            while (($line = fgets($fh)) !== false) {
                $line = trim($line);

                if ($line === '') {
                    continue;
                }

                $decoded = json_decode($line, true);

                if (!is_array($decoded)) {
                    continue;
                }

                $tagId = trim((string) ($decoded['tag_id'] ?? ''));
                $text = trim((string) ($decoded['text'] ?? ''));

                if ($tagId !== '' && $text !== '') {
                    return true;
                }
            }
        } finally {
            fclose($fh);
        }

        return false;
    }

    /**
     * @return array{exit:int, stdout:string, stderr:string}
     */
    private function runCommand(string $cmd): array
    {
        $descriptorSpec = [
            0 => ['pipe', 'r'],
            1 => ['pipe', 'w'],
            2 => ['pipe', 'w'],
        ];

        $process = @proc_open($cmd, $descriptorSpec, $pipes);

        if (!is_resource($process)) {
            throw new \RuntimeException('Could not start tag vector ingest process.');
        }

        fclose($pipes[0]);
        stream_set_blocking($pipes[1], false);
        stream_set_blocking($pipes[2], false);

        $stdout = '';
        $stderr = '';
        $startedAt = microtime(true);
        $timedOut = false;

        try {
            while (true) {
                $stdout .= stream_get_contents($pipes[1]) ?: '';
                $stderr .= stream_get_contents($pipes[2]) ?: '';

                $status = proc_get_status($process);

                if (!is_array($status) || ($status['running'] ?? false) !== true) {
                    break;
                }

                if ((microtime(true) - $startedAt) > $this->timeoutSeconds) {
                    $timedOut = true;
                    proc_terminate($process);
                    usleep(self::GRACEFUL_TERMINATION_SECONDS * 1000000);

                    $status = proc_get_status($process);
                    if (is_array($status) && ($status['running'] ?? false) === true) {
                        proc_terminate($process, 9);
                    }

                    break;
                }

                usleep(100000);
            }

            $stdout .= stream_get_contents($pipes[1]) ?: '';
            $stderr .= stream_get_contents($pipes[2]) ?: '';
        } finally {
            fclose($pipes[1]);
            fclose($pipes[2]);
        }

        $exitCode = proc_close($process);

        if ($timedOut) {
            $this->agentLogger->error('[tags] tag vector ingest timed out', [
                'timeout' => $this->timeoutSeconds,
                'stdout' => $stdout,
                'stderr' => $stderr,
            ]);

            throw new \RuntimeException('Tag vector ingest timed out after ' . $this->timeoutSeconds . ' seconds.');
        }

        return [
            'exit' => is_int($exitCode) ? $exitCode : 1,
            'stdout' => trim($stdout),
            'stderr' => trim($stderr),
        ];
    }

    private function isUsableArtifact(string $path): bool
    {
        return is_file($path) && filesize($path) > 0;
    }

    private function cleanupTemporaryArtifacts(string ...$paths): void
    {
        foreach ($paths as $path) {
            $this->removeFileIfExists($path);
        }
    }

    private function removeFileIfExists(string $path): void
    {
        if (is_file($path)) {
            @unlink($path);
        }
    }

    private function commitRuntime(bool $indexPresent): void
    {
        $this->metaManager->touchRuntime([
            'last_tags_rebuild_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
            'tags_index_present' => $indexPresent,
        ]);
    }

    private function atomicReplace(string $tmp, string $final): void
    {
        if (!@rename($tmp, $final)) {
            if (!@copy($tmp, $final)) {
                @unlink($tmp);
                throw new \RuntimeException('Atomic replace failed for: ' . $final);
            }

            @unlink($tmp);
        }

        @chmod($final, 0664);
    }
}