<?php
declare(strict_types=1);

namespace Instinctor\Core;

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

/**
 * Per-owner plan state for the free-publish model.
 *
 * Free tenants live on the managed multi-tenant app (instinctor.com/<slug>), which
 * runs as INSTINCTOR_ENV=standalone. TierManager::canFeature() short-circuits to
 * "always unlocked" on standalone ("payment collected before render"), so the
 * classic chat_pro gate does nothing for these tenants. This helper is the ONE
 * source of truth for whether a specific owner is Pro, keyed on a flag the platform
 * sets on payment (instinctor_owner_pro in the owner-scoped config store).
 *
 * Pro lines in the free tier: free = full live site (shown in full), but online BOOKING
 * and SMS notifications are the paid drivers. Free sites still DISPLAY the booking widget
 * and gift-card CTA (the aspirational upgrade teaser) — they just don't function until the
 * owner is Pro. isPro() answers per owner; smsAllowed()/bookingAllowed() are the gates the
 * choke points call (Core\Sms::send, ChatService relay, FrontendController::ajaxPublicBook).
 *
 * Platform contract: on Pro checkout set instinctor_owner_pro = '1' for that owner
 * (Config::setForOwner($ownerId, 'instinctor_owner_pro', '1')); clear or set '0' on
 * lapse. Nothing else in the plugin writes this key.
 *
 * @package Instinctor
 */
final class TenantPlan
{
    public const FLAG = 'instinctor_owner_pro';

    /**
     * Free monthly smart-chat allowance, in AI replies per owner (Sergei 2026-07-16;
     * 50 -> 30 the same evening, on the fleet maths).
     *
     * Smart chat is PRESENT on free sites, not withheld: an owner will not pay for
     * something they never see working. The cap bounds the tail (a bot or a competitor
     * hammering the widget was unbounded spend), not the median free site, which uses
     * close to none of this. Pro is unlimited.
     *
     * This is REAL money, and it is easy to think otherwise: the reply is not "our own AI
     * on our own server", it is platform_v2's ClaudeService posting to
     * api.anthropic.com/v1/messages on our ANTHROPIC_API_KEY. At 1,000 free sites, 50/mo
     * was a 50,000-call monthly bill; 30 is 30,000. The number is the fleet's, not the
     * owner's: nearly every free site uses single digits.
     */
    public const FREE_AI_REPLIES_PER_MONTH = 30;

    /**
     * Lead alerts a free owner gets, EVER. Not per month: a lifetime taste.
     *
     * SMS is the one Pro driver an owner cannot imagine, only feel. A monthly quota does
     * not sell it - it budgets it, and at 1,000 free sites a 30/mo allowance is 30,000
     * texts a month on our Telnyx account, forever, growing with every signup. Three,
     * once, is ~3,000 texts across the whole fleet one time (about $24) and it fires at
     * the only moment that converts: a real customer, a real job, buzzing in his pocket
     * at 9pm on a Saturday. The FOURTH lead arrives silent, and the silence is the pitch.
     *
     * Applies ONLY to owner lead alerts (Sms::sendLeadAlert). Everything else a tenant
     * might text stays fully Pro: this is a demo of the feature, not an SMS allowance.
     */
    // Ten CUSTOMERS, not ten messages (Sergei 2026-07-26). Three was too few to build
    // the reflex the taste exists to create: phone buzzes, a customer, open the
    // dashboard. The unit is the customer because that is what an owner counts, and
    // each one spends a single allowance while texting BOTH sides, the owner's alert
    // and the client's confirmation. What runs out is then the whole loop rather than
    // half of it. Roughly twenty messages per free tenant for the life of the account.
    public const FREE_CUSTOMERS = 10;

    /**
     * Image-library upload cap, in ORIGINALS per owner. The lever against a tenant
     * filling our disk with junk: a free portfolio is a handful of shots (a carousel,
     * a grid), not an archive. Pro gets a higher ceiling, not infinity, for the same
     * reason. Counted on parent_id IS NULL rows (variants/depth maps don't count).
     * Vendor / no-owner context is the shared global pool and is never capped.
     * (Sergei 2026-07-17.)
     */
    public const FREE_IMAGE_UPLOADS = 10;
    public const PRO_IMAGE_UPLOADS  = 50;

