<?php

declare(strict_types=1);

namespace Atdt;

/**
 * Menu-targeted message areas (1.6.5, SPEC-AREATARGETING). This class is
 * the single home for the feature's constants, the scope store, and the
 * pieces of the resolution order that are not the legacy path.
 *
 * The hard constraints live here as running rules:
 *  - HC-2: a NULL menu scope means the legacy current area through the
 *    legacy code path. Nothing in this class runs for a scopeless menu
 *    except byTag() for one-shot targeting.
 *  - HC-3/HC-4: `listed` is discovery, never access control. Read ACS is
 *    the only read gate, checked here exactly where the area list checks
 *    it. (Write ACS is not enforced anywhere in the caller UI today, and
 *    targeting does not change that.)
 */
final class AreaScope
{
    public const SCOPE_MAX_LEN = 16;
    public const SCOPE_PATTERN = '/^[A-Z0-9_]{1,16}$/';
    public const ARG2_MAX_LEN = 32;
    public const ROWS_PER_USER_SOFT_CAP = 64;

    // ---- areas by tag -----------------------------------------------------

    /** A local message area by tag, case-folded. Mail never targets. */
    public static function byTag(Db $db, string $tag): ?array
    {
        $tag = strtoupper(trim($tag));
        if ($tag === '') {
            return null;
        }
        $area = $db->one(
            "SELECT a.*, c.name AS cat_name
             FROM message_areas a
             LEFT JOIN message_categories c ON c.id = a.category_id
             WHERE a.tag = ? AND a.kind = 'local'",
            [$tag]
        );
        return $area;
    }

    /** The one read gate (HC-4): the same check the area list makes. */
    public static function readable(?array $area, ?array $user): bool
    {
        return $area !== null && Acs::check((string)$area['acs_read'], $user);
    }

    // ---- the scope store --------------------------------------------------

    /** The stored current-area tag for (caller, scope), or null. */
    public static function get(Db $db, ?array $user, string $scope): ?string
    {
        if ($user === null || $scope === '') {
            return null;
        }
        $tag = $db->val(
            'SELECT area_tag FROM user_area_scope WHERE user_id = ? AND scope = ?',
            [(int)$user['id'], $scope]
        );
        return is_string($tag) && $tag !== '' ? $tag : null;
    }

    /** Remember (caller, scope) -> tag. Upsert plus the soft-cap guard. */
    public static function set(Db $db, ?array $user, string $scope, string $tag): void
    {
        if ($user === null || $scope === '' || $tag === '') {
            return;
        }
        $uid = (int)$user['id'];
        // Native upsert against the (user_id, scope) primary key
        // (1.7.4): the old update-then-insert threw and caught a key
        // violation on MySQL whenever the same tag was re-picked
        // within one clock second. One statement, no theatrics.
        $db->upsert('user_area_scope', [
            'user_id' => $uid, 'scope' => $scope,
            'area_tag' => $tag, 'updated_at' => now_utc(),
        ], ['user_id', 'scope']);
        // The pathological-menu-tree guard (SPEC 3.3): oldest out first.
        $n = (int)$db->val('SELECT COUNT(*) FROM user_area_scope WHERE user_id = ?', [$uid]);
        if ($n > self::ROWS_PER_USER_SOFT_CAP) {
            foreach ($db->all(
                'SELECT scope FROM user_area_scope WHERE user_id = ? ORDER BY updated_at, scope LIMIT ' . ($n - self::ROWS_PER_USER_SOFT_CAP),
                [$uid]
            ) as $r) {
                $db->q('DELETE FROM user_area_scope WHERE user_id = ? AND scope = ?', [$uid, (string)$r['scope']]);
            }
        }
    }

    public static function clear(Db $db, ?array $user, string $scope): void
    {
        if ($user !== null && $scope !== '') {
            $db->q(
                'DELETE FROM user_area_scope WHERE user_id = ? AND scope = ?',
                [(int)$user['id'], $scope]
            );
        }
    }

    // ---- resolution (SPEC section 5, steps 3-4) ---------------------------

    /**
     * Resolve a SCOPED menu's current area: the stored row if it still
     * exists and the caller still passes Read ACS (a dead row is deleted
     * and passed over), else the menu's default_area (made sticky), else
     * null -- the caller must be shown the picker (step 5). The scopeless
     * path (step 2) never calls this; it stays on the legacy code.
     * @param array<string, mixed> $menu the menus row
     */
    public static function resolveScoped(Db $db, ?array $user, array $menu): ?array
    {
        $scope = trim((string)($menu['area_scope'] ?? ''));
        if ($scope === '') {
            return null; // not scoped; callers must use the legacy path
        }
        $stored = self::get($db, $user, $scope);
        if ($stored !== null) {
            $area = self::byTag($db, $stored);
            if (self::readable($area, $user)) {
                return $area;
            }
            self::clear($db, $user, $scope); // gone or barred: forget it
        }
        $def = trim((string)($menu['default_area'] ?? ''));
        if ($def !== '') {
            $area = self::byTag($db, $def);
            if (self::readable($area, $user)) {
                self::set($db, $user, $scope, (string)$area['tag']); // sticky (step 4)
                return $area;
            }
        }
        return null; // step 5: the picker
    }

    // ---- housekeeping (SPEC 3.3, daily tick) ------------------------------

    /** Prune rows whose tag dangles or whose caller is deleted. */
    public static function prune(Db $db): int
    {
        $n = $db->q(
            "DELETE FROM user_area_scope WHERE area_tag NOT IN
               (SELECT tag FROM message_areas WHERE kind = 'local')"
        )->rowCount();
        // Soft-deleted callers keep nothing here; there is no hard
        // user-delete path on this board, so the tick is the cleanup.
        $n += $db->q(
            "DELETE FROM user_area_scope WHERE user_id IN
               (SELECT id FROM users WHERE status = 'deleted')"
        )->rowCount();
        return $n;
    }

    // ---- console bookkeeping ----------------------------------------------

    /**
     * Menu items and menu defaults that reference an area tag -- the
     * deletion guard (SPEC 7.4) and the rename follow both start here.
     * @return list<array{menu: string, hotkey: string, kind: string}>
     */
    public static function referencesToTag(Db $db, string $tag): array
    {
        $out = [];
        foreach ($db->all(
            'SELECT mi.hotkey, m.name FROM menu_items mi JOIN menus m ON m.id = mi.menu_id WHERE mi.arg2 = ?',
            [$tag]
        ) as $r) {
            $out[] = ['menu' => (string)$r['name'], 'hotkey' => (string)$r['hotkey'], 'kind' => 'item'];
        }
        foreach ($db->all('SELECT name FROM menus WHERE default_area = ?', [$tag]) as $r) {
            $out[] = ['menu' => (string)$r['name'], 'hotkey' => '', 'kind' => 'default'];
        }
        return $out;
    }

    /** A tag rename follows through every reference (owner ruling). */
    public static function renameTag(Db $db, string $from, string $to): void
    {
        if ($from === '' || $to === '' || $from === $to) {
            return;
        }
        $db->q('UPDATE menu_items SET arg2 = ? WHERE arg2 = ?', [$to, $from]);
        $db->q('UPDATE menus SET default_area = ? WHERE default_area = ?', [$to, $from]);
        $db->q('UPDATE user_area_scope SET area_tag = ? WHERE area_tag = ?', [$to, $from]);
    }
}
