<?php

/**
 * Installer Toolkit - Build Command
 *
 * Generates install.php + updater.php + post_update.php + readme.html for any
 * Laravel project from shared templates. Each project only needs a
 * package-config.php in package/.
 *
 * Output is written outside the project (a system temp dir by default) so
 * running this never leaves untracked artifacts in the project tree.
 *
 * Usage:
 *   php ~/php/installer-toolkit/bin/build                              # Run from project root, temp output dir
 *   php ~/php/installer-toolkit/bin/build /path/to/project              # Specify project path, temp output dir
 *   php ~/php/installer-toolkit/bin/build /path/to/project /output/dir  # Specify both
 */

$toolkitDir = dirname(__DIR__);
$projectDir = isset($argv[1]) ? $argv[1] : getcwd();
$packageDir = isset($argv[2]) ? $argv[2] : sys_get_temp_dir().'/installer-toolkit-build-'.uniqid();

if (! is_dir($projectDir)) {
    echo "Error: Project directory not found: {$projectDir}\n";
    exit(1);
}

$configFile = $projectDir.'/package/package-config.php';
if (! file_exists($configFile)) {
    echo "Error: No package-config.php found in {$projectDir}/package/\n";
    echo "Create one using this format:\n\n";
    echo "  return [\n";
    echo "      'name'              => 'Your App Name',\n";
    echo "      'slug'              => 'your-app',\n";
    echo "      'essential_seeders' => [...],\n";
    echo "      'sample_seeders'    => [...],\n";
    echo "  ];\n\n";
    exit(1);
}

$config = require $configFile;

$required = ['name', 'slug'];
foreach ($required as $key) {
    if (empty($config[$key])) {
        echo "Error: Missing required config key: {$key}\n";
        exit(1);
    }
}

// Derived from the project's composer.json rather than package-config.php, so
// the floor baked into the generated install.php/updater.php can never drift
// from the requirement the app actually enforces.
$resolveMinPhpVersion = require $toolkitDir.'/src/min_php_version.php';
$resolvedMinPhp = $resolveMinPhpVersion($projectDir);

if ($resolvedMinPhp['error'] !== null) {
    echo "Error: Could not determine the minimum PHP version: {$resolvedMinPhp['error']}\n";
    exit(1);
}

$config['min_php_version'] = $resolvedMinPhp['version'];

// The slug is interpolated verbatim into generated PHP below (inside a
// single-quoted define() literal) — validate its shape up front so a stray
// quote or backslash in package-config.php can't corrupt the generated
// install.php/updater.php. LoadsPackageConfig enforces the same pattern on
// the artisan (package:build) side; bin/build is a separate entry point and
// must not trust the config file any less. min_php_version needs no such
// check: it is derived above and always matches \d+\.\d+\.\d+ by construction.
if (! preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/', $config['slug'])) {
    echo "Error: package-config.php's 'slug' must contain only lowercase letters, numbers, and hyphens, got: '{$config['slug']}'.\n";
    exit(1);
}

$slug = $config['slug'];
$zipFilename = $config['slug'].'.zip';

echo "Building package for: {$config['name']}\n";
echo "Output: {$packageDir}\n\n";

// ─── Create directory structure ───────────────────────────────────────────────
if (! is_dir($packageDir)) {
    mkdir($packageDir, 0755, true);
    echo "  Created directory structure\n";
}

$toolkitVersion = trim(file_get_contents($toolkitDir.'/VERSION'));

// The compiled stylesheet is injected into both generated tools.
$installerCssPath = $toolkitDir.'/templates/install/installer.css';
if (! file_exists($installerCssPath)) {
    echo "Error: {$installerCssPath} not found. Run build-tools/compile-css.sh in the toolkit repo first.\n";
    exit(1);
}
$installerCss = trim(file_get_contents($installerCssPath));

// ─── 1. Generate install.php ──────────────────────────────────────────────────
echo "  Generating install.php...";
$template = buildToolTemplate($toolkitDir, 'install');

$configBlock = implode("\n", [
    "define('ZIP_FILENAME', '{$zipFilename}');",
    "define('APP_FOLDER', '{$slug}');",
    "define('APP_NAME', '".addslashes($config['name'])."');",
    "define('MIN_PHP_VERSION', '{$config['min_php_version']}');",
    "define('INSTALLER_VERSION', '{$toolkitVersion}');",
]);

$template = replaceMarkerOrFail(
    '/\/\/ \[\[INSTALLER_CONFIG\]\].*?\/\/ \[\[\/INSTALLER_CONFIG\]\]/s',
    $configBlock,
    $template,
    '[[INSTALLER_CONFIG]] marker in install header.php'
);