    /** Owner-scoped usage counter. Storing the month WITH the count means a stale
     *  month reads as zero, so the allowance self-resets with no cron. */
    private const USAGE_MONTH = 'instinctor_chat_ai_ym';
    private const USAGE_COUNT = 'instinctor_chat_ai_count';

    /** Lifetime lead-alert counter. Deliberately carries NO month: it must never reset. */
    private const TRIAL_ALERTS = 'instinctor_lead_alerts_used';

    /**
     * The free WP build's own counters. DELIBERATELY different keys from the per-owner
     * pair above, not the same ones written globally.
     *
     * Config::getForOwner() falls back to the GLOBAL value when an owner has none of
     * their own, so if the free-build counter wrote instinctor_chat_ai_count, every
     * tenant who had not yet used a reply would inherit this install's usage as their
     * own and could start life already capped. Inert on instinctor.com today (it is not
     * a free build, so the global is never written), which is exactly the kind of "works
     * by accident" this keeps tripping over.
     */
    private const BUILD_USAGE_MONTH = 'instinctor_chat_ai_build_ym';
    private const BUILD_USAGE_COUNT = 'instinctor_chat_ai_build_count';

    /** Resolve the owner id: explicit arg, else the current Tenant context. */
    private static function owner(?int $ownerId): ?int
    {
        if ($ownerId !== null && $ownerId > 0) return $ownerId;
        return Tenant::currentOwnerId();
    }

    /**
     * May a null-owner context act, or is "null" just nobody having decided?
     *
     * The gates used to return true for every null owner, documented as "standalone
     * client / DIY WP / vendor, all covered by the paid model". True for those, and a
     * hole on the managed app: null ALSO means "no code path declared whose behalf this
     * is", which is exactly what a background job iterating free owners looks like. It
     * would text on their behalf, on our Telnyx bill, handing a free user a Pro feature.
     *
     * The distinction is not the id (null either way), it is whether anyone DECIDED:
     *   - single-tenant install (not the managed app) -> null is its normal resting
     *     state. Always allow. Client installs and DIY WP are untouched by this.
     *   - managed app + an explicit decision (Tenant::enter(null) for vendor work, or a
     *     page render carrying owner_id) -> allow.
     *   - managed app + nobody decided -> DENY. That is the bug, and it now fails shut.
     *
     * Callers that legitimately act as the vendor on the managed app must say so:
     * Tenant::withOwner(null, fn) (see DunningService, FrontendController form notify).
     */
    private static function ambientAllowed(): bool
    {
        if (!\Instinctor\Core\Plugin::isInstinctorApp()) return true;
        return Tenant::hasContext();
    }

    /** UTC so the reset instant is deterministic regardless of site timezone. */
    private static function currentMonth(): string
    {
        return gmdate('Ym');
    }

    /** True only in the stripped free WordPress.org build (see includes/free-tier.php). */
    private static function isFreeBuild(): bool
    {
        return function_exists('instinctor_is_free_build') && \instinctor_is_free_build();
    }

    /**
     * The free WP build's allowance, counted in GLOBAL config.
     *
     * A self-hosted install is one owner with no owner_id, so there is nothing to scope
     * to and nothing to scope against: the whole site is the bucket. Same month-with-count
     * trick as the per-owner counters, so a stale month reads as zero and the allowance
     * self-resets with no cron.
     *
     * Locally this bounds OUR spend. The relay keeps its own monthly fence bucketed per
     * (scope, owner_ref) as the floor, because a self-hosted install can edit its own
     * config table and this counter is only as honest as the site running it.
     */
    private static function globalRepliesUsed(): int
    {
        if ((string) Config::get(self::BUILD_USAGE_MONTH, '') !== self::currentMonth()) return 0;
        return max(0, (int) Config::get(self::BUILD_USAGE_COUNT, '0'));
    }

