<?php

declare(strict_types=1);

namespace Atdt\Dos;

use Atdt\Db;

/**
 * The dosfs operations (SPEC-DOSDOORS §9.2-§9.5): stateless, path-
 * jailed file access under data/doorstore/dos/<slug>/. The DOS-side
 * handle table lives in the browser shim; PHP never sees a handle.
 * Every write runs under flock so two nodes can never tear a record,
 * and every mutation bumps the door's generation counter, which is
 * how the shims know to drop their shared-file caches.
 */
final class Fs
{
    /** §9.3: bytes DOS filenames may carry, beyond dot and slash. */
    private const NAME_RE = "/^[A-Z0-9_\\-~!#\$%&()@^{}'`\\. ]+$/";

    // ---- the jail (§9.3) --------------------------------------------------

    /**
     * Canonicalize and jail a DOS path. Uppercase, backslashes to
     * forward slashes, 8.3-ish segments, no dot-dot, no drive letters,
     * and the result must stay inside the door's state directory.
     * Returns [absolute path, canonical relative] or null (logged).
     */
    public static function jail(string $slug, string $path): ?array
    {
        $p = strtoupper(str_replace('\\', '/', trim($path)));
        $p = ltrim($p, '/');
        if ($p === '' || str_contains($p, '..') || preg_match('/^[A-Z]:/', $p) === 1) {
            return self::refuse($slug, $path);
        }
        $parts = [];
        foreach (explode('/', $p) as $seg) {
            if ($seg === '' || $seg === '.') {
                continue;
            }
            if (preg_match(self::NAME_RE, $seg) !== 1) {
                return self::refuse($slug, $path);
            }
            $parts[] = $seg;
        }
        if ($parts === []) {
            return self::refuse($slug, $path);
        }
        $rel = implode('/', $parts);
        $base = Doors::stateDir($slug);
        $full = $base . '/' . $rel;
        $dir = realpath(dirname($full));
        $baseReal = realpath($base);
        if ($baseReal === false || $dir === false || !str_starts_with($dir . '/', $baseReal . '/')) {
            if ($dir !== false || $baseReal === false) {
                return self::refuse($slug, $path);
            }
            // Parent does not exist yet (mkdir of a fresh subdir): the
            // string check above already proved containment.
        }
        return [$full, $rel];
    }

    private static function refuse(string $slug, string $path): ?array
    {
        atdt_log_event('security', "dosdoor:$slug", 'Path refused: ' . mb_strimwidth($path, 0, 120));
        return null;
    }

    /** The directory part of a jailed path for list operations. */
    public static function jailDir(string $slug, string $dir): ?array
    {
        $dir = trim($dir);
        if ($dir === '' || $dir === '\\' || $dir === '/' || $dir === '.') {
            return [Doors::stateDir($slug), ''];
        }
        return self::jail($slug, $dir);
    }

    // ---- the generation counter (§9.4) ------------------------------------

    public static function gen(string $slug): int
    {
        $f = Doors::stateDir($slug) . '.gen';
        $v = @file_get_contents($f);
        return $v === false ? 0 : (int)$v;
    }

    public static function bumpGen(string $slug): int
    {
        $f = Doors::stateDir($slug) . '.gen';
        $fh = fopen($f, 'c+');
        if ($fh === false) {
            return 0;
        }
        flock($fh, LOCK_EX);
        $v = (int)stream_get_contents($fh) + 1;
        ftruncate($fh, 0);
        rewind($fh);
        fwrite($fh, (string)$v);
        fflush($fh);
        flock($fh, LOCK_UN);
        fclose($fh);
        return $v;
    }

    // ---- DOS attribute and timestamp mapping (§9.2) -----------------------

    private static function attr(string $full): int
    {
        $a = 0x20;                                       // archive, always
        if (is_dir($full)) {
            $a |= 0x10;
        }
        if (!is_writable($full)) {
            $a |= 0x01;                                  // read-only
        }
        return $a;
    }

    /** DOS packed date/time (high word date, low word time) as one int. */
    private static function dtime(int $unix): int
    {
        $y = (int)date('Y', $unix);
        $date = ((max(1980, $y) - 1980) << 9) | ((int)date('n', $unix) << 5) | (int)date('j', $unix);
        $time = ((int)date('G', $unix) << 11) | ((int)date('i', $unix) << 5) | ((int)date('s', $unix) >> 1);
        return ($date << 16) | $time;
    }

