#!/usr/bin/env php
<?php

declare(strict_types=1);

const M7_MANIFEST_NAME = 'm7-identity-web-php-MANIFEST.json';
const M7_RECEIPT_NAME = 'm7-identity-web-php-install-receipt.json';

function m7_usage(): string
{
    return <<<'TEXT'
Usage:
  m7-identity-web-install --target <document-root> --receipt-dir <directory>
                          [--source-control-revision <revision>]

Installs the package-owned m7_sso_session directory into an existing public
document root. The receipt directory must exist outside the document root.

The command never merges with or overwrites an existing m7_sso_session path,
manifest, or receipt. Use the documented release/symlink upgrade procedure for
an existing deployment.
TEXT;
}

/** @return array{target: string, receipt_dir: string, source_control_revision: ?string} */
function m7_parse_arguments(array $arguments): array
{
    $values = [
        'target' => null,
        'receipt_dir' => null,
        'source_control_revision' => null,
    ];

    for ($index = 1, $count = count($arguments); $index < $count; $index++) {
        $argument = $arguments[$index];
        if ($argument === '--help' || $argument === '-h') {
            fwrite(STDOUT, m7_usage() . PHP_EOL);
            exit(0);
        }

        $name = null;
        $value = null;
        if (str_starts_with($argument, '--target=')) {
            $name = 'target';
            $value = substr($argument, strlen('--target='));
        } elseif ($argument === '--target') {
            $name = 'target';
        } elseif (str_starts_with($argument, '--receipt-dir=')) {
            $name = 'receipt_dir';
            $value = substr($argument, strlen('--receipt-dir='));
        } elseif ($argument === '--receipt-dir') {
            $name = 'receipt_dir';
        } elseif (str_starts_with($argument, '--source-control-revision=')) {
            $name = 'source_control_revision';
            $value = substr($argument, strlen('--source-control-revision='));
        } elseif ($argument === '--source-control-revision') {
            $name = 'source_control_revision';
        } else {
            throw new InvalidArgumentException('Unknown argument: ' . $argument);
        }

        if ($value === null) {
            $index++;
            if ($index >= $count || str_starts_with($arguments[$index], '--')) {
                throw new InvalidArgumentException('Missing value for --' . str_replace('_', '-', $name));
            }
            $value = $arguments[$index];
        }
        if ($value === '') {
            throw new InvalidArgumentException('Empty value for --' . str_replace('_', '-', $name));
        }
        if ($values[$name] !== null) {
            throw new InvalidArgumentException('Duplicate --' . str_replace('_', '-', $name));
        }
        $values[$name] = $value;
    }

    if (!is_string($values['target']) || !is_string($values['receipt_dir'])) {
        throw new InvalidArgumentException('--target and --receipt-dir are required');
    }
    $revision = $values['source_control_revision'];
    if ($revision !== null
        && (!is_string($revision) || strlen($revision) > 200 || preg_match('/[\x00-\x1F\x7F]/', $revision))) {
        throw new InvalidArgumentException('--source-control-revision is invalid');
    }

    /** @var array{target: string, receipt_dir: string, source_control_revision: ?string} $values */
    return $values;
}

function m7_is_absolute_path(string $path): bool
{
    return str_starts_with($path, '/')
        || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1;
}

function m7_normalize_directory(string $name, string $path): string
{
    if (!m7_is_absolute_path($path)) {
        throw new RuntimeException($name . ' must be an absolute path');
    }
    if (is_link($path) || !is_dir($path)) {
        throw new RuntimeException($name . ' must be an existing non-symlink directory');
    }
    $resolved = realpath($path);
    if (!is_string($resolved) || $resolved === '') {
        throw new RuntimeException($name . ' cannot be resolved');
    }
    if (!is_writable($resolved)) {
        throw new RuntimeException($name . ' is not writable');
    }
    $trimmed = rtrim($resolved, DIRECTORY_SEPARATOR);
    return $trimmed === '' ? DIRECTORY_SEPARATOR : $trimmed;
}

function m7_is_filesystem_root(string $path): bool
{
    if ($path === DIRECTORY_SEPARATOR) {
        return true;
    }
    return preg_match('/^[A-Za-z]:[\\\\\/]?$/', $path) === 1;
}

function m7_path_is_within(string $path, string $parent): bool
{
    $path = rtrim($path, DIRECTORY_SEPARATOR);
    $parent = rtrim($parent, DIRECTORY_SEPARATOR);
    return $path === $parent || str_starts_with($path, $parent . DIRECTORY_SEPARATOR);
}

