<?php

declare(strict_types=1);

namespace PpPhp\Core\Update;

use PpPhp\Core\Deploy\TransportFactory;
use PpPhp\Core\Deploy\TransportInterface;
use RuntimeException;

/**
 * A read/lightweight-write view of the update host's remote filesystem, so the
 * owner can BROWSE the directory tree, pick the upload directory, and create or
 * delete folders — instead of typing paths blind.
 *
 * A thin wrapper over the Deploy module's transports (FTP/FTPS/SFTP): it reuses
 * their `listDir`/`ensureDir`/`removeDir` primitives. Authority-only — the
 * packager keeps it out of client bundles. Every path is sanitised (no `..`,
 * always absolute) before it reaches the transport.
 */
final class UpdateHostBrowser
{
    /**
     * @param array<string,mixed>  $profile   a TransportFactory profile (with the real secret)
     * @param ?TransportInterface  $transport pre-built transport (tests inject a mock)
     */
    public function __construct(private array $profile, private ?TransportInterface $transport = null) {}

    private function transport(): TransportInterface
    {
        return $this->transport ?? TransportFactory::make($this->profile);
    }

    /** Normalise a remote path: strip `.`/`..`, collapse slashes, force absolute. */
    public static function sanitise(string $path): string
    {
        $path = str_replace('\\', '/', trim($path));
        $parts = [];
        foreach (explode('/', $path) as $seg) {
            if ($seg === '' || $seg === '.') continue;
            if ($seg === '..') { array_pop($parts); continue; }
            $parts[] = $seg;
        }
        return '/' . implode('/', $parts);
    }

    /**
     * List a remote directory (folders first, then files, alphabetical).
     *
     * @return array{path:string, entries:array<int,array{name:string,type:string,size:int,mtime:?int}>}
     */
    public function list(string $path): array
    {
        $path = self::sanitise($path);
        $t = $this->transport();
        $t->connect();
        try {
            $raw = $t->listDir($path);
        } finally {
            try { $t->close(); } catch (\Throwable $e) {}
        }
        usort($raw, static function ($a, $b) {
            $ad = ($a['type'] ?? '') === 'dir';
            $bd = ($b['type'] ?? '') === 'dir';
            if ($ad !== $bd) return $ad ? -1 : 1;
            return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
        });
        return ['path' => $path, 'entries' => array_values($raw)];
    }

    /** Create a folder (and any missing parents). */
    public function makeDir(string $path): bool
    {
        $path = self::sanitise($path);
        if ($path === '/' || $path === '') {
            throw new RuntimeException('Refusing to create the filesystem root.');
        }
        $t = $this->transport();
        $t->connect();
        try {
            $t->ensureDir($path);
            return true;
        } finally {
            try { $t->close(); } catch (\Throwable $e) {}
        }
    }

    /**
     * Delete several remote files over a SINGLE connection (used when pruning a
     * published release's bundle + delta patches). A file that is already gone
     * counts as success. Never throws per-file — returns a summary so the caller
     * can still remove the catalogue rows even if some files resisted.
     *
     * @param array<int,string> $paths
     * @return array{attempted:int, deleted:int, failed:int, error:string}
     */
    public function removeFiles(array $paths, ?callable $onFile = null): array
    {
        $clean = [];
        foreach ($paths as $p) {
            $s = self::sanitise((string) $p);
            if ($s !== '/' && $s !== '') $clean[$s] = true;   // dedupe + drop root
        }
        $clean = array_keys($clean);
        if ($clean === []) return ['attempted' => 0, 'deleted' => 0, 'failed' => 0, 'error' => ''];

        $deleted = 0; $failed = 0; $error = '';
        $t = $this->transport();
        $t->connect();
        try {
            foreach ($clean as $path) {
                $ok = false; $why = '';
                try {
                    $ok = $t->delete($path);
                    if (!$ok && $t->stat($path) === null) $ok = true;   // already gone → fine
                    if ($ok) { $deleted++; }
                    else { $failed++; $why = 'could not delete'; if ($error === '') $error = 'could not delete ' . basename($path); }
                } catch (\Throwable $e) {
                    $failed++; $why = $e->getMessage();
                    if ($error === '') $error = $e->getMessage();
                }
                if ($onFile !== null) { try { $onFile($path, $ok, $why); } catch (\Throwable $e) {} }
            }
        } finally {
            try { $t->close(); } catch (\Throwable $e) {}
        }
        return ['attempted' => count($clean), 'deleted' => $deleted, 'failed' => $failed, 'error' => $error];
    }

    /** Delete an EMPTY folder — refuses if it still has contents (safe by design). */
    public function removeDir(string $path): bool
    {
        $path = self::sanitise($path);
        if ($path === '/' || $path === '') {
            throw new RuntimeException('Refusing to delete the filesystem root.');
        }
        $t = $this->transport();
        $t->connect();
        try {
            $entries = $t->listDir($path);
            if ($entries !== []) {
                throw new RuntimeException('That folder is not empty — remove its contents first.');
            }
            return $t->removeDir($path);
        } finally {
            try { $t->close(); } catch (\Throwable $e) {}
        }
    }
}
