<?php

declare(strict_types=1);

/**
 * STAR MERCHANT — the universe (SPEC §15.2, §15.5). Original work: all
 * names, tables, and rules belong to this project.
 *
 * Tables, generation, pricing, and the pieces both door.php and cron.php
 * share. Everything goes through the door store — no SQL leaves this
 * sandbox.
 */

namespace Doors\StarMerchant;

use Atdt\DoorStore;
use Atdt\DoorTable;

final class Universe
{
    public const BASES = ['ore' => 25, 'bio' => 60, 'tech' => 110];
    public const GOODS = ['ore', 'bio', 'tech'];
    public const GOOD_NAMES = ['ore' => 'Ore', 'bio' => 'Biologics', 'tech' => 'Tech'];
    public const MAX_STOCK = 3000;
    public const CHARTER_MAX = 9;      // sectors 1-9: patrolled, no combat
    public const COLONISTS_PER_HOLD = 100;

    /** Planet classes: per-1000-colonists daily output [ore, bio, tech]. */
    public const PLANET_CLASSES = [
        'Verdant' => [5, 30, 5],
        'Arid' => [15, 5, 10],
        'Ocean' => [5, 25, 10],
        'Alpine' => [20, 10, 5],
        'Glacial' => [10, 5, 25],
        'Volcanic' => [30, 5, 5],
        'Gas' => [5, 10, 30],
    ];

    public function __construct(public readonly DoorStore $store)
    {
    }

    // ---- tables ----------------------------------------------------------

    public function players(): DoorTable
    {
        return $this->store->table('players');
    }

    public function sectors(): DoorTable
    {
        return $this->store->table('sectors');
    }

    public function warps(): DoorTable
    {
        return $this->store->table('warps');
    }

    public function ports(): DoorTable
    {
        return $this->store->table('ports');
    }

    public function planets(): DoorTable
    {
        return $this->store->table('planets');
    }

    public function visits(): DoorTable
    {
        return $this->store->table('visits');
    }

    public function notices(): DoorTable
    {
        return $this->store->table('notices');
    }

    public function slog(): DoorTable
    {
        return $this->store->table('slog');
    }

    public function drifters(): DoorTable
    {
        return $this->store->table('drifters');
    }

    public function marauders(): DoorTable
    {
        return $this->store->table('marauders');
    }

    public function ensureTables(): void
    {
        $this->players()->ensure([
            'id' => 'pk',
            'user_id' => ['int'],
            'handle' => ['text', 'short' => true],
            'credits' => ['int', 'default' => 0],
            'bank' => ['int', 'default' => 0],
            'turns' => ['int', 'default' => 0],
            'sector' => ['int', 'default' => 1],
            'holds' => ['int', 'default' => 20],
            'fighters' => ['int', 'default' => 0],
            'shields' => ['int', 'default' => 0],
            'ore' => ['int', 'default' => 0],
            'bio' => ['int', 'default' => 0],
            'tech' => ['int', 'default' => 0],
            'colonists' => ['int', 'default' => 0],
            'kills' => ['int', 'default' => 0],
            'deaths' => ['int', 'default' => 0],
            'has_seedship' => ['int', 'default' => 0],
        ], [['user_id']]);
        $this->sectors()->ensure([
            'id' => 'pk',
            'sid' => ['int'],
            'beacon' => ['text', 'short' => true, 'default' => ''],
            'beacon_owner' => ['text', 'short' => true, 'default' => ''],
        ], [['sid']]);
        $this->warps()->ensure([
            'id' => 'pk',
            'wfrom' => ['int'],
            'wto' => ['int'],
        ], [['wfrom'], ['wto']]);
        $this->ports()->ensure([
            'id' => 'pk',
            'sector' => ['int'],
            'pclass' => ['int'],
            'name' => ['text', 'short' => true],
            'ore' => ['int', 'default' => 0],
            'bio' => ['int', 'default' => 0],
            'tech' => ['int', 'default' => 0],
        ], [['sector']]);
        $this->planets()->ensure([
            'id' => 'pk',
            'sector' => ['int'],
            'owner' => ['int', 'default' => 0],
            'owner_handle' => ['text', 'short' => true, 'default' => ''],
            'pname' => ['text', 'short' => true],
            'pclass' => ['text', 'short' => true],
            'colonists' => ['int', 'default' => 0],
            'ore' => ['int', 'default' => 0],
            'bio' => ['int', 'default' => 0],
            'tech' => ['int', 'default' => 0],
            'fighters' => ['int', 'default' => 0],
        ], [['sector'], ['owner']]);
        $this->visits()->ensure([
            'id' => 'pk',
            'player' => ['int'],
            'sector' => ['int'],
            'seen' => 'text',
            'at' => 'datetime',
        ], [['player', 'sector']]);
        $this->notices()->ensure([
            'id' => 'pk',
            'player' => ['int'],
            'body' => 'text',
            'at' => 'datetime',
        ], [['player']]);
        $this->slog()->ensure([
            'id' => 'pk',
            'at' => 'datetime',
            'body' => ['text', 'short' => true],
        ]);
        $this->drifters()->ensure([
            'id' => 'pk',
            'sector' => ['int'],
            'loot' => ['int'],
        ], [['sector']]);
        $this->marauders()->ensure([
            'id' => 'pk',
            'sector' => ['int'],
            'name' => ['text', 'short' => true],
            'fighters' => ['int'],
            'shields' => ['int'],
            'bounty' => ['int'],
        ], [['sector']]);
    }

