<?php
declare(strict_types=1);

namespace Instinctor\Services;

use Instinctor\Core\DB;
use Instinctor\Core\Config;
use Instinctor\Core\Mailer;

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

/**
 * Multi-touch re-engagement ("dunning") for two audiences:
 *
 *   A) Abandoned demos that left a recovery contact -> a couple of nudges to
 *      publish before the demo is purged (DemoService::purgeOldDemos, 2 days).
 *   B) Free published owners not on Pro -> a short sequence nudging them to
 *      upgrade, showing what Pro unlocks.
 *
 * Each send is idempotent + rate-tracked (a per-record counter) so re-running the
 * sweep never double-sends. Meant to run once a day from bin/dunning.php (cron).
 * Email is primary; SMS is best-effort (only when we have a number).
 *
 * Every SMS here is wrapped in Tenant::withOwner(null, ...): an EXPLICIT vendor context.
 * This is us texting our own user (about their demo, or to sell them Pro), not a tenant
 * texting their customer, so the free-tier SMS gate must not apply - the recipient being
 * non-Pro is the whole reason we are writing.
 *
 * It used to work by accident, on the gate's old "any null owner is allowed" behaviour.
 * When that was closed (2.1.2297) this file kept sending only where it happened to be
 * wrapped, and Flow A went silently dead for a release. Do not remove the wrappers, and
 * do not assume "no context" means "vendor": say so.
 */
final class DunningService
{
    /** Flow A: unpublished demo preview lives ~24h; nudge (if contact left) at this age (hours). */
    private const DEMO_STEPS = [12];
    /** Flow B: nudge an ACTIVATED free owner toward Pro at these ages (days). */
    private const OWNER_STEPS = [3, 7, 14];
    /** Flow C: a PUBLISHED free site that never shows life gets a daily "make it yours"
     *  nudge (email + SMS) and is REMOVED at this age (days). Most publishers are tire
     *  kickers; anything real (a visitor, a lead, a return, an edit) activates the site
     *  and stops the clock. Recoverable the instant the owner logs back in. */
    private const LIFECYCLE_REMOVE_DAYS = 3;

    /** Run every sweep. Returns per-flow counts. */
    public static function runAll(): array
    {
        return [
            'demo'       => self::runDemoDunning(),
            'free_owner' => self::runFreeOwnerDunning(),            // Pro upsell (activated only)
            'lifecycle'  => self::runInactiveFreeOwnerLifecycle(),  // finish-your-site, then pause
        ];
    }

    // ── Flow A: abandoned-demo recovery ──────────────────────────────────
    public static function runDemoDunning(): int
    {
        $t    = DB::prefix() . 'instinctor_pages';
        $rows = DB::get_results(
            "SELECT id, name, settings, created_at FROM {$t}
              WHERE status = 'draft' AND owner_id IS NULL AND settings LIKE '%\"is_demo\":1%'"
        );
        $sent = 0;
        foreach ($rows ?: [] as $r) {
            $s = json_decode((string) $r->settings, true);
            if (!is_array($s)) continue;
            $email = (string) ($s['contact_email'] ?? '');
            $phone = (string) ($s['contact_phone'] ?? '');
            if ($email === '' && $phone === '') continue;               // no way to reach them

            $ageH  = (time() - strtotime((string) $r->created_at)) / 3600;
            $count = (int) ($s['dun_count'] ?? 0);
            $due   = self::dueStep($count, self::DEMO_STEPS, $ageH);
            if ($due <= $count) continue;

            $link = DemoService::demoUrl((int) $r->id);
            $biz  = trim(preg_replace('/\s*\(demo\)\s*$/i', '', (string) $r->name)) ?: 'your business';
            if ($email !== '') {
                $body = "Your website for " . $biz . " is built and waiting.\n\n"
                      . "See it and publish it free (it is yours to keep): " . $link . "\n\n"
                      . "Heads up: it will expire soon if you do not publish it.\n\nInstinctor\n";
                try { Mailer::send($email, 'Your ' . $biz . ' website is ready', $body); } catch (\Throwable $e) {}
            }
            if ($phone !== '') {
                // Explicit vendor context, same as runFreeOwnerDunning below. This is US
                // texting someone about the demo THEY asked us to build, not a tenant
                // texting their customer, so the free-tenant SMS gate must not apply.
                //
                // This flow was missed when the gates were made to fail shut, and the miss
                // was invisible: no tenant context is declared anywhere in
                // bin/dunning.php -> runAll() -> runDemoDunning(), so ambientAllowed()
                // returned false on the managed app and Sms::send bailed before the relay.
                // The email still went, so the sweep reported healthy while every
                // abandoned-demo text silently stopped. Worse, dun_count below is written
                // regardless of the send, so each step burned and the demo was purged at
                // ~48h having never been texted.
                \Instinctor\Core\Tenant::withOwner(null, function () use ($phone, $biz, $link) {
                    try { \Instinctor\Core\Sms::send($phone, 'Your ' . $biz . ' website is ready. See it and publish free before it expires: ' . $link); } catch (\Throwable $e) {}
                });
            }
            $s['dun_count'] = $due;
            $s['dun_last']  = gmdate('c');
            DB::query(DB::prepare("UPDATE {$t} SET settings = %s WHERE id = %d", wp_json_encode($s), (int) $r->id));
            $sent++;
        }
        return $sent;
    }