$template = injectCss($template, $installerCss, 'install.php');

$essentialSeeders = $config['essential_seeders'] ?? [];
$sampleSeeders = $config['sample_seeders'] ?? [];

$essentialArray = buildSeederArray($essentialSeeders);
$sampleArray = buildSeederArray($sampleSeeders);

$template = replaceMarkerOrFail(
    '/private const ESSENTIAL_SEED_CLASSES = \[.*?\];/s',
    "private const ESSENTIAL_SEED_CLASSES = {$essentialArray};",
    $template,
    'ESSENTIAL_SEED_CLASSES in Concerns/RunsInstallTasks.php'
);

$template = replaceMarkerOrFail(
    '/private const SAMPLE_SEED_CLASSES = \[.*?\];/s',
    "private const SAMPLE_SEED_CLASSES = {$sampleArray};",
    $template,
    'SAMPLE_SEED_CLASSES in Concerns/RunsInstallTasks.php'
);

file_put_contents($packageDir.'/install.php', $template);
echo " done\n";

// ─── 2. Generate updater.php ─────────────────────────────────────────────────
echo "  Generating updater.php...";
$updaterTemplate = buildToolTemplate($toolkitDir, 'update');

$updaterConfigBlock = implode("\n", [
    "define('APP_FOLDER', '{$slug}');",
    "define('APP_NAME', '".addslashes($config['name'])."');",
    "define('MIN_PHP_VERSION', '{$config['min_php_version']}');",
    "define('UPDATER_VERSION', '{$toolkitVersion}');",
]);

$updaterTemplate = replaceMarkerOrFail(
    '/\/\/ \[\[INSTALLER_CONFIG\]\].*?\/\/ \[\[\/INSTALLER_CONFIG\]\]/s',
    $updaterConfigBlock,
    $updaterTemplate,
    '[[INSTALLER_CONFIG]] marker in update header.php'
);

$updaterTemplate = injectCss($updaterTemplate, $installerCss, 'updater.php');

file_put_contents($packageDir.'/updater.php', $updaterTemplate);
echo " done\n";

// ─── 3. Copy the post-update hook stub ───────────────────────────────────────
// Shipped inside the signed inner zip at {slug}/.updater/post_update.php by
// package:build; the updater includes it after extraction to run migrations
// and cache commands against the freshly-updated code.
echo "  Copying post_update.php...";
$hookSource = $toolkitDir.'/templates/update/post_update.stub.php';
if (! file_exists($hookSource)) {
    echo "Error: {$hookSource} not found.\n";
    exit(1);
}
copy($hookSource, $packageDir.'/post_update.php');
echo " done\n";

// ─── 4. Generate readme.html ─────────────────────────────────────────────────
// The readme is templated (not copied verbatim) so the PHP version it quotes
// always matches the app's own min_php_version instead of a hardcoded one.
echo "  Generating readme.html...";
$readme = file_get_contents($toolkitDir.'/readme.html');
// '8.3.0' reads awkwardly as "PHP 8.3.0+" — display it as "PHP 8.3+".
$minPhpDisplay = preg_replace('/\.0$/', '', $config['min_php_version']);
$readmeVersioned = str_replace('[[MIN_PHP_VERSION]]', $minPhpDisplay, $readme, $readmeMarkers);

if ($readmeMarkers < 1) {
    echo "Error: Failed to substitute [[MIN_PHP_VERSION]] marker in readme.html\n";
    exit(1);
}

file_put_contents($packageDir.'/readme.html', $readmeVersioned);
echo " done\n";

echo "\n\033[32m✔ Package built successfully for {$config['name']}\033[0m\n";
echo "  Files generated in: {$packageDir}\n\n";
echo "Next steps:\n";
echo "  1. Run 'php artisan package:build' to create the distributable zip\n";
echo "  2. Copy install.php + the zip to your distribution channel\n";

/**
 * Substitute a single marker/placeholder in $subject and fail loudly (rather
 * than silently shipping a broken installer) if the pattern doesn't match
 * exactly once.
 */
/**
 * preg_replace_callback (not preg_replace) so the replacement lands
 * literally — preg_replace would interpret \1/$1-style sequences in the
 * replacement (seeder arrays, the addslashes()'d app name) as
 * backreferences and silently corrupt the output. Same trap injectCss()
 * documents for the compiled CSS.
 */
function replaceMarkerOrFail(string $pattern, string $replacement, string $subject, string $errorLabel): string
{
    $result = preg_replace_callback($pattern, fn () => $replacement, $subject, 1, $count);

    if ($count !== 1) {
        echo "Error: Failed to substitute {$errorLabel}\n";
        exit(1);
    }

    return $result;
}