    /**
     * Is this specific tenant owner on the Pro plan? False when there is no tenant
     * owner (that is not "a free owner" — it's "no owner", handled by smsAllowed()).
     */
    public static function isPro(?int $ownerId = null): bool
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) return false;
        return (string) Config::getForOwner($ownerId, self::FLAG, '0') === '1';
    }

    /**
     * May SMS be sent in the current (or given) owner context?
     *
     * - No tenant owner (null) → true ONLY when that null was decided: a single-tenant
     *   standalone client site, DIY WP, or the vendor explicitly saying so. On the
     *   managed app, an undeclared null now fails shut (see ambientAllowed).
     * - Tenant owner → only when that owner is Pro.
     */
    public static function smsAllowed(?int $ownerId = null): bool
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) return self::ambientAllowed();
        return (string) Config::getForOwner($ownerId, self::FLAG, '0') === '1';
    }

    /**
     * May we text this owner that a lead just landed?
     *
     * The ONE crack in "all SMS is Pro", and only for alerts TO THE OWNER about their own
     * lead. Never for anything they send a customer: smsAllowed() still governs that, so a
     * free tenant cannot use us as an SMS gateway.
     *
     * A paid/standalone install (no owner) is unaffected and unlimited, as always.
     */
    public static function leadAlertAllowed(?int $ownerId = null): bool
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) return self::ambientAllowed();
        if (self::isPro($ownerId)) return true;
        return self::leadAlertsUsed($ownerId) < self::FREE_CUSTOMERS;
    }

    /** Lead alerts this owner has ever been sent. Lifetime: never resets. */
    public static function leadAlertsUsed(?int $ownerId = null): int
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) return 0;
        return max(0, (int) Config::getForOwner($ownerId, self::TRIAL_ALERTS, '0'));
    }

    /** How many free alerts this owner has left, for honest copy ("2 of 3 left"). */
    public static function leadAlertsLeft(?int $ownerId = null): int
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) return 0;
        if (self::isPro($ownerId)) return 0;   // unlimited: nothing to count down
        return max(0, self::FREE_CUSTOMERS - self::leadAlertsUsed($ownerId));
    }

    /** Burn one. Only called after the relay actually accepted the text. */
    public static function recordLeadAlert(?int $ownerId = null): void
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) return;   // untracked: paid model
        if (self::isPro($ownerId)) return;                // unlimited: nothing to count
        Config::setForOwner($ownerId, self::TRIAL_ALERTS, (string) (self::leadAlertsUsed($ownerId) + 1));
    }

    /**
     * May an online booking be TAKEN in the current (or given) owner context?
     *
     * Same shape as smsAllowed(): no tenant owner (standalone client / DIY WP / vendor)
     * is unrestricted; a free-publish tenant owner can accept bookings only once Pro.
     * The booking widget is still rendered on free sites as the upgrade teaser — this
     * only gates the WRITE path (FrontendController::ajaxPublicBook).
     */
    public static function bookingAllowed(?int $ownerId = null): bool
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) return self::ambientAllowed();
        return (string) Config::getForOwner($ownerId, self::FLAG, '0') === '1';
    }

    /**
     * How many library images this owner may hold (originals). Free 10, Pro 50.
     * A null / non-positive owner is the vendor / global pool and is never capped
     * (PHP_INT_MAX) — the caller passes the EXPLICIT uploading site_owner id, so
     * "no owner" here always means vendor-level, not "resolve from context".
     */
    public static function imageUploadCap(?int $ownerId): int
    {
        if ($ownerId === null || $ownerId <= 0) return PHP_INT_MAX;   // vendor / shared pool
        return self::isPro($ownerId) ? self::PRO_IMAGE_UPLOADS : self::FREE_IMAGE_UPLOADS;
    }

    /**
     * May video be uploaded to the media library in this owner context?
     *
     * A background film is two orders of magnitude heavier than a photo, and it
     * is the one asset an abandoned free site can leave real weight behind on
     * the disk. So it follows the same shape as SMS and booking: no tenant owner
     * (standalone client, DIY WP, vendor) is the paid product and may; a tenant
     * must be Pro. Sergei 2026-08-06: "we don't want to inflate our server with
     * garbage".
     */
    public static function videoUploadAllowed(?int $ownerId = null): bool
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) return true;
        return self::isPro($ownerId);
    }

    /**
     * How many videos one site may hold. Deliberately uniform: a standalone
     * client install is a site, and so is instinctor.com, and both sit on our
     * disks. Raise this one number if a site legitimately needs more.
     */
    public const MAX_VIDEOS_PER_SITE = 1;

    /**
     * May the smart-chat AI compose a reply in the current (or given) owner context?
     *
     * - No tenant owner → true: standalone client / DIY WP / vendor, covered by the paid model.
     * - Pro tenant      → true, unlimited (fair use). Usage is not even counted.
     * - Free tenant     → true until the monthly allowance is spent.
     *
     * Past the allowance this returns false and getAIReply() simply returns null, so the
     * widget degrades to plain live chat: the visitor's message still reaches the owner and
     * is still captured as a lead. It must never error or read as broken in front of the
     * owner's own client.
     */
    public static function aiReplyAllowed(?int $ownerId = null): bool
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) {
            if (!self::ambientAllowed()) return false;
            // The free WordPress.org build has no owner to key on, so this used to mean
            // "unlimited" - i.e. every free install answering visitors forever on OUR API
            // key. It gets the same 50 as a free tenant (Sergei, 2026-07-16: treat free WP
            // exactly as free verticals), counted globally because a self-hosted install
            // IS one owner. A PAID standalone or licensed WP install is not a free build,
            // so it stays uncapped and is unaffected.
            if (self::isFreeBuild()) return self::globalRepliesUsed() < self::FREE_AI_REPLIES_PER_MONTH;
            return true;
        }
        if (self::isPro($ownerId)) return true;
        return self::aiRepliesUsed($ownerId) < self::FREE_AI_REPLIES_PER_MONTH;
    }

    /** AI replies this owner has spent in the current UTC month (0 when untracked/Pro). */
    public static function aiRepliesUsed(?int $ownerId = null): int
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) return 0;
        if ((string) Config::getForOwner($ownerId, self::USAGE_MONTH, '') !== self::currentMonth()) {
            return 0;   // stale month = fresh allowance
        }
        return max(0, (int) Config::getForOwner($ownerId, self::USAGE_COUNT, '0'));
    }

    /** Replies left this month. PHP_INT_MAX for Pro / no-owner (unlimited). */
    public static function aiRepliesRemaining(?int $ownerId = null): int
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) return PHP_INT_MAX;
        if (self::isPro($ownerId)) return PHP_INT_MAX;
        return max(0, self::FREE_AI_REPLIES_PER_MONTH - self::aiRepliesUsed($ownerId));
    }

    /**
     * Count one AI reply against the free allowance. Call ONLY after the model actually
     * answered: a failed or empty relay call must not burn a free owner's month.
     */
    public static function recordAiReply(?int $ownerId = null): void
    {
        $ownerId = self::owner($ownerId);
        if ($ownerId === null || $ownerId <= 0) {
            // A cap nobody counts is not a cap. The free WP build has no owner, so this
            // returned early and the 50 above could never be reached.
            if (!self::isFreeBuild()) return;             // untracked: paid model
            $now  = self::currentMonth();
            $used = (string) Config::get(self::BUILD_USAGE_MONTH, '') === $now
                ? max(0, (int) Config::get(self::BUILD_USAGE_COUNT, '0'))
                : 0;
            if ($used === 0) Config::set(self::BUILD_USAGE_MONTH, $now);
            Config::set(self::BUILD_USAGE_COUNT, (string) ($used + 1));
            return;
        }
        if (self::isPro($ownerId)) return;                // unlimited: nothing to count
        $now  = self::currentMonth();
        $used = (string) Config::getForOwner($ownerId, self::USAGE_MONTH, '') === $now
            ? max(0, (int) Config::getForOwner($ownerId, self::USAGE_COUNT, '0'))
            : 0;
        if ($used === 0) Config::setForOwner($ownerId, self::USAGE_MONTH, $now);
        Config::setForOwner($ownerId, self::USAGE_COUNT, (string) ($used + 1));
    }
}
