<?php

namespace MintyPHP\App\Module;

use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;

/**
 * Builds a runtime PageRoot by symlinking core and enabled module pages.
 *
 * Build uses atomic staging+swap: output is constructed in a temporary staging
 * directory alongside the live target and only swapped into place after
 * successful validation. Partial failures cannot leave inconsistent live state.
 */
final class ModuleRuntimePageBuilder
{
    /**
     * @param array<string, ModuleManifest> $modules
     * @return array{core_entries: int, module_entries: int}
     */
    public function build(string $runtimeDir, string $corePagesDir, array $modules): array
    {
        $corePages = realpath($corePagesDir);
        if ($corePages === false) {
            throw new RuntimeException('Core pages/ directory not found.');
        }

        if (count($modules) === 0) {
            $this->pointRuntimeToCore($runtimeDir, $corePages);
            return ['core_entries' => 0, 'module_entries' => 0];
        }

        // ── Staging: build in a sibling directory, swap on success ────
        $stagingDir = dirname($runtimeDir) . '/.pages-staging-' . getmypid();
        $this->ensureCleanDirectory($stagingDir);

        try {
            $result = $this->populateDirectory($stagingDir, $corePages, $modules);
        } catch (\Throwable $e) {
            $this->removeDirectory($stagingDir);
            throw $e;
        }

        // ── Atomic swap ──────────────────────────────────────────────
        $this->swapIntoLive($stagingDir, $runtimeDir);

        return $result;
    }

