<?php

declare(strict_types=1);

namespace Atdt;

/**
 * Door persistence (SPEC §13.4): a slug-scoped key/value store, door-owned
 * tables physically named door_<slug>_<name>, and a sandboxed file area
 * under data/doorstore/<slug>/. Doors never see PDO or core tables — this
 * is the whole wall.
 */
final class DoorStore
{
    public function __construct(
        private readonly Db $db,
        private readonly string $slug,
    ) {
    }

    // ---- key/value (door_kv, slug-scoped) --------------------------------

    public function get(string $k, ?string $default = null): ?string
    {
        $v = $this->db->val('SELECT v FROM door_kv WHERE door_slug = ? AND k = ?', [$this->slug, $k]);
        return $v === null ? $default : (string)$v;
    }

    public function set(string $k, string $v): void
    {
        // A native upsert (1.7.4): the old update-then-insert read
        // MySQL's changed-rows count as "no row" whenever a door wrote
        // a value equal to what it had, and the insert then hit the
        // primary key. Every door crashed on its second entry.
        $this->db->upsert('door_kv', ['door_slug' => $this->slug, 'k' => $k, 'v' => $v], ['door_slug', 'k']);
    }

    public function del(string $k): void
    {
        $this->db->q('DELETE FROM door_kv WHERE door_slug = ? AND k = ?', [$this->slug, $k]);
    }

    /** @return array<string, string> keys (optionally by prefix) => values */
    public function all(string $prefix = ''): array
    {
        // '!' as the LIKE escape: literal and identical on both dialects
        // (a bare backslash reads differently in MySQL string syntax).
        $rows = $prefix === ''
            ? $this->db->all('SELECT k, v FROM door_kv WHERE door_slug = ? ORDER BY k', [$this->slug])
            : $this->db->all(
                "SELECT k, v FROM door_kv WHERE door_slug = ? AND k LIKE ? ESCAPE '!' ORDER BY k",
                [$this->slug, str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $prefix) . '%']
            );
        $out = [];
        foreach ($rows as $r) {
            $out[(string)$r['k']] = (string)$r['v'];
        }
        return $out;
    }

    // ---- door-owned tables ----------------------------------------------

    public function table(string $name): DoorTable
    {
        if (preg_match('/^[a-z][a-z0-9_]{0,23}$/', $name) !== 1) {
            throw new \InvalidArgumentException("Bad door table name '$name'");
        }
        return new DoorTable($this->db, 'door_' . str_replace('-', '_', $this->slug) . '_' . $name);
    }

    /** Run work in one transaction (universe generation batches). */
    public function tx(callable $fn): mixed
    {
        return $this->db->tx($fn);
    }

    /**
     * Delete EVERYTHING a door ever stored (1.6.0): its door_<slug>_*
     * tables, its door_kv rows, and its data/doorstore sandbox. Used by
     * the console's remove-with-data option; the door's folder on disk
     * is the sysop's to delete (or keep, for a clean re-install).
     */
    public static function purge(Db $db, string $slug): int
    {
        if (preg_match('/^[a-z0-9][a-z0-9_-]{1,31}$/', $slug) !== 1) {
            return 0;
        }
        // Underscores in LIKE are wildcards: escape them, or purging
        // door "hilo" could also match a door named "hiloa"'s tables.
        $prefix = 'door_' . str_replace('-', '_', $slug) . '_';
        $like = str_replace('_', '\\_', $prefix) . '%';
        $tables = $db->dialect() === Db::MYSQL
            ? $db->all(
                "SELECT table_name AS t FROM information_schema.tables
                 WHERE table_schema = DATABASE() AND table_name LIKE ? ESCAPE '\\\\'",
                [$like]
            )
            : $db->all(
                "SELECT name AS t FROM sqlite_master WHERE type = 'table' AND name LIKE ? ESCAPE '\\'",
                [$like]
            );
        $dropped = 0;
        foreach ($tables as $row) {
            $db->exec($db->dropTableSql((string)$row['t']));
            $dropped++;
        }
        $db->q('DELETE FROM door_kv WHERE door_slug = ?', [$slug]);
        $dir = ATDT_ROOT . '/data/doorstore/' . $slug;
        if (is_dir($dir)) {
            self::rmTree($dir);
        }
        return $dropped;
    }

    /** Recursive delete, sandbox-internal only. */
    private static function rmTree(string $dir): void
    {
        foreach (scandir($dir) ?: [] as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            $path = $dir . '/' . $entry;
            if (is_dir($path) && !is_link($path)) {
                self::rmTree($path);
            } else {
                @unlink($path);
            }
        }
        @rmdir($dir);
    }

    // ---- sandboxed files -------------------------------------------------

    /** Absolute directory for this door's files, created on demand. */
    public function filesDir(): string
    {
        $dir = ATDT_ROOT . '/data/doorstore/' . $this->slug;
        if (!is_dir($dir)) {
            @mkdir($dir, 0755, true);
        }
        return $dir;
    }

    /** Resolve a relative path INSIDE the sandbox; refuses escapes. */
    public function path(string $rel): string
    {
        $base = $this->filesDir();
        if (preg_match('#(^|/)\.\.(/|$)#', $rel) || str_starts_with($rel, '/')) {
            throw new \InvalidArgumentException('Door file paths stay inside the sandbox');
        }
        $full = $base . '/' . $rel;
        $dir = dirname($full);
        if (!is_dir($dir)) {
            @mkdir($dir, 0755, true);
        }
        $realBase = realpath($base);
        $realDir = realpath($dir);
        if ($realBase === false || $realDir === false || !str_starts_with($realDir . '/', $realBase . '/')) {
            throw new \InvalidArgumentException('Door file paths stay inside the sandbox');
        }
        return $full;
    }
}