    private static function unixFromDtime(int $dt): int
    {
        $date = ($dt >> 16) & 0xFFFF;
        $time = $dt & 0xFFFF;
        $ts = mktime(
            ($time >> 11) & 0x1F,
            ($time >> 5) & 0x3F,
            ($time & 0x1F) * 2,
            ($date >> 5) & 0x0F,
            $date & 0x1F,
            1980 + (($date >> 9) & 0x7F)
        );
        return $ts === false ? time() : $ts;
    }

    /**
     * DOS wildcard match, the FCB way, byte-identical to the client
     * shim's maskMatch (dosdoor.js): name and mask both normalize to
     * the 11-character FCB form and compare position by position, ?
     * matching anything including the space padding. The old regex
     * translation demanded a literal dot, so DIR's real-world mask
     * "????????.???" missed every extensionless name -- which is most
     * DIRECTORIES: MD FOO then DIR showed no FOO, though CD (a stat,
     * no mask) walked right in.
     */
    public static function matchMask(string $name, string $mask): bool
    {
        if ($mask === '' || $mask === '*.*' || $mask === '*') {
            return true;
        }
        $t = self::fcbTemplate($mask);
        $f = self::fcbName($name);
        for ($i = 0; $i < 11; $i++) {
            if ($t[$i] !== '?' && $t[$i] !== $f[$i]) {
                return false;
            }
        }
        return true;
    }

    /** "FILE.TXT" -> "FILE    TXT": the 11-character FCB form of a name. */
    private static function fcbName(string $name): string
    {
        $name = strtoupper($name);
        $dot = strrpos($name, '.');
        $base = $dot === false ? $name : substr($name, 0, $dot);
        $ext = $dot === false ? '' : substr($name, $dot + 1);
        return substr(str_pad($base, 8), 0, 8) . substr(str_pad($ext, 3), 0, 3);
    }

    /** "FILE*.D?T" -> "FILE????D?T": the FCB template of a wildcard mask. */
    private static function fcbTemplate(string $mask): string
    {
        $mask = strtoupper($mask);
        $dot = strrpos($mask, '.');
        $base = $dot === false ? $mask : substr($mask, 0, $dot);
        $ext = $dot === false ? '*' : substr($mask, $dot + 1);
        $pad = static function (string $s, int $n): string {
            $star = strpos($s, '*');
            if ($star !== false) {
                $s = substr($s, 0, $star) . substr('????????', 0, max(0, $n - $star));
            }
            return substr(str_pad($s, $n), 0, $n);
        };
        return $pad($base, 8) . $pad($ext, 3);
    }

    // ---- the operations (§9.2) --------------------------------------------

    /**
     * Run one dosfs op. $p carries the request fields; binary payloads
     * are base64 both ways. Every response includes the current gen.
     * @return array<string, mixed>
     */
    public static function op(Db $db, string $slug, int $sessionId, string $op, array $p): array
    {
        $out = match ($op) {
            'stat' => self::stat($slug, (string)($p['path'] ?? '')),
            'list' => self::list($slug, (string)($p['dir'] ?? ''), (string)($p['pattern'] ?? '*.*'), (int)($p['attr_mask'] ?? 0)),
            'read' => self::read($slug, (string)($p['path'] ?? ''), (int)($p['offset'] ?? 0), (int)($p['length'] ?? 0)),
            'write' => self::write($db, $slug, (string)($p['path'] ?? ''), (int)($p['offset'] ?? 0), (string)($p['data'] ?? '')),
            'create' => self::create($db, $slug, (string)($p['path'] ?? ''), (int)($p['attr'] ?? 0x20), (bool)($p['truncate'] ?? true)),
            'truncate' => self::truncate($db, $slug, (string)($p['path'] ?? ''), (int)($p['size'] ?? 0)),
            'delete' => self::delete($db, $slug, (string)($p['path'] ?? '')),
            'rename' => self::rename($db, $slug, (string)($p['path'] ?? ''), (string)($p['newpath'] ?? '')),
            'mkdir' => self::mkdir($db, $slug, (string)($p['path'] ?? '')),
            'rmdir' => self::rmdir($db, $slug, (string)($p['path'] ?? '')),
            'setattr' => self::setattr($db, $slug, (string)($p['path'] ?? ''), $p),
            'space' => ['ok' => true, 'free' => 32 * 1024 * 1024, 'total' => 64 * 1024 * 1024],
            'lock' => Sessions::lock($db, $slug, $sessionId, (string)($p['path'] ?? ''), (int)($p['offset'] ?? 0), (int)($p['length'] ?? 0)),
            'unlock' => Sessions::unlock($db, $slug, $sessionId, (string)($p['path'] ?? ''), (int)($p['offset'] ?? 0), (int)($p['length'] ?? 0)),
            default => ['ok' => false, 'error' => 'unknown op'],
        };
        $out['gen'] = self::gen($slug);
        return $out;
    }

