<?php
declare(strict_types=1);

/**
 * Build the release archive.
 *
 * A packaging checklist that a person reads is not a control. `tmp_debug.php`
 * -- a script that dumped API output and would have been executable from the
 * browser in the project-root document-root layout -- survived a declared
 * "final cleanup" and was found later by inspection. This script decides what
 * ships, and refuses to produce an archive when anything excluded would be
 * included.
 *
 * Usage:
 *   php bin/package.php            build dist/<name>-<version>.zip
 *   php bin/package.php --check    report what would ship, write nothing
 */

if (PHP_SAPI !== 'cli') {
    http_response_code(404);
    exit;
}

require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'bootstrap.php';
app_bootstrap();

$root = dirname(__DIR__);
$checkOnly = in_array('--check', $argv, true);

/**
 * Directories that never ship.
 *
 * tests/ and docs/ are development material; dist/ is build output; storage/
 * holds runtime data. Shipping any of them means serving them from the
 * document root in the project-root layout.
 */
const EXCLUDED_DIRS = ['dist', 'tests', 'storage', '.git', '.github', '.superpowers', 'node_modules', 'vendor', 'quaeriva'];

/** Only these application directories may enter a release archive. */
const ALLOWED_TOP_LEVEL_DIRECTORIES = ['admin', 'api', 'app', 'bin', 'config', 'database', 'docs', 'install', 'plugins', 'public', 'resources'];

/** Only these root files may enter a release archive. */
const ALLOWED_TOP_LEVEL_FILES = [
    '.env.example',
    '.htaccess',
    'CHANGELOG.md',
    'CONTRIBUTING.md',
    'LICENSE',
    'README.md',
    'RELEASE-CHECKLIST.md',
    'SECURITY.md',
    'VERSION',
];

/** Files that never ship, matched on the path relative to the project root. */
const EXCLUDED_FILES = [
    'config/database.local.php',   // the operator's live credentials
    'config/site.local.php',       // the operator's obscured panel path
    '.env',                        // same
    '.gitignore',
    'AGENTS.md',
    'config/release-manifest.json', // generated inside the archive, never trusted from the source tree
    'CLOUD-VERIFICATION-SUMMARY.md',
    'Quaeriva-0.7.1-stabilization-handoff.md',
    'ask-joflows-spec.md',
    'quaero-qa-plan-v1.md',
    'ask-joflows-design-system.html',
];

/** Patterns that never ship, whatever their location. */
const EXCLUDED_PATTERNS = [
    '~(^|/)\.~',                   // dotfiles, except the .htaccess files added back below
    '~(^|/)tmp_~',                 // scratch scripts
    '~\.(zip|tar|gz|log|bak)$~i',
    '~(^|/)\.quaeriva-installed~', // installation locks
    '~-IMPLEMENTATION-SUMMARY[^/]*\.md$~i',  // internal status reports
    '~^SESSION-FIXES[^/]*\.md$~i',
];

/**
 * Dotfiles that must ship despite the dotfile rule -- and, for storage/, despite
 * its directory being excluded. The runtime contents of storage/ are not
 * shipped, but the rule that keeps logs and session files off the web is, or
 * every installed site would be missing the protection this one has.
 */
const REQUIRED_DOTFILES = ['.htaccess', 'admin/.htaccess', 'public/.htaccess', 'public/uploads/.htaccess', 'install/.htaccess', 'storage/.htaccess', '.env.example'];
const RELEASE_INVENTORY_FILE = 'config/release-inventory.json';

$version = app_version();
$archiveName = 'Quaeriva-' . $version . '.zip';
$archivePath = $root . DIRECTORY_SEPARATOR . 'dist' . DIRECTORY_SEPARATOR . $archiveName;