    /** How many marauder crews a galaxy this size supports. */
    public function marauderCap(): int
    {
        return max(8, intdiv((int)$this->store->get('gen_sectors', '1000'), 50));
    }

    /** Roll one pirate crew into a random wild sector. */
    public function spawnMarauder(?int $sector = null): void
    {
        $n = (int)$this->store->get('gen_sectors', '1000');
        $fighters = random_int(4, 40);
        $shields = random_int(0, 10);
        $this->marauders()->insert([
            'sector' => $sector ?? random_int(self::CHARTER_MAX + 1, max(self::CHARTER_MAX + 1, $n)),
            'name' => self::marauderName(),
            'fighters' => $fighters,
            'shields' => $shields,
            'bounty' => 150 + $fighters * 40 + $shields * 80,
        ]);
    }

    /** Original pirate-cutter names: "Rust Queen", "Silent Talon". */
    public static function marauderName(): string
    {
        $a = ['Rust', 'Void', 'Black', 'Crimson', 'Iron', 'Ash', 'Broken', 'Silent', 'Grim', 'Feral'];
        $b = ['Queen', 'Jackal', 'Saw', 'Lantern', 'Anchor', 'Viper', 'Crown', 'Wake', 'Talon', 'Howl'];
        return $a[random_int(0, 9)] . ' ' . $b[random_int(0, 9)];
    }

    // ---- config (kv-backed, door admin edits) ----------------------------

    public function cfg(string $key, int $default): int
    {
        return (int)$this->store->get('cfg_' . $key, (string)$default);
    }

    public function setCfg(string $key, int $value): void
    {
        $this->store->set('cfg_' . $key, (string)$value);
    }

    public function generated(): bool
    {
        return $this->store->get('gen_done') === '1';
    }

    // ---- generation (§15.2: one request for N=1000) ----------------------