/** @return list<array{path: string, sha256: string, size: int, mode: int}> */
function m7_collect_files(string $root): array
{
    $records = [];
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::LEAVES_ONLY
    );
    foreach ($iterator as $entry) {
        if ($entry->isLink() || !$entry->isFile()) {
            throw new RuntimeException('Package payload contains a non-regular file: ' . $entry->getPathname());
        }
        $path = $entry->getPathname();
        $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($root) + 1));
        $digest = hash_file('sha256', $path);
        $size = filesize($path);
        $permissions = fileperms($path);
        if (!is_string($digest) || !is_int($size) || !is_int($permissions)) {
            throw new RuntimeException('Unable to inspect package payload: ' . $relative);
        }
        $records[] = [
            'path' => $relative,
            'sha256' => $digest,
            'size' => $size,
            'mode' => $permissions & 0777,
        ];
    }
    usort($records, static fn(array $left, array $right): int => $left['path'] <=> $right['path']);
    if ($records === []) {
        throw new RuntimeException('Package payload is empty');
    }
    return $records;
}

function m7_copy_payload(string $sourceRoot, string $stagingRoot, array $records): void
{
    if (file_exists($stagingRoot) || is_link($stagingRoot)) {
        throw new RuntimeException('Refusing to reuse an existing staging path');
    }
    if (!mkdir($stagingRoot, 0755)) {
        throw new RuntimeException('Unable to create the staging directory');
    }
    foreach ($records as $record) {
        $relative = str_replace('/', DIRECTORY_SEPARATOR, $record['path']);
        $source = $sourceRoot . DIRECTORY_SEPARATOR . $relative;
        $destination = $stagingRoot . DIRECTORY_SEPARATOR . $relative;
        $parent = dirname($destination);
        if (!is_dir($parent) && !mkdir($parent, 0755, true) && !is_dir($parent)) {
            throw new RuntimeException('Unable to create staging directory: ' . $record['path']);
        }
        if (!copy($source, $destination) || !chmod($destination, $record['mode'])) {
            throw new RuntimeException('Unable to stage package file: ' . $record['path']);
        }
        if (hash_file('sha256', $destination) !== $record['sha256']) {
            throw new RuntimeException('Staged package digest mismatch: ' . $record['path']);
        }
    }
}

function m7_remove_staging_directory(string $path): void
{
    if ($path === '' || is_link($path) || !is_dir($path)) {
        return;
    }
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ($iterator as $entry) {
        if ($entry->isLink() || $entry->isFile()) {
            @unlink($entry->getPathname());
        } else {
            @rmdir($entry->getPathname());
        }
    }
    @rmdir($path);
}

function m7_json(array $value): string
{
    $json = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
    return $json . PHP_EOL;
}

function m7_write_exclusive(string $path, string $contents): void
{
    $handle = @fopen($path, 'x');
    if ($handle === false) {
        throw new RuntimeException('Refusing to overwrite existing receipt material: ' . $path);
    }
    $complete = false;
    try {
        try {
            $written = fwrite($handle, $contents);
            if ($written !== strlen($contents) || !fflush($handle)) {
                throw new RuntimeException('Unable to write receipt material: ' . $path);
            }
        } finally {
            fclose($handle);
        }
        if (!chmod($path, 0644)) {
            throw new RuntimeException('Unable to set receipt permissions: ' . $path);
        }
        $complete = true;
    } finally {
        if (!$complete) {
            @unlink($path);
        }
    }
}