    // ── Flow B: free-owner Pro upsell ────────────────────────────────────
    public static function runFreeOwnerDunning(): int
    {
        $u      = DB::prefix() . 'instinctor_users';
        $owners = DB::get_results(
            "SELECT id, email, display_name, created_at FROM {$u} WHERE role = 'site_owner' AND status = 'active'"
        );
        $sent = 0;
        foreach ($owners ?: [] as $o) {
            $ownerId = (int) $o->id;
            if (\Instinctor\Core\TenantPlan::isPro($ownerId)) continue;  // already paying
            // Pitch Pro only to owners whose site is ALIVE (a lead, traffic, a return
            // visit). A cold ghost gets the lifecycle track below ("finish your site",
            // then pause), not a Pro upsell it has no use for yet.
            if (!SiteLifecycle::isActivated($ownerId)) continue;

            $ageD  = (time() - strtotime((string) $o->created_at)) / 86400;
            $count = (int) Config::get('pro_dun_count_' . $ownerId, 0);  // global-namespaced (no whitelist)
            $due   = self::dueStep($count, self::OWNER_STEPS, $ageD);
            if ($due <= $count) continue;

            $email = (string) $o->email;
            if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) continue;
            $first = trim((string) (explode(' ', trim((string) ($o->display_name ?? '')))[0] ?? '')) ?: 'there';
            $phone = trim((string) Config::getForOwner($ownerId, 'instinctor_store_phone', ''));
            $link  = self::ownerSsoLink($ownerId, $email, (string) ($o->display_name ?? ''), '/dashboard');

            // Per vertical, for the same reason the dashboard nudge is (2.1.2301): this
            // pitched every owner the salon list, and a trade's site has no booking block,
            // no reminders and no gift cards. This is the channel that actually REACHES a
            // free owner (SMS is Pro), so it was the loudest place we were selling a
            // plumber four things he does not have.
            $books = DemoService::ownerBooks($ownerId);
            $lead  = $books
                ? "Your site is live and free. For about \$1 a day, Pro does the booking work for you:"
                : "Your site is live and free. For about \$1 a day, Pro does the quoting work for you:";
            $wins  = $books
                ? ["Clients book a guaranteed time instantly, day or night",
                   "You get a text the moment someone books or messages you",
                   "Automatic reminders cut no-shows",
                   "Sell gift cards"]
                : ["Send a priced quote they approve from their phone",
                   "You get a text the moment a lead lands",
                   "Invoice and get paid without chasing",
                   "Unlimited smart chat, answering day and night"];
            $body = "Hi " . $first . ",\n\n" . $lead . "\n\n"
                  . '  . ' . implode("\n  . ", $wins) . "\n\n"
                  . "Turn it on (\$29.90/mo): " . $link . "\n\nInstinctor\n";
            try { Mailer::send($email, 'Get more from your site for $1 a day', $body); } catch (\Throwable $e) {}
            if ($phone !== '') {
                // Explicit vendor context. This is US texting OUR user to sell them Pro,
                // not a tenant texting their customer, so the free-tenant SMS gate must
                // not apply: the recipient being non-Pro is the whole reason we're here.
                //
                // It used to work by accident. The gate returned true for any null owner,
                // and this loop simply never entered one, so it looked like the vendor.
                // That is the same hole a background job would fall through in the other
                // direction, so the gate now fails shut on the managed app unless someone
                // DECLARES the context. This declares it.
                \Instinctor\Core\Tenant::withOwner(null, function () use ($phone, $first) {
                    try { \Instinctor\Core\Sms::send($phone, $first . ', get more from your site for $1/day with Pro: clients book instantly, you get texted every booking, reminders cut no-shows. ' . rtrim(self::baseUrl(), '/') . '/login'); } catch (\Throwable $e) {}
                });
            }
            Config::set('pro_dun_count_' . $ownerId, $due);
            Config::set('pro_dun_last_' . $ownerId, gmdate('c'));
            $sent++;
        }
        return $sent;
    }

    // ── Flow C: published free site — daily "make it yours", then remove ──
    //
    // Most people who publish a free site are tire kickers: they generate it, click
    // publish, and never come back. Such a site is noindexed from the start (see
    // SiteLifecycle), and here it gets a DAILY email + SMS ("make it yours") and is
    // REMOVED (pages trashed, recoverable) at LIFECYCLE_REMOVE_DAYS if it never shows
    // life. "Shows life" = any real use — a visitor, a lead, a return login, or an
    // edit (SiteLifecycle::isActivated) — which stops the clock and drops the owner
    // out of this sweep. Removal is recoverable: logging back in restores it.
    public static function runInactiveFreeOwnerLifecycle(): array
    {
        $u      = DB::prefix() . 'instinctor_users';
        $owners = DB::get_results(
            "SELECT id, email, display_name, created_at FROM {$u} WHERE role = 'site_owner' AND status = 'active'"
        );
        $nudged = 0; $removed = 0;
        $today  = gmdate('Y-m-d');
        foreach ($owners ?: [] as $o) {
            $ownerId = (int) $o->id;
            if (\Instinctor\Core\TenantPlan::isPro($ownerId)) continue;          // paying -> not here
            if (SiteLifecycle::isActivated($ownerId)) continue;                  // alive -> kept (Pro track)
            if ((string) Config::get('lifecycle_removed_' . $ownerId, '') !== '') continue;  // already removed
            // Only a LIVE published free site is in scope. A draft-only / empty account
            // has no public footprint — that is the abandoned-demo funnel (Flow A).
            $hasPub = (int) DB::get_var(DB::prepare(
                "SELECT COUNT(*) FROM " . DB::prefix() . "instinctor_pages WHERE owner_id = %d AND status = 'published'",
                $ownerId));
            if ($hasPub === 0) continue;

            $email = (string) $o->email;
            if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) continue;
            $first = trim((string) (explode(' ', trim((string) ($o->display_name ?? '')))[0] ?? '')) ?: 'there';
            $phone = trim((string) Config::getForOwner($ownerId, 'instinctor_store_phone', ''));
            $ageD  = (time() - strtotime((string) $o->created_at)) / 86400;
            $link  = self::ownerSsoLink($ownerId, $email, (string) ($o->display_name ?? ''), '/dashboard/edit');

            // Past the deadline and still cold -> remove the site (once).
            if ($ageD >= self::LIFECYCLE_REMOVE_DAYS) {
                if (self::removeOwnerSite($ownerId, $email, $first, $phone, $link)) $removed++;
                continue;
            }

            // Otherwise a daily "make it yours" nudge — at most one per calendar day.
            if ((string) Config::get('lifecycle_nudge_day_' . $ownerId, '') === $today) continue;
            $daysLeft = max(1, (int) ceil(self::LIFECYCLE_REMOVE_DAYS - $ageD));
            self::sendEditNudge($ownerId, $email, $first, $phone, $link, $daysLeft);
            Config::set('lifecycle_nudge_day_' . $ownerId, $today);
            $nudged++;
        }
        return ['nudged' => $nudged, 'removed' => $removed];
    }

    /** Daily "make it yours or it goes away" nudge: email + best-effort SMS. */
    private static function sendEditNudge(int $ownerId, string $email, string $first, string $phone, string $link, int $daysLeft): void
    {
        $when = $daysLeft === 1 ? 'tomorrow' : ('in ' . $daysLeft . ' days');
        $body = "Hi " . $first . ",\n\n"
              . "Your website is live, but it still looks like the sample we built. Make it yours: add your hours, your photos and your real details, or share the link so people visit it.\n\n"
              . "If nothing happens with it, we remove it " . $when . " to keep our directory clean. Two minutes keeps it:\n"
              . $link . "\n\nInstinctor\n";
        try { Mailer::send($email, 'Edit your site, or it goes away ' . $when, $body); } catch (\Throwable $e) {}
        if ($phone !== '') {
            // Explicit vendor context: US texting OUR user about THEIR own site, not a
            // tenant texting a customer, so the free-tenant SMS gate must not apply.
            \Instinctor\Core\Tenant::withOwner(null, function () use ($phone, $first, $when, $link) {
                try { \Instinctor\Core\Sms::send($phone, $first . ', your free site is live but still the sample. Edit it (or share it) or we remove it ' . $when . ': ' . $link); } catch (\Throwable $e) {}
            });
        }
    }

    /** Remove a cold free site: trash the owner's published page(s), record their ids
     *  for a one-click restore, and tell them (email + SMS). Recoverable — logging back
     *  in un-trashes exactly those pages (TenantDashboardController::restorePausedSite). */
    private static function removeOwnerSite(int $ownerId, string $email, string $first, string $phone, string $link): bool
    {
        // A non-positive owner id must never reach the page sweep: two registry
        // templates carried owner_id 0, so any pass that resolved to owner 0 would
        // trash the master templates as "this owner's pages" (Sergei 2026-07-29).
        if ($ownerId <= 0) return false;
        $t     = DB::prefix() . 'instinctor_pages';
        $pages = array_map('intval', (array) DB::get_col(DB::prepare(
            "SELECT id FROM {$t} WHERE owner_id = %d AND status = 'published'", $ownerId
        )));
        if ($pages) {
            $in = implode(',', $pages);
            DB::query("UPDATE {$t} SET status = 'trash' WHERE id IN ({$in})");
        }
        Config::set('lifecycle_removed_' . $ownerId, wp_json_encode(['at' => gmdate('c'), 'pages' => $pages]));

        $body = "Hi " . $first . ",\n\n"
              . "Your free site was never used after you created it, so we have removed it to keep our directory clean.\n\n"
              . "Nothing is lost. It is saved exactly as you left it, and one login brings it right back, live again:\n"
              . $link . "\n\nInstinctor\n";
        try { Mailer::send($email, 'We removed your unused site (log in to bring it back)', $body); } catch (\Throwable $e) {}
        if ($phone !== '') {
            \Instinctor\Core\Tenant::withOwner(null, function () use ($phone, $first, $link) {
                try { \Instinctor\Core\Sms::send($phone, $first . ', we removed your unused free site. It is saved; log in to bring it back: ' . $link); } catch (\Throwable $e) {}
            });
        }
        return $pages !== [];
    }

    // ── helpers ──────────────────────────────────────────────────────────

    /** Next step number due: the highest step whose threshold has passed and
     *  which has not been sent yet ($count already sent). Returns $count if none. */
    private static function dueStep(int $count, array $steps, float $age): int
    {
        $due = $count;
        foreach ($steps as $i => $threshold) {
            if (($i + 1) > $count && $age >= $threshold) $due = $i + 1;
        }
        return $due;
    }

    private static function baseUrl(): string
    {
        return defined('INSTINCTOR_STANDALONE_URL') ? INSTINCTOR_STANDALONE_URL : 'https://instinctor.com';
    }

    /** Fresh SSO magic link (7 days) so the owner lands signed in on $return. */
    private static function ownerSsoLink(int $ownerId, string $email, string $name, string $return): string
    {
        $secret = defined('INSTINCTOR_PLATFORM_TOKEN') ? INSTINCTOR_PLATFORM_TOKEN : '';
        if ($secret === '') return rtrim(self::baseUrl(), '/') . '/login';
        $payload = ['user_id' => $ownerId, 'email' => strtolower($email), 'name' => $name, 'exp' => time() + 7 * 86400];
        $b64 = rtrim(strtr(base64_encode((string) json_encode($payload)), '+/', '-_'), '=');
        $sig = hash_hmac('sha256', $b64, (string) $secret);
        return rtrim(self::baseUrl(), '/') . '/sso?token=' . rawurlencode($b64 . '.' . $sig) . '&return=' . rawurlencode($return);
    }
}