    private static function stat(string $slug, string $path): array
    {
        $j = self::jail($slug, $path);
        if ($j === null) {
            return ['ok' => false, 'error' => 'bad path'];
        }
        [$full] = $j;
        if (!file_exists($full)) {
            return ['ok' => true, 'exists' => false];
        }
        return ['ok' => true, 'exists' => true, 'size' => is_dir($full) ? 0 : (int)filesize($full),
            'attr' => self::attr($full), 'dtime' => self::dtime((int)filemtime($full))];
    }

    private static function list(string $slug, string $dir, string $pattern, int $attrMask): array
    {
        $j = self::jailDir($slug, $dir);
        if ($j === null) {
            return ['ok' => false, 'error' => 'bad path'];
        }
        [$full] = $j;
        if (!is_dir($full)) {
            return ['ok' => false, 'error' => 'no such directory'];
        }
        $entries = [];
        foreach (glob($full . '/*') ?: [] as $f) {
            $name = basename($f);
            if (!self::matchMask($name, $pattern)) {
                continue;
            }
            $isDir = is_dir($f);
            if ($isDir && ($attrMask & 0x10) === 0) {
                continue;                                // directories only when asked for
            }
            $entries[] = ['name' => $name, 'attr' => self::attr($f),
                'size' => $isDir ? 0 : (int)filesize($f), 'dtime' => self::dtime((int)filemtime($f))];
        }
        return ['ok' => true, 'entries' => $entries];
    }

    private static function read(string $slug, string $path, int $offset, int $length): array
    {
        $j = self::jail($slug, $path);
        if ($j === null) {
            return ['ok' => false, 'error' => 'bad path'];
        }
        [$full] = $j;
        if (!is_file($full)) {
            return ['ok' => false, 'error' => 'no such file'];
        }
        $length = max(0, min($length, 65535));
        $fh = fopen($full, 'rb');
        if ($fh === false) {
            return ['ok' => false, 'error' => 'open failed'];
        }
        flock($fh, LOCK_SH);
        fseek($fh, max(0, $offset));
        $data = $length === 0 ? '' : (string)fread($fh, $length);
        $size = (int)fstat($fh)['size'];
        flock($fh, LOCK_UN);
        fclose($fh);
        return ['ok' => true, 'data' => base64_encode($data), 'eof' => $offset + strlen($data) >= $size];
    }

    /** §9.5 write atomicity: open-lock-seek-write-flush-unlock, always. */
    private static function write(Db $db, string $slug, string $path, int $offset, string $b64): array
    {
        $j = self::jail($slug, $path);
        if ($j === null) {
            return ['ok' => false, 'error' => 'bad path'];
        }
        [$full] = $j;
        $data = base64_decode($b64, true);
        if ($data === false) {
            return ['ok' => false, 'error' => 'bad data'];
        }
        $fh = fopen($full, 'c+b');
        if ($fh === false) {
            return ['ok' => false, 'error' => 'open failed'];
        }
        flock($fh, LOCK_EX);
        fseek($fh, max(0, $offset));
        $written = (int)fwrite($fh, $data);
        fflush($fh);
        $size = (int)fstat($fh)['size'];
        flock($fh, LOCK_UN);
        fclose($fh);
        self::bumpGen($slug);
        return ['ok' => true, 'written' => $written, 'size' => $size];
    }