function m7_main(array $arguments): int
{
    $options = m7_parse_arguments($arguments);
    $packageRoot = realpath(__DIR__ . '/..');
    if (!is_string($packageRoot) || $packageRoot === '') {
        throw new RuntimeException('Unable to resolve the Composer package root');
    }
    $sourceRoot = $packageRoot . DIRECTORY_SEPARATOR . 'm7_sso_session';
    if (!is_dir($sourceRoot) || is_link($sourceRoot)) {
        throw new RuntimeException('Composer package is missing m7_sso_session');
    }

    $version = trim((string)file_get_contents($packageRoot . DIRECTORY_SEPARATOR . 'VERSION'));
    $metadata = json_decode(
        (string)file_get_contents($packageRoot . DIRECTORY_SEPARATOR . 'package-metadata.json'),
        true,
        32,
        JSON_THROW_ON_ERROR
    );
    $composer = json_decode(
        (string)file_get_contents($packageRoot . DIRECTORY_SEPARATOR . 'composer.json'),
        true,
        32,
        JSON_THROW_ON_ERROR
    );
    if (!is_array($metadata)
        || ($metadata['name'] ?? null) !== 'web-php'
        || ($metadata['version'] ?? null) !== $version
        || ($metadata['install_directory'] ?? null) !== 'm7_sso_session') {
        throw new RuntimeException('Package version or metadata is inconsistent');
    }
    if (!is_array($composer) || ($composer['name'] ?? null) !== 'm7/identity-web-php') {
        throw new RuntimeException('Composer package metadata is inconsistent');
    }

    $documentRoot = m7_normalize_directory('--target', $options['target']);
    $receiptRoot = m7_normalize_directory('--receipt-dir', $options['receipt_dir']);
    if (m7_is_filesystem_root($documentRoot) || m7_is_filesystem_root($receiptRoot)) {
        throw new RuntimeException('Refusing to use a filesystem root as an install or receipt directory');
    }
    if (m7_path_is_within($receiptRoot, $documentRoot)) {
        throw new RuntimeException('The receipt directory must remain outside the public document root');
    }
    if (m7_path_is_within($documentRoot, $packageRoot)
        || m7_path_is_within($packageRoot, $documentRoot)
        || m7_path_is_within($receiptRoot, $packageRoot)
        || m7_path_is_within($packageRoot, $receiptRoot)) {
        throw new RuntimeException('Install and receipt paths must not overlap the Composer package');
    }

    $destination = $documentRoot . DIRECTORY_SEPARATOR . 'm7_sso_session';
    if (file_exists($destination) || is_link($destination)) {
        throw new RuntimeException('Refusing to overwrite existing destination: ' . $destination);
    }
    $manifestPath = $receiptRoot . DIRECTORY_SEPARATOR . M7_MANIFEST_NAME;
    $receiptPath = $receiptRoot . DIRECTORY_SEPARATOR . M7_RECEIPT_NAME;
    if (file_exists($manifestPath) || is_link($manifestPath)
        || file_exists($receiptPath) || is_link($receiptPath)) {
        throw new RuntimeException('Refusing to overwrite an existing manifest or install receipt');
    }

    $records = m7_collect_files($sourceRoot);
    $manifestRecords = array_map(
        static fn(array $record): array => [
            'path' => $record['path'],
            'sha256' => $record['sha256'],
            'size' => $record['size'],
        ],
        $records
    );
    $manifest = [
        'schema_version' => 1,
        'package' => 'web-php',
        'version' => $version,
        'install_directory' => 'm7_sso_session',
        'files' => $manifestRecords,
    ];
    $manifestJson = m7_json($manifest);
    $receipt = [
        'schema_version' => 1,
        'package' => 'web-php',
        'version' => $version,
        'distribution_channel' => 'composer',
        'composer_package' => 'm7/identity-web-php',
        'artifact_sha256' => null,
        'manifest_sha256' => hash('sha256', $manifestJson),
        'installed_at' => gmdate('Y-m-d\\TH:i:s\\Z'),
        'installed_path' => $destination,
        'source_control_revision' => $options['source_control_revision'],
        'local_modifications' => false,
    ];

    $suffix = bin2hex(random_bytes(12));
    $stagingRoot = $documentRoot . DIRECTORY_SEPARATOR . '.m7_sso_session.stage.' . $suffix;
    $manifestWritten = false;
    $receiptWritten = false;
    try {
        m7_copy_payload($sourceRoot, $stagingRoot, $records);
        $stagedRecords = m7_collect_files($stagingRoot);
        if ($stagedRecords !== $records) {
            throw new RuntimeException('Staged tree does not exactly match the package payload');
        }

        m7_write_exclusive($manifestPath, $manifestJson);
        $manifestWritten = true;
        m7_write_exclusive($receiptPath, m7_json($receipt));
        $receiptWritten = true;
        if (!rename($stagingRoot, $destination)) {
            throw new RuntimeException('Unable to atomically install m7_sso_session');
        }
    } catch (Throwable $error) {
        m7_remove_staging_directory($stagingRoot);
        if ($receiptWritten) {
            @unlink($receiptPath);
        }
        if ($manifestWritten) {
            @unlink($manifestPath);
        }
        throw $error;
    }

    fwrite(STDOUT, 'Installed: ' . $destination . PHP_EOL);
    fwrite(STDOUT, 'Manifest: ' . $manifestPath . PHP_EOL);
    fwrite(STDOUT, 'Receipt: ' . $receiptPath . PHP_EOL);
    return 0;
}

try {
    exit(m7_main($_SERVER['argv']));
} catch (Throwable $error) {
    fwrite(STDERR, 'ERROR: ' . $error->getMessage() . PHP_EOL);
    fwrite(STDERR, m7_usage() . PHP_EOL);
    exit(1);
}