    public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
    {
        if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
            $this->removeDirectoryContents($runtimeDir);
            rmdir($runtimeDir);
        }
        if (is_link($runtimeDir)) {
            unlink($runtimeDir);
        }
        if (!@symlink($corePages, $runtimeDir)) {
            throw new RuntimeException("Failed to create symlink: {$runtimeDir} → {$corePages}");
        }
    }

    // ── Fingerprint: detect stale runtime state ─────────────────────

    /**
     * Compute the expected fingerprint for a set of modules.
     *
     * The digest includes module IDs, each manifest file content hash,
     * and top-level page-entry signatures so that manifest-only or
     * page-structure changes are detected even when the module ID list
     * is unchanged.
     *
     * @param array<string, ModuleManifest> $modules
     */
    public static function expectedFingerprint(array $modules): string
    {
        $parts = [];

        $manifests = $modules;
        uasort($manifests, static fn (ModuleManifest $a, ModuleManifest $b): int => $a->id <=> $b->id);

        foreach ($manifests as $manifest) {
            $manifestFile = $manifest->basePath . '/module.php';
            $manifestHash = is_file($manifestFile)
                ? md5_file($manifestFile)
                : 'missing';

            $pageEntries = self::topLevelPageEntries($manifest->basePath . '/pages');

            $parts[] = $manifest->id . ':' . $manifestHash . ':' . implode(',', $pageEntries);
        }

        if ($parts === []) {
            return '';
        }

        return hash('sha256', implode('|', $parts));
    }

    /**
     * Read the persisted fingerprint from disk.
     */
    public static function readFingerprint(string $runtimeDir): string
    {
        $file = dirname($runtimeDir) . '/.module-fingerprint';

        return is_file($file) ? trim((string) file_get_contents($file)) : '';
    }

    /**
     * Write a fingerprint after a successful build so web/index.php can
     * detect stale runtime state without rebuilding on every request.
     *
     * @param array<string, ModuleManifest> $modules
     */
    public static function writeFingerprint(string $runtimeDir, array $modules): void
    {
        $file = dirname($runtimeDir) . '/.module-fingerprint';

        file_put_contents($file, self::expectedFingerprint($modules));
    }

    // ── Internal helpers ────────────────────────────────────────────

    /**
     * Populate a directory with core + module page symlinks.
     *
     * @param array<string, ModuleManifest> $modules
     * @return array{core_entries: int, module_entries: int}
     */
    private function populateDirectory(string $targetDir, string $corePages, array $modules): array
    {
        $coreEntries = scandir($corePages);
        if ($coreEntries === false) {
            throw new RuntimeException('Cannot scan core pages directory.');
        }

        $coreCount = 0;
        foreach ($coreEntries as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            $source = $corePages . '/' . $entry;
            $target = $targetDir . '/' . $entry;
            if (!@symlink($source, $target)) {
                throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
            }
            $coreCount++;
        }

        $modulePageCount = 0;
        foreach ($modules as $manifest) {
            $modulePagesDir = $manifest->basePath . '/pages';
            if (!is_dir($modulePagesDir)) {
                continue;
            }
            $entries = scandir($modulePagesDir);
            if ($entries === false) {
                continue;
            }
            foreach ($entries as $entry) {
                if ($entry === '.' || $entry === '..') {
                    continue;
                }
                $source = $modulePagesDir . '/' . $entry;
                $target = $targetDir . '/' . $entry;
                if (file_exists($target) || is_link($target)) {
                    throw new RuntimeException(
                        "Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
                    );
                }
                if (!@symlink($source, $target)) {
                    throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
                }
                $modulePageCount++;
            }
        }

        return [
            'core_entries' => $coreCount,
            'module_entries' => $modulePageCount,
        ];
    }

    /**
     * Swap staging directory into the live runtime path.
     *
     * Uses rename() for an atomic filesystem operation (same filesystem).
     */
    private function swapIntoLive(string $stagingDir, string $runtimeDir): void
    {
        // Remove existing live target
        $oldDir = null;
        if (is_link($runtimeDir)) {
            unlink($runtimeDir);
        } elseif (is_dir($runtimeDir)) {
            // Move old live dir aside, then remove after swap
            $oldDir = $runtimeDir . '.old-' . getmypid();
            if (!@rename($runtimeDir, $oldDir)) {
                $this->removeDirectoryContents($runtimeDir);
                rmdir($runtimeDir);
                $oldDir = null;
            }
        } else {
            $oldDir = null;
        }

        // Atomic rename of staging → live
        if (!@rename($stagingDir, $runtimeDir)) {
            throw new RuntimeException(
                "Failed to swap staging directory into live runtime path: {$stagingDir} → {$runtimeDir}"
            );
        }

        // Clean up old directory if we moved it aside
        if ($oldDir !== null && (is_dir($oldDir) || is_link($oldDir))) {
            $this->removeDirectory($oldDir);
        }
    }

    private function ensureCleanDirectory(string $dir): void
    {
        if (is_dir($dir)) {
            $this->removeDirectoryContents($dir);
            rmdir($dir);
        }
        if (is_link($dir)) {
            unlink($dir);
        }
        if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
            throw new RuntimeException('Cannot create staging directory: ' . $dir);
        }
    }

    /**
     * Return sorted top-level entry names from a module pages directory.
     *
     * @return list<string>
     */
    private static function topLevelPageEntries(string $pagesDir): array
    {
        if (!is_dir($pagesDir)) {
            return [];
        }
        $entries = scandir($pagesDir);
        if ($entries === false) {
            return [];
        }
        $entries = array_values(array_filter(
            $entries,
            static fn (string $e): bool => $e !== '.' && $e !== '..',
        ));
        sort($entries);
        return $entries;
    }

    private function removeDirectory(string $dir): void
    {
        if (is_link($dir)) {
            unlink($dir);
            return;
        }
        if (!is_dir($dir)) {
            return;
        }
        $this->removeDirectoryContents($dir);
        rmdir($dir);
    }

    private function removeDirectoryContents(string $dir): void
    {
        $entries = scandir($dir);
        if ($entries === false) {
            return;
        }
        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            $path = $dir . '/' . $entry;
            if (is_link($path)) {
                unlink($path);
                continue;
            }
            if (is_dir($path)) {
                $items = new RecursiveIteratorIterator(
                    new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
                    RecursiveIteratorIterator::CHILD_FIRST
                );
                foreach ($items as $item) {
                    $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
                }
                rmdir($path);
                continue;
            }
            unlink($path);
        }
    }
}
