<?php

declare(strict_types=1);

namespace PpPhp\Core\Migrate;

use PpPhp\Core\Deploy\TransportInterface;

/**
 * Phase "transport": push a packaged bundle to the destination over SFTP/FTP.
 *
 * Robust across flaky shared-hosting links:
 *   • RESUMABLE — a file whose remote size already matches the local size is
 *     skipped, so a re-run after a dropped connection only sends what is missing.
 *   • ORDERED — the agent + manifest go LAST, so the destination never sees a
 *     "ready" agent before its payload is fully present.
 *   • VERIFIED — byte-for-byte integrity is proven afterwards by the agent's
 *     `verify` action (re-hashing every artifact against the manifest); size-match
 *     here is only the cheap resumability check, not the integrity guarantee.
 *
 * It never throws: every failure is collected and returned so the runner can stop
 * cleanly and tell the operator exactly which file did not make it.
 */
final class BundleTransporter
{
    /** @var array<string,bool> memo of remote dirs already ensured */
    private array $dirs = [];

    public function __construct(
        private TransportInterface $transport,
        private string $bundleDir,
        private string $remoteBase,
    ) {}

    /**
     * @param callable(string):void $log
     * @return array{ok:bool,uploaded:int,skipped:int,failed:list<string>,bytes:int}
     */
    public function upload(callable $log): array
    {
        $uploaded = 0; $skipped = 0; $bytes = 0; $failed = [];

        $this->transport->connect();

        // Deferred entries (agent + manifest) are sent only after the payload.
        $deferred = [];
        foreach ($this->files() as $rel) {
            if ($rel === 'manifest.json'
                || $rel === AgentBuilder::AGENT_FILE
                || $rel === AgentBuilder::META_FILE) {
                $deferred[] = $rel;
                continue;
            }
            $this->one($rel, $uploaded, $skipped, $bytes, $failed, $log);
        }
        foreach ($deferred as $rel) {
            $this->one($rel, $uploaded, $skipped, $bytes, $failed, $log);
        }

        $this->transport->close();

        return ['ok' => $failed === [], 'uploaded' => $uploaded, 'skipped' => $skipped,
                'failed' => $failed, 'bytes' => $bytes];
    }

    /** Upload (or skip) one bundle-relative file. */
    private function one(string $rel, int &$uploaded, int &$skipped, int &$bytes, array &$failed, callable $log): void
    {
        $local  = $this->bundleDir . '/' . $rel;
        $remote = $this->remoteBase . '/' . $rel;
        $size   = (int) @filesize($local);

        $this->ensureRemoteDir(dirname($remote));

        // Resumable: skip when the remote copy is already the right size.
        $stat = $this->transport->stat($remote);
        if ($stat !== null && (int) ($stat['size'] ?? -1) === $size) {
            $skipped++;
            $log('  = ' . $rel . ' (already present)');
            return;
        }

        if ($this->transport->put($local, $remote)) {
            $uploaded++; $bytes += $size;
            $log('  ↑ ' . $rel . ' (' . MigrationInventory::human($size) . ')');
        } else {
            $failed[] = $rel;
            $log('  ✗ ' . $rel . ' — upload failed');
        }
    }

    /** Ensure a remote directory (memoised). */
    private function ensureRemoteDir(string $dir): void
    {
        $dir = rtrim($dir, '/');
        if ($dir === '' || isset($this->dirs[$dir])) { return; }
        $this->transport->ensureDir($dir);
        $this->dirs[$dir] = true;
    }

    /** All bundle-relative file paths (forward-slashed), directories excluded. */
    private function files(): array
    {
        $out = [];
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($this->bundleDir, \FilesystemIterator::SKIP_DOTS)
        );
        foreach ($it as $f) {
            if ($f->isFile()) {
                $rel = str_replace('\\', '/', ltrim(str_replace($this->bundleDir, '', $f->getPathname()), '/\\'));
                $out[] = $rel;
            }
        }
        sort($out);
        return $out;
    }
}