    public function generate(?int $sectors = null): void
    {
        $n = max(100, min(5000, $sectors ?? $this->cfg('sectors', 1000)));
        $this->ensureTables();
        $this->store->tx(function () use ($n): void {
            foreach ([$this->players(), $this->sectors(), $this->warps(), $this->ports(),
                $this->planets(), $this->visits(), $this->notices(), $this->drifters(),
                $this->marauders()] as $t) {
                $t->delete([]);
            }

            // Spanning tree first (guaranteed connectivity), then extra
            // edges to average out-degree ~2.6; 12% of extras stay one-way.
            $edges = [];
            $have = [];
            $order = range(2, $n);
            shuffle($order);
            $tree = [1];
            foreach ($order as $sid) {
                $anchor = $tree[random_int(0, count($tree) - 1)];
                $edges[] = [$anchor, $sid];
                $edges[] = [$sid, $anchor];
                $have["$anchor:$sid"] = true;
                $have["$sid:$anchor"] = true;
                $tree[] = $sid;
            }
            $want = (int)ceil($n * 2.6);
            $guard = $want * 20;
            while (count($edges) < $want && $guard-- > 0) {
                $a = random_int(1, $n);
                $b = random_int(1, $n);
                if ($a === $b || isset($have["$a:$b"])) {
                    continue;
                }
                $edges[] = [$a, $b];
                $have["$a:$b"] = true;
                if (random_int(1, 100) > 12 && !isset($have["$b:$a"])) {
                    $edges[] = [$b, $a];
                    $have["$b:$a"] = true;
                }
            }
            $this->warps()->insertMany(array_map(
                fn(array $e) => ['wfrom' => $e[0], 'wto' => $e[1]],
                $edges
            ));

            $sectorRows = [];
            for ($sid = 1; $sid <= $n; $sid++) {
                $sectorRows[] = ['sid' => $sid, 'beacon' => '', 'beacon_owner' => ''];
            }
            $this->sectors()->insertMany($sectorRows);

            // Ports on ~45% of the wilds (never in Charter Space).
            $portRows = [];
            for ($sid = self::CHARTER_MAX + 1; $sid <= $n; $sid++) {
                if (random_int(1, 100) > 45) {
                    continue;
                }
                $portRows[] = [
                    'sector' => $sid,
                    'pclass' => random_int(1, 8),
                    'name' => self::portName(),
                    'ore' => random_int(0, self::MAX_STOCK),
                    'bio' => random_int(0, self::MAX_STOCK),
                    'tech' => random_int(0, self::MAX_STOCK),
                ];
            }
            $this->ports()->insertMany($portRows);

            // Marauder crews prowl the wilds from day one — somebody to
            // fight on a quiet board (owner request; NPC clans stay v2).
            for ($i = $this->marauderCap(); $i > 0; $i--) {
                $this->spawnMarauder();
            }

            // Gaia (§15.2): sector 2, endless colonists, owned by no one.
            $this->planets()->insert([
                'sector' => 2, 'owner' => 0, 'owner_handle' => '',
                'pname' => 'Gaia', 'pclass' => 'Gaia',
                'colonists' => 1000000000, 'ore' => 0, 'bio' => 0, 'tech' => 0, 'fighters' => 0,
            ]);
        });
        $this->store->set('gen_done', '1');
        $this->store->set('gen_sectors', (string)$n);
        $this->logEvent('A fresh galaxy of ' . $n . ' sectors condensed out of the dark.');
    }

    // ---- pricing (§15.2) -------------------------------------------------

    /** Port class n: which goods it Sells (true) vs Buys (false). */
    public static function classPattern(int $pclass): array
    {
        $bits = $pclass - 1; // class 1 = BBB ... class 8 = SSS
        return [
            'ore' => (bool)($bits & 4),
            'bio' => (bool)($bits & 2),
            'tech' => (bool)($bits & 1),
        ];
    }

    public static function classLabel(int $pclass): string
    {
        $p = self::classPattern($pclass);
        return ($p['ore'] ? 'S' : 'B') . ($p['bio'] ? 'S' : 'B') . ($p['tech'] ? 'S' : 'B');
    }

    /**
     * What a PLAYER pays at a selling port: 0.95× base when the port is
     * bare, sliding to 0.4× in a glut. Buy from the full ones.
     */
    public static function pricePlayerBuys(string $good, int $stock): int
    {
        $full = min(1.0, $stock / self::MAX_STOCK);
        return max(1, (int)round(self::BASES[$good] * (0.95 - 0.55 * $full)));
    }