/** @return array{include:list<string>,refused:list<string>,unexpected:list<string>,unsafe:list<string>} */
function collect(string $root): array
{
    $include = [];
    $refused = [];
    $unexpected = [];
    $unsafe = [];
    $canonicalRoot = realpath($root);
    if ($canonicalRoot === false) {
        throw new RuntimeException('Could not resolve the release root.');
    }
    $canonicalPrefix = rtrim($canonicalRoot, "\\/") . DIRECTORY_SEPARATOR;

    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::SELF_FIRST
    );

    foreach ($iterator as $item) {
        /** @var SplFileInfo $item */
        $relative = str_replace('\\', '/', substr($item->getPathname(), strlen($root) + 1));

        if ($item->isLink()) {
            $unsafe[] = $relative . ' is a symbolic link';
            continue;
        }

        $topLevel = explode('/', $relative)[0];
        if (str_starts_with($relative, 'docs/superpowers/') || str_starts_with($relative, 'docs/archive/')) continue;
        if (in_array($topLevel, EXCLUDED_DIRS, true) && !in_array($relative, REQUIRED_DOTFILES, true)) {
            continue;
        }

        $allowedTopLevel = in_array($topLevel, ALLOWED_TOP_LEVEL_DIRECTORIES, true)
            || (count(explode('/', $relative)) === 1 && in_array($relative, ALLOWED_TOP_LEVEL_FILES, true));
        // A file the patterns already reject is excluded, not "unexpected" --
        // otherwise a dated status report in the working tree blocks the build.
        $patternExcluded = false;
        foreach (EXCLUDED_PATTERNS as $pattern) {
            if (preg_match($pattern, $relative) === 1) { $patternExcluded = true; break; }
        }
        if ($patternExcluded && !in_array($relative, REQUIRED_DOTFILES, true)) {
            if ($item->isFile()) $refused[] = $relative;
            continue;
        }

        if (!$allowedTopLevel && !in_array($relative, REQUIRED_DOTFILES, true) && !in_array($relative, EXCLUDED_FILES, true)) {
            if (!in_array($topLevel, $unexpected, true)) {
                $unexpected[] = $topLevel;
            }
            continue;
        }

        if (!$item->isFile()) {
            continue;
        }

        $resolved = realpath($item->getPathname());
        if ($resolved === false || !str_starts_with(strtolower($resolved), strtolower($canonicalPrefix))) {
            $unsafe[] = $relative . ' resolves outside the release root';
            continue;
        }

        if (in_array($relative, REQUIRED_DOTFILES, true)) {
            $include[] = $relative;
            continue;
        }

        if (in_array($relative, EXCLUDED_FILES, true)) {
            $refused[] = $relative;
            continue;
        }

        foreach (EXCLUDED_PATTERNS as $pattern) {
            if (preg_match($pattern, $relative) === 1) {
                $refused[] = $relative;
                continue 2;
            }
        }

        $include[] = $relative;
    }

    sort($include);
    sort($refused);
    sort($unexpected);
    sort($unsafe);

    return ['include' => $include, 'refused' => $refused, 'unexpected' => $unexpected, 'unsafe' => $unsafe];
}

$result = collect($root);
$printInventory = in_array('--print-inventory', $argv, true);
if ($printInventory) {
    if ($result['unsafe'] !== []) {
        fwrite(STDERR, "Refusing to inventory unsafe paths:\n- " . implode("\n- ", $result['unsafe']) . "\n");
        exit(1);
    }
    echo json_encode(['schema' => 1, 'files' => $result['include']], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
    exit;
}

printf("Quaeriva packaging %s\n", $version);
printf("  files to ship: %d\n", count($result['include']) + 1);
printf("  excluded:      %d\n", count($result['refused']));

// Anything excluded is reported rather than silently dropped, so an
// unexpected entry is noticed instead of shipping unnoticed next time.
foreach ($result['refused'] as $file) {
    printf("    excluded: %s\n", $file);
}

/**
 * Refuse to build when something that must never ship is still present in the
 * working tree in a location the archive would otherwise pick up.
 */
$fatal = [];
foreach ($result['unsafe'] as $reason) {
    $fatal[] = $reason;
}
foreach ($result['unexpected'] as $entry) {
    $fatal[] = $entry . ' is not in the release allowlist';
}
$inventoryPath = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, RELEASE_INVENTORY_FILE);
$inventory = is_file($inventoryPath) ? json_decode((string) file_get_contents($inventoryPath), true) : null;
$inventoryFiles = is_array($inventory) && is_array($inventory['files'] ?? null)
    ? array_values(array_map('strval', $inventory['files']))
    : [];
sort($inventoryFiles);
if (($inventory['schema'] ?? null) !== 1 || $inventoryFiles === [] || count($inventoryFiles) !== count(array_unique($inventoryFiles))) {
    $fatal[] = RELEASE_INVENTORY_FILE . ' is missing or malformed';
} else {
    foreach (array_diff($result['include'], $inventoryFiles) as $file) $fatal[] = $file . ' is not in the reviewed release inventory';
    foreach (array_diff($inventoryFiles, $result['include']) as $file) $fatal[] = $file . ' is missing from the source tree';
}
foreach (['config/database.local.php', 'config/site.local.php', '.env'] as $secret) {
    if (is_file($root . DIRECTORY_SEPARATOR . $secret) && !in_array($secret, $result['refused'], true)) {
        $fatal[] = $secret . ' would have shipped';
    }
}
if (is_file($root . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'release-manifest.json')) {
    $fatal[] = 'config/release-manifest.json must be generated, not stored in the source tree';
}
foreach ($result['include'] as $file) {
    if (str_starts_with($file, 'tests/') || str_starts_with($file, 'docs/superpowers/') || str_starts_with($file, 'docs/archive/') || str_starts_with($file, 'dist/')) {
        $fatal[] = $file . ' is development material';
    }
    if (preg_match('~\.(zip|log)$~i', $file) === 1) {
        $fatal[] = $file . ' is build or runtime output';
    }
}

