<?php

declare(strict_types=1);

namespace Instinctor\Core;

if (!defined('ABSPATH')) exit;

/**
 * Tenant — resolves the current tenant (owner) for a request.
 *
 * On instinctor.com the site is multi-tenant: every customer is an
 * `owner`-role user in instinctor_users, and their pages carry pages.owner_id.
 * The vendor site itself is owner_id IS NULL. This class answers one question:
 * "whose settings/data does the current context belong to?"
 *
 * Resolution order (first hit wins):
 *   1. An explicit context pushed via enter()/withOwner() — provisioning, the
 *      per-tenant admin, tests. This lets code operate as a tenant deliberately.
 *   2. The frontend render context: the page being rendered stashes its row in
 *      $GLOBALS['impulse_current_page'] (see templates/impulse-page.php), so a
 *      tenant page resolves to its owner automatically with no plumbing.
 *   3. null — the vendor / global context.
 *
 * A null result means "use the global instinctor_config" (Config falls back to
 * it). Owner ids are always positive; 0 / null / negatives normalise to null so
 * the shared vendor namespace can never be mistaken for a tenant.
 *
 * @since 2.1.2226
 */
final class Tenant
{
    /** @var int[] Explicit tenant context stack (LIFO). */
    private static array $stack = [];

    /**
     * Push an explicit tenant context. Pair every enter() with a leave(), or
     * use withOwner() which guarantees the pop even on exceptions.
     *
     * Passing null / 0 pushes the global context (temporarily suppresses any
     * ambient frontend owner) — useful when vendor-level code runs mid-render.
     */
    public static function enter(?int $ownerId): void
    {
        self::$stack[] = self::normalize($ownerId) ?? 0;
    }

    /** Pop the most recent explicit context. */
    public static function leave(): void
    {
        array_pop(self::$stack);
    }

    /**
     * Run $fn as tenant $ownerId, restoring the previous context afterwards.
     *
     * @template T
     * @param  callable():T $fn
     * @return T
     */
    public static function withOwner(?int $ownerId, callable $fn): mixed
    {
        self::enter($ownerId);
        try {
            return $fn();
        } finally {
            self::leave();
        }
    }

    /**
     * The owner id for the current context, or null for the vendor / global
     * context. Positive int or null.
     */
    public static function currentOwnerId(): ?int
    {
        if (self::$stack !== []) {
            $top = self::$stack[count(self::$stack) - 1];
            return $top > 0 ? $top : null;
        }

        $pg = $GLOBALS['impulse_current_page'] ?? null;
        if (is_array($pg) && isset($pg['owner_id'])) {
            return self::normalize((int) $pg['owner_id']);
        }

        return null;
    }

    /** True when the current context belongs to a tenant (not the vendor). */
    public static function isTenant(): bool
    {
        return self::currentOwnerId() !== null;
    }

    /**
     * Has ANYONE declared whose behalf we are acting on?
     *
     * currentOwnerId() answers null for two very different situations: "the vendor,
     * deliberately" and "nobody said, and we are guessing". They are indistinguishable
     * by the id alone, which is why the TenantPlan gates could fail open on the managed
     * app: a background job that iterates free owners without entering their context
     * looks exactly like the vendor, so SMS/bookings were allowed on their behalf and
     * billed to us.
     *
     * enter(null) is an EXPLICIT vendor declaration and counts as context: the stack is
     * non-empty even though the id is null. So does rendering a page, which carries its
     * own owner_id (null for the vendor's own pages).
     *
     * Empty stack AND no render context = nobody decided. On a single-tenant install
     * that is simply normal. On the multi-tenant app it is a bug, and TenantPlan treats
     * it as one.
     */
    public static function hasContext(): bool
    {
        if (self::$stack !== []) return true;
        $pg = $GLOBALS['impulse_current_page'] ?? null;
        return is_array($pg) && array_key_exists('owner_id', $pg);
    }

    /**
     * A WHERE-clause fragment scoping a query to the current tenant's rows.
     *
     * Vendor / single-tenant WP (owner null) yields "col IS NULL", which on a
     * single-tenant install (every row NULL) returns exactly the rows an
     * unscoped query would — so scoping is a no-op there. A tenant yields
     * "col = <id>". The owner id is a validated positive int and the column
     * name is caller-supplied (never user input), so inlining is injection-safe.
     *
     * @param string $column Column, optionally table-aliased, e.g. 'c.owner_id'.
     */
    public static function scopeSql(string $column = 'owner_id'): string
    {
        return self::scopeSqlFor(self::currentOwnerId(), $column);
    }

    /**
     * Like scopeSql() but for an EXPLICIT owner rather than the ambient context.
     * Use when the owner is known from data (e.g. the service being booked)
     * rather than from the request context. Null yields "col IS NULL".
     */
    public static function scopeSqlFor(?int $ownerId, string $column = 'owner_id'): string
    {
        return $ownerId === null ? "{$column} IS NULL" : "{$column} = " . (int) $ownerId;
    }

    /** Reset all explicit context — tests only. */
    public static function reset(): void
    {
        self::$stack = [];
    }

    private static function normalize(?int $ownerId): ?int
    {
        return ($ownerId !== null && $ownerId > 0) ? $ownerId : null;
    }
}