    /**
     * What a PLAYER gets at a buying port: 1.1× base when the port is
     * stuffed, up to 2.4× when it is starving. Sell to the empty ones.
     * The spread between these two curves IS the game (§15.2).
     */
    public static function pricePlayerSells(string $good, int $stock): int
    {
        $empt = 1.0 - min(1.0, $stock / self::MAX_STOCK);
        return max(1, (int)round(self::BASES[$good] * (1.1 + 1.3 * $empt)));
    }

    // ---- shared helpers --------------------------------------------------

    /** @return list<int> out-warps of a sector, ascending */
    public function warpsOut(int $sid): array
    {
        return array_map(
            fn(array $r) => (int)$r['wto'],
            $this->warps()->rows(['wfrom' => $sid], 'wto ASC')
        );
    }

    /** BFS route (§15.3 autopilot/computer); known-only when $known given. */
    public function route(int $from, int $to, ?array $known = null): ?array
    {
        if ($from === $to) {
            return [];
        }
        $prev = [$from => 0];
        $queue = [$from];
        $limit = 20000;
        while ($queue !== [] && $limit-- > 0) {
            $cur = array_shift($queue);
            foreach ($this->warpsOut($cur) as $next) {
                if (isset($prev[$next]) || ($known !== null && !isset($known[$next]) && $next !== $to)) {
                    continue;
                }
                $prev[$next] = $cur;
                if ($next === $to) {
                    $path = [$to];
                    for ($at = $to; $at !== $from; $at = $prev[$at]) {
                        array_unshift($path, $prev[$at]);
                    }
                    array_shift($path);
                    return $path;
                }
                $queue[] = $next;
            }
        }
        return null;
    }

    public function logEvent(string $body): void
    {
        $this->slog()->insert(['at' => now_utc(), 'body' => mb_strimwidth($body, 0, 120)]);
    }

    public function notify(int $userId, string $body): void
    {
        $this->notices()->insert(['player' => $userId, 'body' => mb_strimwidth($body, 0, 200), 'at' => now_utc()]);
    }

    /** Net worth for the rankings ladder (§15.1). */
    public function netWorth(array $p): int
    {
        $worth = (int)$p['credits'] + (int)$p['bank']
            + (int)$p['ore'] * self::BASES['ore']
            + (int)$p['bio'] * self::BASES['bio']
            + (int)$p['tech'] * self::BASES['tech']
            + ((int)$p['holds'] - 20) * 40
            + (int)$p['fighters'] * 20
            + (int)$p['shields'] * 80;
        foreach ($this->planets()->rows(['owner' => (int)$p['user_id']]) as $pl) {
            $worth += (int)($pl['colonists'] / 10)
                + (int)$pl['ore'] * self::BASES['ore']
                + (int)$pl['bio'] * self::BASES['bio']
                + (int)$pl['tech'] * self::BASES['tech']
                + (int)$pl['fighters'] * 20;
        }
        return $worth;
    }

    /** Original two-syllable port names: "Vega Depot", "Korr Exchange". */
    public static function portName(): string
    {
        $first = ['Vega', 'Korr', 'Nadi', 'Solan', 'Brem', 'Ost', 'Kel', 'Tarn', 'Ilex', 'Marrow',
            'Quill', 'Fenn', 'Halcy', 'Dorm', 'Ashe', 'Lorn', 'Pell', 'Rud', 'Sable', 'Vint'];
        $second = ['Depot', 'Exchange', 'Yards', 'Post', 'Terminal', 'Market', 'Landing', 'Works', 'Reach', 'Anchorage'];
        return $first[random_int(0, count($first) - 1)] . ' ' . $second[random_int(0, count($second) - 1)];
    }

    public static function planetClassRoll(): string
    {
        $keys = array_keys(self::PLANET_CLASSES);
        return $keys[random_int(0, count($keys) - 1)];
    }
}