if ($fatal !== []) {
    fwrite(STDERR, "\nREFUSED to package:\n");
    foreach ($fatal as $reason) {
        fwrite(STDERR, '  - ' . $reason . "\n");
    }
    exit(1);
}

// The routing/security .htaccess files are required for project-root hosting;
// admin/.htaccess specifically prevents mod_dir from redirecting POST /admin.
foreach (['.htaccess', 'admin/.htaccess', 'public/.htaccess', 'install/.htaccess', 'storage/.htaccess', '.env.example'] as $required) {
    if (!in_array($required, $result['include'], true)) {
        fwrite(STDERR, "\nREFUSED to package: missing required file " . $required . "\n");
        exit(1);
    }
}

// This entry is generated below and is deliberately absent from collect().
$requiredGeneratedEntries = ['config/release-manifest.json'];

if ($checkOnly) {
    echo "\nCheck only; no archive written.\n";
    exit(0);
}

if (!class_exists('ZipArchive')) {
    fwrite(STDERR, "\nZipArchive is not available; cannot build the archive.\n");
    exit(1);
}

if (!is_dir(dirname($archivePath)) && !mkdir(dirname($archivePath), 0755, true) && !is_dir(dirname($archivePath))) {
    fwrite(STDERR, "\nCould not create the dist directory.\n");
    exit(1);
}

$zip = new ZipArchive();
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
    fwrite(STDERR, "\nCould not open " . $archivePath . " for writing.\n");
    exit(1);
}

// Building on Windows stamps every entry with the Unix mode 0666, and the
// cPanel/LiteSpeed extractor honours the mode stored in the archive: every PHP
// file lands group- and world-writable, LSAPI refuses to execute a script it can
// write to, and the whole site answers 403 with nothing in the PHP log. The
// archive therefore states the modes it means to produce rather than inheriting
// whatever the build host happened to use.
$directories = [];
$manifestFiles = [];

foreach ($result['include'] as $file) {
    $entry = 'quaeriva/' . $file;
    $zip->addFile($root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $file), $entry);
    $zip->setExternalAttributesName($entry, ZipArchive::OPSYS_UNIX, 0100644 << 16);
    $manifestFiles[$file] = hash_file('sha256', $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $file));

    $segments = explode('/', $file);
    array_pop($segments);
    $prefix = 'quaeriva/';
    foreach ($segments as $segment) {
        $prefix .= $segment . '/';
        $directories[$prefix] = true;
    }
}

$builtAt = gmdate('c');
$contentFingerprint = hash('sha256', json_encode($manifestFiles, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$buildId = $version . '-' . gmdate('Ymd-His') . '-' . substr($contentFingerprint, 0, 12);
$manifest = json_encode([
    'product' => 'Quaeriva',
    'version' => $version,
    'build_id' => $buildId,
    'built_at' => $builtAt,
    'hash_algorithm' => 'sha256',
    'files' => $manifestFiles,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
$manifestEntry = 'quaeriva/' . $requiredGeneratedEntries[0];
$zip->addFromString($manifestEntry, $manifest);
$zip->setExternalAttributesName($manifestEntry, ZipArchive::OPSYS_UNIX, 0100644 << 16);
$directories['quaeriva/config/'] = true;

// addFile creates directories implicitly, and an implicit directory carries no
// mode at all -- some extractors then fall back to the archive default rather
// than the account umask. Naming them explicitly is what makes 0755 stick.
$directories['quaeriva/'] = true;
foreach (array_keys($directories) as $directory) {
    if ($zip->locateName($directory) === false) {
        $zip->addEmptyDir($directory);
    }
    $zip->setExternalAttributesName($directory, ZipArchive::OPSYS_UNIX, 040755 << 16);
}

$zip->close();

printf("\nWrote %s (%s)\n", 'dist/' . $archiveName, number_format((float) filesize($archivePath) / 1024, 1) . ' KB');
echo "Extract it and re-run verification against the extracted copy before release.\n";