/**
 * Inject the compiled stylesheet into an assembled tool template.
 *
 * preg_replace_callback (not preg_replace) so the compiled CSS is inserted
 * literally — preg_replace would interpret \1/$1-style sequences in the CSS
 * as backreferences and silently corrupt the output.
 */
function injectCss(string $template, string $css, string $toolLabel): string
{
    $result = preg_replace_callback(
        '/\/\* \[\[INSTALLER_CSS\]\] \*\/.*?\/\* \[\[\/INSTALLER_CSS\]\] \*\//s',
        fn () => $css,
        $template,
        1,
        $cssReplaced
    );

    if ($cssReplaced !== 1) {
        echo "Error: Failed to substitute [[INSTALLER_CSS]] marker for {$toolLabel}\n";
        exit(1);
    }

    return $result;
}

/**
 * var_export(), not string interpolation — a seeder class name containing a
 * quote or backslash would otherwise break out of the generated array
 * literal and corrupt install.php.
 */
function buildSeederArray(array $seeders): string
{
    return var_export(array_values(array_map('strval', $seeders)), true);
}

/**
 * Assemble a single-file tool (install.php or updater.php) from its source
 * pieces under templates/{product}/, plus the shared pieces under
 * templates/shared/. The shipped tools must remain one standalone file
 * (customers upload them with no autoloader available), so everything is
 * inlined here in dependency order: definitions before the class that uses
 * them.
 *
 * Trait files are discovered from templates/shared/Concerns/ and
 * templates/{product}/Concerns/ rather than hardcoded, so adding/removing a
 * trait file is the only edit needed — the `use` statements in class.php's
 * [[INSTALLER_TRAITS]] block are generated from the same directory listings.
 */
function buildToolTemplate(string $toolkitDir, string $product): string
{
    $productDir = $toolkitDir.'/templates/'.$product;
    $sharedDir = $toolkitDir.'/templates/shared';

    $parts = [rtrim(file_get_contents("$productDir/header.php"), "\n")];

    // Shared plain functions (fatal error page) go right after the header so
    // they're defined before anything can throw.
    $parts[] = rtrim(stripPhpOpenTag(file_get_contents("$sharedDir/functions.php"), "$sharedDir/functions.php"), "\n");

    $traitFiles = array_merge(
        glob("$sharedDir/Concerns/*.php") ?: [],
        glob("$productDir/Concerns/*.php") ?: []
    );
    sort($traitFiles);

    if (empty($traitFiles)) {
        throw new \RuntimeException("No trait files found for {$product}");
    }

    $traitNames = array_map(fn ($file) => basename($file, '.php'), $traitFiles);

    if (count($traitNames) !== count(array_unique($traitNames))) {
        throw new \RuntimeException("Duplicate trait names across shared and {$product} Concerns directories.");
    }

    foreach ($traitFiles as $file) {
        $parts[] = rtrim(stripPhpOpenTag(file_get_contents($file), $file), "\n");
    }

    $useStatements = implode("\n", array_map(fn ($name) => "    use {$name};", $traitNames));

    $classTemplate = stripPhpOpenTag(file_get_contents("$productDir/class.php"), "$productDir/class.php");
    $classTemplate = preg_replace(
        '/\/\/ \[\[INSTALLER_TRAITS\]\].*?\/\/ \[\[\/INSTALLER_TRAITS\]\]/s',
        $useStatements,
        $classTemplate,
        1,
        $traitsReplaced
    );

    if ($traitsReplaced !== 1) {
        throw new \RuntimeException("Failed to substitute [[INSTALLER_TRAITS]] marker in {$productDir}/class.php");
    }

    $parts[] = rtrim($classTemplate, "\n");

    $parts[] = rtrim(stripPhpOpenTag(file_get_contents("$productDir/bootstrap.php"), "$productDir/bootstrap.php"), "\n");

    return implode("\n\n", $parts)."\n";
}

/**
 * Strip a file's leading `<?php` open tag using the tokenizer (rather
 * than a regex assuming a fixed blank-line convention), so trait files
 * can be concatenated into one script. Throws if the file doesn't open
 * with a plain `<?php` tag, so a malformed trait file fails the build
 * loudly instead of silently corrupting the generated installer.
 */
function stripPhpOpenTag(string $content, string $path): string
{
    $tokens = token_get_all($content);

    if (empty($tokens) || ! is_array($tokens[0]) || $tokens[0][0] !== T_OPEN_TAG) {
        throw new \RuntimeException("Expected {$path} to start with a <?php tag.");
    }

    return substr($content, strlen($tokens[0][1]));
}