    private static function create(Db $db, string $slug, string $path, int $attr, bool $truncate): array
    {
        $j = self::jail($slug, $path);
        if ($j === null) {
            return ['ok' => false, 'error' => 'bad path'];
        }
        [$full] = $j;
        if (!is_dir(dirname($full))) {
            return ['ok' => false, 'error' => 'no such directory'];
        }
        $fh = fopen($full, $truncate ? 'w+b' : 'c+b');
        if ($fh === false) {
            return ['ok' => false, 'error' => 'create failed'];
        }
        $size = (int)fstat($fh)['size'];
        fclose($fh);
        self::bumpGen($slug);
        return ['ok' => true, 'size' => $size, 'attr' => self::attr($full),
            'dtime' => self::dtime((int)filemtime($full))];
    }

    private static function truncate(Db $db, string $slug, string $path, int $size): array
    {
        $j = self::jail($slug, $path);
        if ($j === null || !is_file($j[0])) {
            return ['ok' => false, 'error' => 'no such file'];
        }
        $fh = fopen($j[0], 'c+b');
        if ($fh === false) {
            return ['ok' => false, 'error' => 'open failed'];
        }
        flock($fh, LOCK_EX);
        ftruncate($fh, max(0, $size));
        flock($fh, LOCK_UN);
        fclose($fh);
        self::bumpGen($slug);
        return ['ok' => true];
    }

    /** DELETE may carry wildcards (EDF5 0x13). */
    private static function delete(Db $db, string $slug, string $path): array
    {
        $dir = str_replace('\\', '/', $path);
        $mask = basename($dir);
        $parent = dirname($dir);
        if (str_contains($mask, '*') || str_contains($mask, '?')) {
            $j = self::jailDir($slug, $parent === '.' ? '' : $parent);
            if ($j === null) {
                return ['ok' => false, 'error' => 'bad path'];
            }
            $hit = 0;
            foreach (glob($j[0] . '/*') ?: [] as $f) {
                if (is_file($f) && self::matchMask(basename($f), $mask)) {
                    @unlink($f);
                    $hit++;
                }
            }
            if ($hit > 0) {
                self::bumpGen($slug);
            }
            return $hit > 0 ? ['ok' => true] : ['ok' => false, 'error' => 'no match'];
        }
        $j = self::jail($slug, $path);
        if ($j === null || !is_file($j[0])) {
            return ['ok' => false, 'error' => 'no such file'];
        }
        @unlink($j[0]);
        self::bumpGen($slug);
        return ['ok' => true];
    }

    private static function rename(Db $db, string $slug, string $from, string $to): array
    {
        $a = self::jail($slug, $from);
        $b = self::jail($slug, $to);
        if ($a === null || $b === null || !file_exists($a[0])) {
            return ['ok' => false, 'error' => 'bad path'];
        }
        if (!@rename($a[0], $b[0])) {
            return ['ok' => false, 'error' => 'rename failed'];
        }
        self::bumpGen($slug);
        return ['ok' => true];
    }

    private static function mkdir(Db $db, string $slug, string $path): array
    {
        $j = self::jail($slug, $path);
        if ($j === null) {
            return ['ok' => false, 'error' => 'bad path'];
        }
        if (!@mkdir($j[0], 0755)) {
            return ['ok' => false, 'error' => 'mkdir failed'];
        }
        self::bumpGen($slug);
        return ['ok' => true];
    }

    private static function rmdir(Db $db, string $slug, string $path): array
    {
        $j = self::jail($slug, $path);
        if ($j === null || !is_dir($j[0])) {
            return ['ok' => false, 'error' => 'no such directory'];
        }
        if (!@rmdir($j[0])) {
            return ['ok' => false, 'error' => 'directory not empty'];
        }
        self::bumpGen($slug);
        return ['ok' => true];
    }

    private static function setattr(Db $db, string $slug, string $path, array $p): array
    {
        $j = self::jail($slug, $path);
        if ($j === null || !file_exists($j[0])) {
            return ['ok' => false, 'error' => 'no such file'];
        }
        if (isset($p['dtime'])) {
            @touch($j[0], self::unixFromDtime((int)$p['dtime']));
        }
        if (isset($p['attr'])) {
            @chmod($j[0], ((int)$p['attr'] & 0x01) !== 0 ? 0444 : 0644);
        }
        self::bumpGen($slug);
        return ['ok' => true];
    }
}
