<?php

namespace App\Http\Controllers;

use Illuminate\Contracts\View\View;

/**
 * Renders the read-only prototype views for the platform admin console.
 */
class AdminPagesController extends Controller
{
    public function dashboard(): View
    {
        return view('admin.dashboard.index');
    }

    // Users & access
    public function users(): View
    {
        return view('admin.users.index');
    }
    public function userCreate(): View
    {
        return view('admin.users.create');
    }
    public function userEdit(string $id): View
    {
        return view('admin.users.edit');
    }
    public function userImport(): View
    {
        return view('admin.users.import');
    }

    /** Stream the blank bulk-import CSV template (header row only). */
    public function userImportTemplate(): \Symfony\Component\HttpFoundation\StreamedResponse
    {
        $columns = ['name', 'email', 'mobile', 'role', 'workspace', 'gender', 'address', 'status'];
        return response()->streamDownload(function () use ($columns) {
            $out = fopen('php://output', 'w');
            fputcsv($out, $columns);
            fputcsv($out, ['Jane Doe', 'jane@example.com', '+15551234567', 'user', 'Acme', 'female', '12 Main St', 'active']);
            fclose($out);
        }, 'users-import-template.csv', ['Content-Type' => 'text/csv']);
    }
    public function userTrash(): View
    {
        return view('admin.users.trash');
    }

    // Roles + permissions are handled by RoleController + PermissionController.

    // Workspaces & devices
    public function workspaces(): View
    {
        return view('admin.workspaces.index');
    }
    public function workspaceCreate(): View
    {
        return view('admin.workspaces.create');
    }
    public function workspaceDetail(string $id): View
    {
        $ws = \App\Models\Workspace::findOrFail($id);
        $package = $ws->plan ? \App\Models\Package::find($ws->plan) : null;
        return view('admin.workspaces.detail', [
            'workspace'      => $ws,
            'package'        => $package,
            'limitColumns'   => self::PLAN_LIMIT_COLUMNS,
        ]);
    }

    /**
     * Per-workspace plan limit overrides (#31-36). Stored as JSON on
     * workspaces.plan_overrides. Reading goes through Workspace::effectiveLimit().
     */
    public function workspaceSaveOverrides(\Illuminate\Http\Request $request, string $id): \Illuminate\Http\RedirectResponse
    {
        $ws = \App\Models\Workspace::findOrFail($id);

        $rules = [];
        foreach (self::PLAN_LIMIT_COLUMNS as $key) {
            $rules['overrides.'.$key] = 'nullable|integer|min:-1|max:10000000';   // -1 = unlimited convention
        }
        $data = $request->validate($rules);
        $overrides = collect($data['overrides'] ?? [])
            ->filter(fn ($v) => $v !== null && $v !== '')
            ->map(fn ($v) => (int) $v)
            ->all();

        $ws->update(['plan_overrides' => $overrides ?: null]);
        return back()->with('success', 'Plan overrides saved.');
    }

    /**
     * The full set of integer "limit" columns on packages. Driving the
     * UI off this list keeps the override form in sync with the schema
     * — when a new limit column is added, just append here.
     */
    public const PLAN_LIMIT_COLUMNS = [
        'device_limit',
        'monthly_messages_limit',
        'contacts_limit',
        'broadcast_limit',
        'template_limit',
        'groups_limit',
        'campaign_messages_limit',
        'automation_messages_limit',
        'broadcast_size_limit',
        'total_campaigns_limit',
        'active_campaign_limit',
        'user_seat_limit',
        'tags_limit',
        'flow_limit',
        'flow_steps_limit',
        'autoreply_limit',
        'chatbot_limit',
        'scheduled_campaign_limit',
        'daily_media_size_allowance',
        // Added in 2026_05_17 extension — every user-facing feature gets a limit.
        'workspaces_per_owner_limit',
        'routing_rules_limit',
        'drip_campaigns_limit',
        'appointments_limit',
        'ai_agents_limit',
        'saved_replies_limit',
        'webhooks_limit',
        // AI token cap when the workspace falls back to admin keys.
        'ai_token_limit_monthly',
        // Sprint 9.5 — caps for WABA calling, AI voice/chat, training, widgets,
        // storefronts, SLA policies, translation.
        'waba_calling_minutes_monthly',
        'ai_voice_minutes_monthly',
        'ai_chat_messages_monthly',
        'ai_training_sources_limit',
        'chatbot_widgets_limit',
        'storefronts_limit',
        'sla_policies_limit',
        'translation_chars_monthly',
    ];

    /**
     * Boolean feature toggles a plan can enable / disable. Empty
     * value = enabled (so existing rows aren't surprise-disabled).
     * Used by Package::PLAN_FEATURE_TOGGLES on the create / edit form
     * and the PlanLimitGuard::featureEnabled() check.
     */
    public const PLAN_FEATURE_TOGGLES = [
        // Existing top-level features.
        'autoreply', 'bulkmessage', 'schedulemessage', 'ads', 'campaign',
        'autoflow', 'broadcast', 'chatgpt_suggestion', 'template',
        'access_carousel_templates', 'role_based_permissions',
        'access_drip_campaigns', 'access_ctwa', 'access_analytics', 'remove_branding',
        // Integrations.
        'integration_shopify', 'integration_woocommerce', 'integration_hubspot',
        'integration_google_calendar', 'integration_google_sheets',
        'integration_slack', 'integration_trello',
        // Granular feature gates added in 2026_05_17.
        'access_kanban_view', 'access_appointment_booking', 'access_edit_messages',
        'access_internal_notes', 'access_message_reactions', 'access_routing_rules',
        'access_business_hours', 'access_team_performance', 'access_outbound_webhooks',
        'access_keyword_replies', 'access_ai_agents',
        // Plan-level "Bring your own AI keys" — when ON, the user's
        // own keys in /settings?tab=aikeys take precedence over the
        // admin's global keys. Off by default so cheap plans don't
        // accidentally bypass the admin's billing.
        'allow_byok_ai_keys',
        // Multi-device sending — when ON, chat queues can fan out
        // across two or more paired devices, splitting the recipient
        // list round-robin so heavy sends don't bottleneck on a
        // single number. Single-select stays the default.
        'multipledevice',
        // File-type restrictions on uploads (was in fillable but missing from gate list).
        'file_type_restrictions',
        // Sprint 9.5 — Cloud-API voice calling + AI voice/chat/training.
        'access_waba_calling',
        'access_call_recording',
        'access_ai_voice_agent',
        'access_ai_chat_assistant',
        'access_ai_training',
        'access_ai_generate',
        // Sprint 9.5 — Storefront / commerce flows / chatbot widgets.
        'access_wa_storefront',
        'access_flows_commerce',
        'access_chatbot_widgets',
        // Sprint 9.5 — SLA / translation / data residency.
        'access_sla_policies',
        'access_translation',
        'access_data_residency',
    ];

    public function devices(): View
    {
        return view('admin.devices.index');
    }
    public function deviceDetail(string $id): View
    {
        return view('admin.devices.detail');
    }

    // Billing
    public function packages(): View
    {
        $packages = \App\Models\Package::query()->orderBy('sort_order')->orderBy('plan_amount')->get();

        // Workspace count per plan — used to show "N workspaces" on each card.
        $wsCounts = \Illuminate\Support\Facades\DB::table('workspaces')
            ->select('plan', \Illuminate\Support\Facades\DB::raw('COUNT(*) as c'))
            ->groupBy('plan')->pluck('c', 'plan');

        $activeCount   = $packages->where('status', true)->count();
        $trialCount    = $packages->where('free', true)->count();
        $archivedCount = $packages->where('status', false)->count();

        // Total subscribed workspaces (any plan).
        $subscribed = (int) \App\Models\Workspace::query()->whereIn('plan', $packages->pluck('id'))->count();

        // MRR = sum of the ACTUAL charged price (offer price when set) ×
        // workspace_count for paid+active plans. Using plan_amount overstated
        // MRR whenever a discounted offer price was in effect.
        $mrr = 0.0;
        foreach ($packages as $p) {
            if ($p->free || !$p->status) continue;
            $mrr += $p->chargeableAmount() * (int) ($wsCounts[$p->id] ?? 0);
        }

        return view('admin.packages.index', [
            'packages'      => $packages,
            'wsCounts'      => $wsCounts,
            'stats' => [
                'active'     => $activeCount,
                'trial'      => $trialCount,
                'archived'   => $archivedCount,
                'subscribed' => $subscribed,
                'mrr'        => $mrr,
            ],
        ]);
    }
    public function packageCreate(): View
    {
        return view('admin.packages.create', [
            'package'        => null,
            'limitColumns'   => self::PLAN_LIMIT_COLUMNS,
            'featureToggles' => self::PLAN_FEATURE_TOGGLES,
            'currencies'     => \App\Models\Currency::query()->where('is_active', true)->orderBy('code')->get(['code', 'name', 'symbol']),
        ]);
    }
    public function packageEdit(string $id): View
    {
        $package = \App\Models\Package::findOrFail($id);
        return view('admin.packages.create', [
            'package'        => $package,
            'limitColumns'   => self::PLAN_LIMIT_COLUMNS,
            'featureToggles' => self::PLAN_FEATURE_TOGGLES,
            'currencies'     => \App\Models\Currency::query()->where('is_active', true)->orderBy('code')->get(['code', 'name', 'symbol']),
        ]);
    }
    public function packageView(string $id): View
    {
        return view('admin.packages.view');
    }
    public function packageAnalytics(\Illuminate\Http\Request $request): View
    {
        $days = (int) $request->query('days', 90);
        if (! in_array($days, [30, 90, 365], true)) $days = 90;
        $packageFilter = (string) $request->query('package', '');
        $since = now()->subDays($days);

        // ── Package list (drives filter dropdown + leaderboard) ─────
        $packages = \App\Models\Package::query()->orderBy('sort_order')->orderBy('id')->get();
        $packageById = $packages->keyBy('id');

        // ── Per-plan subscriber counts (workspaces.plan = slug) ──────
        $subsByPlan = collect();
        try {
            $subsByPlan = \DB::table('workspaces')
                ->selectRaw("COALESCE(NULLIF(plan, ''), 'free') AS plan, COUNT(*) AS n")
                ->whereNull('deleted_at')
                ->groupBy('plan')->pluck('n', 'plan');
        } catch (\Throwable $e) {}

        // ── Per-plan MRR (paid orders in window) ────────────────────
        $mrrByPlan = collect();
        try {
            $mrrByPlan = \DB::table('orders')
                ->where('status', 'paid')
                ->where('created_at', '>=', $since)
                ->selectRaw('package_id, SUM(total_amount) AS mrr')
                ->groupBy('package_id')->pluck('mrr', 'package_id');
        } catch (\Throwable $e) {}

        // ── KPI hero ────────────────────────────────────────────────
        $totalMrr   = (float) $mrrByPlan->sum();
        $subsTotal  = (int)   $subsByPlan->sum();
        $arpa       = $subsTotal > 0 ? $totalMrr / $subsTotal : 0;
        // LTV avg approximated as ARPA × 28 (typical SaaS lifetime months);
        // when subs=0 this collapses to 0.
        $ltvAvg     = $arpa * 28;
        $churnPct   = 0.0;
        $cancelled30d = 0;
        $newPaid30d   = 0;
        try {
            $cancelled30d = (int) \DB::table('orders')
                ->where('status', 'cancelled')->where('updated_at', '>=', $since)->count();
            $newPaid30d   = (int) \DB::table('orders')
                ->where('status', 'paid')->where('created_at', '>=', $since)->count();
            if ($subsTotal > 0) $churnPct = round($cancelled30d / max(1, $subsTotal) * 100, 1);
        } catch (\Throwable $e) {}
        $trialActive = (int) ($subsByPlan->get('free', $subsByPlan->get('trial', 0)));
        $trialToPaidPct = $trialActive > 0 ? round($newPaid30d / max(1, $newPaid30d + $trialActive) * 100) : 0;

        // ── 90-day weekly MRR series per top-3 plans (for chart) ───
        // Weekly buckets so the line chart stays smooth even at 365d.
        $bucketWeeks = max(4, (int) round($days / 7));
        $topPlans = $packages->take(3); // first three packages = top three slugs
        $mrrSeries = ['categories' => [], 'series' => []];
        try {
            $start = now()->subWeeks($bucketWeeks - 1)->startOfWeek();
            $weekCats = [];
            for ($i = 0; $i < $bucketWeeks; $i++) {
                $weekCats[] = $start->copy()->addWeeks($i)->format('M j');
            }
            $mrrSeries['categories'] = $weekCats;

            foreach ($topPlans as $pkg) {
                $row = ['name' => (string) $pkg->pname, 'data' => array_fill(0, $bucketWeeks, 0)];
                $weekly = \DB::table('orders')
                    ->where('status', 'paid')
                    ->where('package_id', $pkg->id)
                    ->where('created_at', '>=', $start)
                    ->selectRaw('YEARWEEK(created_at, 1) as yw, SUM(total_amount) as t')
                    ->groupBy('yw')->pluck('t', 'yw');
                foreach ($weekly as $yw => $t) {
                    $y = (int) substr((string) $yw, 0, 4);
                    $w = (int) substr((string) $yw, 4);
                    $bucket = $start->copy()->setISODate($y, $w);
                    $idx = $start->diffInWeeks($bucket);
                    if ($idx >= 0 && $idx < $bucketWeeks) $row['data'][$idx] = round((float) $t / 1000, 1);
                }
                $mrrSeries['series'][] = $row;
            }
        } catch (\Throwable $e) {}

        // ── Plan share (donut) ──────────────────────────────────────
        $share = [];
        foreach ($packages as $pkg) {
            $count = (int) ($subsByPlan->get(strtolower($pkg->pname), 0)
                ?: $subsByPlan->get($pkg->plan_id ?? '', 0));
            $mrr   = (float) ($mrrByPlan->get($pkg->id, 0));
            $share[] = ['label' => $pkg->pname, 'count' => $count, 'mrr' => $mrr];
        }

        // ── Leaderboard rows ────────────────────────────────────────
        $leaderboard = [];
        foreach ($packages as $pkg) {
            $key = strtolower($pkg->pname);
            $cnt = (int) ($subsByPlan->get($key, 0));
            $mrr = (float) ($mrrByPlan->get($pkg->id, 0));
            $netNew = 0;
            try {
                $netNew = (int) \DB::table('orders')
                    ->where('status', 'paid')->where('package_id', $pkg->id)
                    ->where('created_at', '>=', $since)->count();
            } catch (\Throwable $e) {}
            $cancellations = 0;
            try {
                $cancellations = (int) \DB::table('orders')
                    ->where('status', 'cancelled')->where('package_id', $pkg->id)
                    ->where('updated_at', '>=', $since)->count();
            } catch (\Throwable $e) {}
            $churn = $cnt > 0 ? round($cancellations / $cnt * 100, 1) : 0;
            $arpaP = $cnt > 0 ? $mrr / $cnt : 0;
            $leaderboard[] = [
                'package'   => $pkg,
                'count'     => $cnt,
                'net_new'   => $netNew,
                'mrr'       => $mrr,
                'arpa'      => $arpaP,
                'churn_pct' => $churn,
                'ltv'       => $arpaP * 28,
                'trend'     => $netNew > $cancellations ? 'up' : ($netNew === $cancellations ? 'flat' : 'down'),
            ];
        }

        // ── Upgrade paths ───────────────────────────────────────────
        // Per-workspace plan transitions in the window: pull all paid
        // orders, sort by created_at, then count package_id → package_id
        // transitions. Cheap pass for a 90-day window.
        $upgrades = [];
        $totalTransitions = 0;
        try {
            $workspaceOrders = \DB::table('orders')
                ->where('status', 'paid')
                ->where('created_at', '>=', $since)
                ->whereNotNull('workspace_id')
                ->orderBy('workspace_id')->orderBy('created_at')
                ->get(['workspace_id', 'package_id', 'created_at']);
            $transitions = [];
            $prev = [];
            foreach ($workspaceOrders as $o) {
                $wsId = $o->workspace_id;
                if (isset($prev[$wsId]) && $prev[$wsId] !== $o->package_id) {
                    $key = $prev[$wsId] . '→' . $o->package_id;
                    $transitions[$key] = ($transitions[$key] ?? 0) + 1;
                }
                $prev[$wsId] = $o->package_id;
            }
            // Add a synthetic "trial → first paid" bucket: any workspace
            // that placed its first paid order in this window.
            $trialToPaid = (int) \DB::table('orders')
                ->where('status', 'paid')->where('created_at', '>=', $since)
                ->select('workspace_id')->distinct()->count('workspace_id');
            // Add a cancellations bucket.
            $cancels = $cancelled30d;
            $totalTransitions = array_sum($transitions) + $trialToPaid + $cancels;
            $maxCount = max([$trialToPaid, $cancels, ...array_values($transitions), 1]);

            if ($trialToPaid > 0) {
                $upgrades[] = ['from' => 'Trial', 'to' => 'First paid', 'count' => $trialToPaid,
                               'kind' => 'upgrade', 'pct' => (int) round($trialToPaid / $maxCount * 100)];
            }
            foreach ($transitions as $key => $cnt) {
                [$fromId, $toId] = explode('→', $key);
                $from = $packageById->get((int) $fromId)?->pname ?? '?';
                $to   = $packageById->get((int) $toId)?->pname ?? '?';
                $kind = (int) $toId > (int) $fromId ? 'upgrade' : 'downgrade';
                $upgrades[] = ['from' => $from, 'to' => $to, 'count' => $cnt,
                               'kind' => $kind, 'pct' => (int) round($cnt / $maxCount * 100)];
            }
            if ($cancels > 0) {
                $upgrades[] = ['from' => 'Any plan', 'to' => 'Cancel', 'count' => $cancels,
                               'kind' => 'cancel', 'pct' => (int) round($cancels / $maxCount * 100)];
            }
        } catch (\Throwable $e) {}

        // ── Cancellation reasons ────────────────────────────────────
        // Look for a `cancellation_reason` column on workspaces (set
        // when a workspace cancels). Empty defaults are kept as the
        // prototype layout but with real counts.
        $reasons = collect();
        try {
            if (\Schema::hasColumn('workspaces', 'cancellation_reason')) {
                $reasons = \DB::table('workspaces')
                    ->whereNotNull('cancellation_reason')
                    ->where('updated_at', '>=', $since)
                    ->selectRaw('cancellation_reason as label, COUNT(*) as n')
                    ->groupBy('cancellation_reason')->orderByDesc('n')->get();
            }
        } catch (\Throwable $e) {}
        $maxReason = (int) ($reasons->max('n') ?: 1);

        // ── Feature adoption ────────────────────────────────────────
        // Each row is % of paying workspaces that have at least one row
        // in the matching table. Tables we don't have yet → 0%.
        $payingWs = max(1, $subsTotal);
        $featureAdoption = [];
        $countDistinctWs = function (string $table) {
            try {
                if (! \Schema::hasTable($table)) return 0;
                $col = \Schema::hasColumn($table, 'workspace_id') ? 'workspace_id' : null;
                if (! $col) return 0;
                return (int) \DB::table($table)->distinct($col)->count($col);
            } catch (\Throwable $e) { return 0; }
        };
        // Real table names only — the old list pointed several rows at
        // tables that don't exist (campaigns→wpcampaigns, templates→
        // wa_templates, meta_ads→meta_campaigns) so they always read 0%,
        // and at features with no table at all (REST API, SSO/SAML) which
        // are dropped rather than shown as a permanent 0%.
        $featureAdoption = [
            ['label' => 'Inbox & reply',       'count' => $countDistinctWs('messages')],
            ['label' => 'WA campaigns',        'count' => $countDistinctWs('wpcampaigns')],
            ['label' => 'Templates',           'count' => $countDistinctWs('wa_templates')],
            ['label' => 'Flow builder',        'count' => $countDistinctWs('flows')],
            ['label' => 'Meta Ads CTWA',       'count' => $countDistinctWs('meta_campaigns')],
            ['label' => 'Shopify',             'count' => $countDistinctWs('shopify_integrations')],
            ['label' => 'WooCommerce',         'count' => $countDistinctWs('woocommerce_integrations')],
            ['label' => 'HubSpot CRM',         'count' => $countDistinctWs('hubspot_integrations')],
        ];
        foreach ($featureAdoption as &$f) {
            $f['pct'] = $subsTotal > 0 ? (int) round($f['count'] / $payingWs * 100) : 0;
        }
        unset($f);

        $brand = (string) \App\Models\SystemSetting::get('app_name', config('app.name', 'WaDesk'));

        return view('admin.packages.analytics', compact(
            'days', 'packageFilter', 'packages', 'mrrSeries', 'share', 'leaderboard',
            'totalMrr', 'subsTotal', 'arpa', 'ltvAvg', 'churnPct', 'cancelled30d',
            'newPaid30d', 'trialActive', 'trialToPaidPct', 'brand',
            'upgrades', 'totalTransitions', 'reasons', 'maxReason', 'featureAdoption'
        ));
    }

    /**
     * Validates + persists a Package row. Accepts every limit column
     * + every feature toggle. Empty limit = NULL = unlimited.
     */
    public function packageStore(\Illuminate\Http\Request $request)
    {
        return $this->packagePersist($request, null);
    }

    public function packageUpdate(\Illuminate\Http\Request $request, string $id)
    {
        $package = \App\Models\Package::findOrFail($id);
        return $this->packagePersist($request, $package);
    }

    public function packageDestroy(string $id)
    {
        $package = \App\Models\Package::findOrFail($id);
        // Block delete if any workspace currently uses this package.
        $inUse = \App\Models\Workspace::where('plan', $package->id)->count();
        if ($inUse > 0) {
            return back()->with('error', 'Cannot delete: ' . $inUse . ' workspace(s) currently use this package.');
        }
        $package->delete();
        return redirect()->route('admin.packages.index')->with('success', 'Package deleted.');
    }

    public function packageToggle(string $id)
    {
        $package = \App\Models\Package::findOrFail($id);
        $package->update(['status' => !$package->status]);
        return back()->with('success', $package->status ? 'Activated.' : 'Deactivated.');
    }

    private function packagePersist(\Illuminate\Http\Request $request, ?\App\Models\Package $package)
    {
        // packages.plan_id is a NOT NULL + UNIQUE *integer* column (legacy).
        // The admin form posts it as a free-text "slug" (e.g. "plan_pro_v5"),
        // which MySQL coerces to 0 — so EVERY package landed on plan_id=0 and
        // the second one threw "Duplicate entry '0' for key
        // packages_plan_id_unique". Resolve it defensively, ignoring whatever
        // non-numeric value the form sent:
        //   • edit  → keep the row's current id (never re-number an existing plan)
        //   • new   → use the posted value only if it's a real positive integer,
        //             otherwise auto-assign the next free integer.
        $planIdRaw = $request->input('plan_id');
        $planIdInt = is_numeric($planIdRaw) ? (int) $planIdRaw : 0;
        if ($package) {
            $request->merge(['plan_id' => $package->plan_id]);
        } elseif ($planIdInt > 0) {
            $request->merge(['plan_id' => $planIdInt]);
        } else {
            $request->merge(['plan_id' => ((int) (\App\Models\Package::max('plan_id') ?? 0)) + 1]);
        }

        // Base validation rules.
        $rules = [
            'pname'           => ['required', 'string', 'max:120'],
            'subtitle'        => ['nullable', 'string', 'max:191'],
            'detail'          => ['nullable', 'string', 'max:5000'],
            'plan_id'         => ['nullable', 'integer'],
            'plan_amount'     => ['required', 'numeric', 'min:0'],
            'offer_price'     => ['nullable', 'numeric', 'min:0'],
            'currency'        => ['required', 'string', 'max:10'],
            'plan_duration'   => ['required', 'integer', 'min:1', 'max:120'],
            'plan_unit'       => ['required', 'in:days,weeks,months,years'],
            'cta_label'       => ['nullable', 'string', 'max:64'],
            'cta_url'         => ['nullable', 'string', 'max:255'],
            'sort_order'      => ['nullable', 'integer', 'min:0'],
            'free'            => ['nullable', 'boolean'],
            'lifetime'        => ['nullable', 'boolean'],
            'status'          => ['nullable', 'boolean'],
            'is_default'      => ['nullable', 'boolean'],
            'is_highlighted'  => ['nullable', 'boolean'],
            'is_custom_quote' => ['nullable', 'boolean'],
            // Add-on packages: 'addon' = an à-la-carte feature pack bought on
            // top of a plan (grants its toggles/limits via Workspace::
            // effectiveLimit merge). 'plan' = a normal subscription (default).
            'type'                => ['nullable', 'in:plan,addon'],
            // Days after plan expiry before the workspace's data is auto-wiped
            // (WipeExpiredWorkspaces command). 0 = never.
            'data_retention_days' => ['nullable', 'integer', 'min:0', 'max:3650'],
        ];
        foreach (self::PLAN_LIMIT_COLUMNS as $col)   $rules[$col] = ['nullable', 'integer', 'min:0'];
        foreach (self::PLAN_FEATURE_TOGGLES as $col) $rules[$col] = ['nullable', 'boolean'];
        $data = $request->validate($rules);

        // Normalize: empty limit = NULL (unlimited). Checkbox unchecked
        // = false. The form sends "1" for checked checkboxes only.
        $payload = [];
        foreach ($data as $k => $v) {
            if (in_array($k, self::PLAN_LIMIT_COLUMNS, true)) {
                $payload[$k] = ($v === null || $v === '') ? null : (int) $v;
            } elseif (in_array($k, self::PLAN_FEATURE_TOGGLES, true) || in_array($k, ['free', 'lifetime', 'status', 'is_default', 'is_highlighted', 'is_custom_quote'], true)) {
                $payload[$k] = (bool) $v;
            } else {
                $payload[$k] = $v;
            }
        }
        // Ensure every feature toggle has an explicit value (unchecked
        // checkboxes don't submit at all). Default to false.
        foreach (self::PLAN_FEATURE_TOGGLES as $col) {
            if (!array_key_exists($col, $payload)) $payload[$col] = false;
        }
        foreach (['free', 'lifetime', 'status', 'is_default', 'is_highlighted', 'is_custom_quote'] as $col) {
            if (!array_key_exists($col, $payload)) $payload[$col] = false;
        }
        // Add-on type + retention — explicit defaults (form may omit them).
        $payload['type'] = in_array(($data['type'] ?? 'plan'), ['plan', 'addon'], true) ? $data['type'] : 'plan';
        $payload['data_retention_days'] = (($data['data_retention_days'] ?? '') === '' || ($data['data_retention_days'] ?? null) === null)
            ? 0 : (int) $data['data_retention_days'];

        // Add-ons are managed from their own /admin/addons section — send the
        // operator back there after save; plans go to the packages list.
        $isAddon = ($payload['type'] ?? 'plan') === 'addon';
        $route   = $isAddon ? 'admin.addons.index' : 'admin.packages.index';
        $noun    = $isAddon ? 'Add-on' : 'Package';

        if ($package) {
            $package->update($payload);
            return redirect()->route($route)->with('success', "{$noun} updated.");
        }
        \App\Models\Package::create($payload);
        return redirect()->route($route)->with('success', "{$noun} created.");
    }

    public function billingHistory(): View
    {
        return view('admin.billing-history.index');
    }
    public function billingAnalytics(): View
    {
        return view('admin.billing-history.analytics');
    }
    public function orderHistory(): View
    {
        return view('admin.order-history.index');
    }
    public function orderAnalytics(): View
    {
        return view('admin.order-history.analytics');
    }
    public function invoices(): View
    {
        return view('admin.invoices.index');
    }
    public function invoiceView(string $id): View
    {
        return view('admin.invoices.view');
    }
    public function coupons(): View
    {
        return view('admin.coupons.index');
    }

    // Messaging / platform
    public function campaigns(): View
    {
        return view('admin.campaigns.index');
    }
    public function campaignCreate(): View
    {
        $previewDevice = \App\Models\Device::query()
            ->where('active', 1)
            ->orderByDesc('id')
            ->first();
        return view('admin.campaigns.create', [
            'previewDeviceName'   => $previewDevice?->device_name ?: 'Your business',
            'previewDeviceRegion' => 'business · ' . strtoupper($previewDevice?->region ?: 'IN'),
        ]);
    }
    public function campaignAnalytics(): View
    {
        return view('admin.campaigns.analytics');
    }
    public function broadcasts(): View
    {
        return view('admin.broadcasts.index');
    }
    public function templates(): View
    {
        return view('admin.templates.index');
    }
    public function flows(): View
    {
        return view('admin.flows.index');
    }
    public function autoReplies(): View
    {
        return view('admin.auto-replies.index');
    }
    public function integrations(): View
    {
        return view('admin.integrations.index');
    }
    public function webhooks(): View
    {
        return view('admin.webhooks.index');
    }
    public function contacts(): View
    {
        return view('admin.contacts.index');
    }

    // Meta Ads
    public function metaAds(): View
    {
        return view('admin.meta-ads.index');
    }
    public function metaAdCreate(): View
    {
        return view('admin.meta-ads.create');
    }
    public function metaAdEdit(string $id): View
    {
        return view('admin.meta-ads.edit');
    }
    public function metaAdsAnalytics(): View
    {
        return view('admin.meta-ads.analytics');
    }
    public function metaAdsAnalyticsDetail(string $id): View
    {
        return view('admin.meta-ads.analytics-detail');
    }

    // Support
    public function support(): View
    {
        // Every ticket every workspace has opened across the platform.
        // The user-side flow at /support writes rows to `support_tickets`;
        // this view is where admins triage them.
        //
        // Eager-load `user` + `workspace` so the Workspace column +
        // requester sub-line render without N+1 fetches.
        $tickets = \App\Models\SupportTicket::query()
            ->with(['user', 'workspace'])
            ->orderByDesc('id')
            ->limit(50)
            ->get();

        $openCount       = \App\Models\SupportTicket::where('status', '!=', 'resolved')->count();
        $unassignedCount = $openCount; // No admin-assignee column yet; every open ticket is unassigned today.
        $urgentCount     = \App\Models\SupportTicket::where('status', '!=', 'resolved')
            ->where('created_at', '<=', now()->subHour())->count();
        $breachCount     = \App\Models\SupportTicket::where('status', '!=', 'resolved')
            ->where('created_at', '<=', now()->subHours(4))->count();
        $resolvedTodayCount = \App\Models\SupportTicket::whereDate('resolved_at', now()->toDateString())->count();

        $kpi = [
            'open'           => $openCount,
            'unassigned'     => $unassignedCount,
            'urgent'         => $urgentCount,
            'breach'         => $breachCount,
            'resolved_today' => $resolvedTodayCount,
            // Mine = tickets assigned to the current admin. No
            // assignee column shipped yet, so zero for now — wire
            // when we ship the assignment UI.
            'mine'           => 0,
        ];

        return view('admin.support.index', compact('tickets', 'kpi'));
    }
    public function supportSla(): View
    {
        return view('admin.support.sla');
    }
    public function supportCustomers(): View
    {
        return view('admin.support.customers');
    }
    public function supportPlaybooks(): View
    {
        return view('admin.support.playbooks');
    }
    public function supportReports(): View
    {
        return view('admin.support.reports');
    }
    public function agents(): View
    {
        return view('admin.agents.index');
    }
    public function agentDetail(string $id): View
    {
        return view('admin.agents.detail');
    }
    public function teamInbox(): View
    {
        return view('admin.team-inbox.index');
    }

    // Marketing
    public function announcements(): View
    {
        return view('admin.announcements.index');
    }
    public function guidebook(): View
    {
        return view('admin.guidebook.index');
    }

    // Platform admin
    public function analytics(\Illuminate\Http\Request $request): View|\Symfony\Component\HttpFoundation\StreamedResponse
    {
        $days = max(1, min(365, (int) $request->query('days', 30)));
        $since = now()->subDays($days);

        // ?export=csv → stream a daily-signup CSV for the window. Kept
        // small on purpose: the rest of the dashboard is interactive,
        // CSV is for "give me the chart series" use cases.
        if ($request->query('export') === 'csv') {
            $rows = \App\Models\User::where('created_at', '>=', $since)
                ->selectRaw('DATE(created_at) as d, COUNT(*) as n')
                ->groupBy('d')->orderBy('d')->get();
            $filename = 'analytics-signups-' . $days . 'd-' . now()->format('Ymd-Hi') . '.csv';
            return response()->streamDownload(function () use ($rows) {
                $out = fopen('php://output', 'w');
                fputcsv($out, ['date', 'signups']);
                foreach ($rows as $r) fputcsv($out, [$r->d, (int) $r->n]);
                fclose($out);
            }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
        }

        // ── Devices ─────────────────────────────────────────────────
        $devices = \App\Models\Device::query()
            ->orderByDesc('active')->orderByDesc('id')->limit(8)->get();
        $deviceLabels = $devices->map(function ($d) {
            $tail = $d->phone_number ? ' · +' . ltrim($d->country_code ?: '', '+') . ' ' . substr($d->phone_number, 0, 4) . 'xx' : '';
            return ($d->device_name ?: 'Device #' . $d->id) . $tail;
        })->values()->all();
        $deviceData         = $devices->map(fn ($d) => (int) $d->sent_24h)->values()->all();
        $devicesOnlineCount = \App\Models\Device::where('status', 'connected')->where('active', true)->count();
        $devicesTotalCount  = \App\Models\Device::count();

        // ── Platform headline KPIs ──────────────────────────────────
        $kpi = [
            'users_total'       => \App\Models\User::count(),
            'users_new'         => \App\Models\User::where('created_at', '>=', $since)->count(),
            'workspaces_total'  => \App\Models\Workspace::count(),
            'workspaces_new'    => \App\Models\Workspace::where('created_at', '>=', $since)->count(),
            'devices_online'    => $devicesOnlineCount,
            'devices_total'     => $devicesTotalCount,
        ];

        // Best-effort message + revenue stats. Tables may not exist on
        // fresh installs (e.g. messages_outbound is sprint-N); guard.
        try {
            $kpi['messages_sent']   = \DB::table('messages_outbound')->where('created_at', '>=', $since)->count();
        } catch (\Throwable $e) { $kpi['messages_sent'] = null; }
        try {
            $kpi['revenue_window']  = (float) \DB::table('orders')
                ->where('status', 'paid')->where('created_at', '>=', $since)->sum('total_amount');
        } catch (\Throwable $e) { $kpi['revenue_window'] = null; }
        try {
            $kpi['tickets_open']    = \DB::table('support_tickets')->whereIn('status', ['open','in_progress','pending'])->count();
        } catch (\Throwable $e) { $kpi['tickets_open'] = 0; }

        // ── Daily signups (sparkline) ───────────────────────────────
        $signupSeries = \App\Models\User::where('created_at', '>=', $since)
            ->selectRaw('DATE(created_at) as d, COUNT(*) as n')
            ->groupBy('d')->orderBy('d')->get();

        // ── Plan distribution ───────────────────────────────────────
        // workspaces.plan is a string slug (starter, pro, free, …) — no
        // FK join required. Single quotes inside the raw SELECT are
        // important: double quotes would be interpreted as identifiers
        // under ANSI_QUOTES sql_mode and silently break the query.
        $planDistribution = collect();
        try {
            $planDistribution = \DB::table('workspaces')
                ->selectRaw("COALESCE(NULLIF(plan, ''), 'free') as plan, COUNT(*) as n")
                ->groupBy('plan')
                ->orderByDesc('n')
                ->get()
                ->map(fn ($r) => (object) ['plan' => ucfirst((string) $r->plan), 'n' => (int) $r->n])
                ->values();
        } catch (\Throwable $e) {}

        // ── Top workspaces by 30-day spend ──────────────────────────
        $topWorkspaces = collect();
        try {
            $topWorkspaces = \DB::table('orders')
                ->join('workspaces', 'workspaces.id', '=', 'orders.workspace_id')
                ->where('orders.status', 'paid')
                ->where('orders.created_at', '>=', $since)
                ->selectRaw('workspaces.id, workspaces.name, SUM(orders.total_amount) as paid, COUNT(*) as n')
                ->groupBy('workspaces.id', 'workspaces.name')
                ->orderByDesc('paid')->limit(8)->get();
        } catch (\Throwable $e) {}

        return view('admin.analytics.index', compact(
            'days', 'kpi',
            'deviceLabels', 'deviceData', 'devicesOnlineCount', 'devicesTotalCount',
            'signupSeries', 'planDistribution', 'topWorkspaces',
        ));
    }
    public function auditLog(): View
    {
        return view('admin.audit-log.index');
    }
    public function security(): View
    {
        return view('admin.security.index');
    }

    // Settings
    public function settings(): View
    {
        // Surface affiliate / wallet tunables AND the 3-provider toggle
        // on the settings index so an admin can configure both from the
        // same place. The provider toggle is the master switch that
        // controls which methods workspaces can connect to at /connect.
        $allowed = \App\Models\SystemSetting::get('allowed_send_methods', ['waba', 'baileys', 'twilio']);
        $allowed = is_array($allowed) ? $allowed : ['waba', 'baileys', 'twilio'];

        $settings = [
            'referral_signup_credits'    => (int)   \App\Models\SystemSetting::get('referral_signup_credits', 100),
            'credits_per_message'        => (int)   \App\Models\SystemSetting::get('credits_per_message', 1),
            'credits_per_currency_minor' => (float) \App\Models\SystemSetting::get('credits_per_currency_minor', 0.1),
            'default_send_method'        => (string) \App\Models\SystemSetting::get('default_send_method', 'baileys'),
            'allowed_send_methods'       => $allowed,
            // Per-provider admin defaults — workspaces reuse these unless
            // they override in their own wa_provider_configs row.
            'waba_app_id'                => (string) \App\Models\SystemSetting::get('waba_app_id', ''),
            'waba_app_secret_set'        => \App\Models\SystemSetting::where('key', 'waba_app_secret')->exists(),
            'waba_config_id'             => (string) \App\Models\SystemSetting::get('waba_config_id', ''),
            'waba_coexistence'           => (bool) \App\Models\SystemSetting::get('waba_coexistence', false),
            'waba_webhook_verify_token'  => (string) \App\Models\SystemSetting::get('waba_webhook_verify_token', ''),
            'baileys_server_url'         => (string) \App\Models\SystemSetting::get('baileys_server_url', env('SERVER_URL', '')),
            'twilio_account_sid'         => (string) \App\Models\SystemSetting::get('twilio_account_sid', ''),
            'twilio_auth_token_set'      => \App\Models\SystemSetting::where('key', 'twilio_auth_token')->exists(),
            'twilio_whatsapp_number'     => (string) \App\Models\SystemSetting::get('twilio_whatsapp_number', ''),
            // Phase 5 — multi-WABA dispatcher feature flag + Graph API version.
            'waba_dispatch_v2_enabled'   => (bool)   \App\Models\SystemSetting::get('waba_dispatch_v2_enabled', false),
            'waba_graph_api_version'     => (string) \App\Models\SystemSetting::get('waba_graph_api_version', 'v23.0'),
            'waba_connected_count'       => (int)    \DB::table('wa_provider_configs')->where('provider', 'waba')->where('status', 'connected')->count(),
            // Phase 6 — templates v2 (Meta /message_templates submit + status sync).
            'waba_templates_v2_enabled'  => (bool)   \App\Models\SystemSetting::get('waba_templates_v2_enabled', false),
            'waba_template_polling_min'  => (int)    \App\Models\SystemSetting::get('waba_template_polling_min', 30),
            'waba_template_lint_strict'  => (bool)   \App\Models\SystemSetting::get('waba_template_lint_strict', true),
            // Sender pacing — Node bridge reads these via /api/whatsapp-message-settings
            // and uses them as msg_gap (sec), batches_gap (per batch),
            // bw_msg_gap (min), enable_batches (0/1). Keys match
            // node/index.js exactly so the bridge picks them up without
            // any translation layer.
            'msg_gap'                    => (int)    \App\Models\SystemSetting::get('msg_gap', 3),
            'batches_gap'                => (int)    \App\Models\SystemSetting::get('batches_gap', 50),
            'bw_msg_gap'                 => (int)    \App\Models\SystemSetting::get('bw_msg_gap', 5),
            'enable_batches'             => (bool)   \App\Models\SystemSetting::get('enable_batches', false),
        ];
        // KPI strip on the index — replaces the hardcoded 12/9/3/8/2h
        // placeholders. Numbers come from the actual system_settings
        // table so a fresh install starts blank and fills up as the
        // admin configures things.
        $secretPatterns = ['_secret', '_password', '_token', '_apikey', '_api_key'];
        $allKv = \App\Models\SystemSetting::query()->select(['key', 'value', 'updated_at'])->get();
        $totalRows = $allKv->count();
        $healthyRows = $allKv->filter(fn ($r) => trim((string) $r->value) !== '')->count();
        $secretRows = $allKv->filter(function ($r) use ($secretPatterns) {
            $k = strtolower($r->key);
            foreach ($secretPatterns as $p) if (str_ends_with($k, $p)) return true;
            return false;
        })->filter(fn ($r) => trim((string) $r->value) !== '')->count();

        // "Needs test" = saved but never verified by an audit ping. Looks
        // for the matching audit-action keys; missing tables don't crash.
        $needsTest = [];
        try {
            $testedActions = \App\Models\AuditLog::query()->whereIn('action', [
                'settings.mail_test', 'settings.catalog_test', 'shopify.oauth_completed',
            ])->where('outcome', 'success')->pluck('action')->unique()->all();
            if (! in_array('settings.mail_test', $testedActions, true)
                && trim((string) \App\Models\SystemSetting::get('mail_host', '')) !== '') $needsTest[] = 'mail';
            if (\App\Models\SystemSetting::get('catalog_enabled')
                && ! in_array('settings.catalog_test', $testedActions, true)) $needsTest[] = 'catalog';
            if (\App\Models\SystemSetting::get('shopify_enabled')
                && ! in_array('shopify.oauth_completed', $testedActions, true)) $needsTest[] = 'shopify';
        } catch (\Throwable $e) {}

        $lastUpdate = $allKv->max('updated_at');
        $lastUpdateBy = null;
        try {
            $lastAudit = \App\Models\AuditLog::query()
                ->where('action', 'like', 'settings.%')
                ->orderByDesc('created_at')
                ->with('user:id,name')
                ->first();
            $lastUpdateBy = $lastAudit?->user?->name;
        } catch (\Throwable $e) {}

        $kpi = [
            'total_rows'    => $totalRows,
            'healthy'       => $healthyRows,
            'needs_test'    => $needsTest,
            'secret_count'  => $secretRows,
            'last_updated'  => $lastUpdate,
            'last_updated_by' => $lastUpdateBy,
        ];

        return view('admin.settings.index', compact('settings', 'kpi'));
    }

    public function settingsProvidersUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        // Track every providers/pacing save so a "toggle didn't save" report is
        // reproducible from the log: exactly what reached the controller +
        // the raw enable_batches value before validation/cast.
        \Illuminate\Support\Facades\Log::info('[WADESK-MSG] providers.update received', [
            'allowed_send_methods'=> $request->input('allowed_send_methods'),
            'default_engine'      => $request->input('default_engine'),
            'enable_batches_raw'  => $request->input('enable_batches'),     // null when unchecked
            'enable_batches_bool' => $request->boolean('enable_batches'),
            'msg_gap'             => $request->input('msg_gap'),
            'batches_gap'         => $request->input('batches_gap'),
            'bw_msg_gap'          => $request->input('bw_msg_gap'),
            'node_token_field_filled' => filled($request->input('node_webhook_token')),
            'admin_id'            => optional($request->user())->id,
        ]);

        $data = $request->validate([
            // Multi-engine: the admin enables ANY SUBSET of the three engines
            // platform-wide; each workspace then uses whatever it connects
            // within this set. `default_engine` is the platform fallback for
            // sends that don't pin a sender. (Was a single `active_engine`
            // radio — now checkboxes + a default picker.)
            'allowed_send_methods'       => 'required|array|min:1',
            'allowed_send_methods.*'     => 'in:twilio,wa-api,business-api',
            'default_engine'             => 'nullable|in:twilio,wa-api,business-api',
            'waba_app_id'                => 'nullable|string|max:64',
            'waba_app_secret'            => 'nullable|string|max:128',
            'waba_config_id'             => 'nullable|string|max:64',
            'waba_coexistence'           => 'nullable|boolean',
            'waba_webhook_verify_token'  => 'nullable|string|max:96',
            'baileys_server_url'         => 'nullable|url|max:191',
            'twilio_account_sid'         => 'nullable|string|max:64',
            'twilio_auth_token'          => 'nullable|string|max:128',
            'twilio_whatsapp_number'     => 'nullable|string|max:32',
            // Phase 5 feature flag — gate the multi-tenant WABA dispatcher.
            // Default OFF; admin flips ON once they've connected at least
            // one WABA account at /devices. Rollback by flipping OFF.
            'waba_dispatch_v2_enabled'   => 'sometimes|boolean',
            'waba_graph_api_version'     => 'nullable|regex:/^v\d{1,2}\.\d{1,2}$/',
            'waba_templates_v2_enabled'  => 'sometimes|boolean',
            'waba_template_polling_min'  => 'sometimes|integer|min:5|max:240',
            'waba_template_lint_strict'  => 'sometimes|boolean',
            // Sender pacing — Node-side key names, matched 1:1.
            'msg_gap'                    => 'sometimes|integer|min:1|max:600',
            'batches_gap'                => 'sometimes|integer|min:1|max:10000',
            'bw_msg_gap'                 => 'sometimes|integer|min:1|max:1440',
            'enable_batches'             => 'sometimes|boolean',
            'node_webhook_token'         => 'nullable|string|max:191',
        ]);

        // Multi-engine: map each visual engine slug → internal provider key.
        // The admin may enable any subset (1, 2, or all 3). allowed_send_methods
        // is the platform-wide enabled SET; default_send_method is the fallback
        // engine for sends that don't pin a sender. 3 cards — "Business API"
        // covers both manual-token + Embedded Signup (same Meta Cloud API).
        $slugToProvider = [
            'twilio'       => 'twilio',
            'wa-api'       => 'baileys',
            'business-api' => 'waba',
        ];
        $providerToSlug = array_flip($slugToProvider);

        // Enabled set (unique, order-preserving) of internal provider keys.
        $enabledProviders = array_values(array_unique(array_map(
            fn ($slug) => $slugToProvider[$slug],
            $data['allowed_send_methods']
        )));

        // Default engine must be one of the enabled set. If the posted default
        // isn't enabled (or wasn't posted), fall back to the first enabled.
        $defaultProvider = isset($data['default_engine'])
            ? ($slugToProvider[$data['default_engine']] ?? null)
            : null;
        if (! $defaultProvider || ! in_array($defaultProvider, $enabledProviders, true)) {
            $defaultProvider = $enabledProviders[0];
        }
        $defaultSlug = $providerToSlug[$defaultProvider] ?? 'wa-api';

        \App\Models\SystemSetting::set('allowed_send_methods', $enabledProviders, 'json',   'Platform-wide enabled WhatsApp engines (any subset of baileys/waba/twilio).');
        \App\Models\SystemSetting::set('default_send_method',  $defaultProvider,  'string', 'Default engine for sends that do not pin a sender.');
        \App\Models\SystemSetting::set('active_engine_slug',   $defaultSlug,      'string', 'Default engine card (back-compat: the visual slug of default_send_method).');

        // Per-provider creds. Empty strings clear the value; secrets
        // are only updated when the form actually carried a value
        // (so re-saving the page doesn't blank out a stored secret).
        \App\Models\SystemSetting::set('waba_app_id',               $data['waba_app_id']               ?? '', 'string', 'Meta App ID for Embedded Signup.');
        \App\Models\SystemSetting::set('waba_config_id',            $data['waba_config_id']            ?? '', 'string', 'Meta Login Configuration ID.');
        \App\Models\SystemSetting::set('waba_coexistence',          $request->boolean('waba_coexistence'), 'bool', 'Launch Embedded Signup in WhatsApp Coexistence mode (onboard existing Business App numbers).');
        \App\Models\SystemSetting::set('waba_webhook_verify_token', $data['waba_webhook_verify_token'] ?? '', 'string', 'Webhook verify token Meta will echo on subscription.');
        if (!empty($data['waba_app_secret'])) {
            \App\Models\SystemSetting::set('waba_app_secret', $data['waba_app_secret'], 'string', 'Meta App Secret (encrypt-at-rest recommended).');
        }
        \App\Models\SystemSetting::set('baileys_server_url', $data['baileys_server_url'] ?? '', 'string', 'Default URL of the Baileys Node bridge.');
        // Mirror the Server URL into .env SERVER_URL so DB and env never drift
        // (admin doesn't hand-edit .env). DB stays authoritative via wd_node_url().
        \App\Support\EnvWriter::set('SERVER_URL', (string) ($data['baileys_server_url'] ?? ''));

        \App\Models\SystemSetting::set('twilio_account_sid',     $data['twilio_account_sid']     ?? '', 'string', 'Twilio Account SID.');
        \App\Models\SystemSetting::set('twilio_whatsapp_number', $data['twilio_whatsapp_number'] ?? '', 'string', 'Twilio WhatsApp From number.');
        if (!empty($data['twilio_auth_token'])) {
            \App\Models\SystemSetting::set('twilio_auth_token', $data['twilio_auth_token'], 'string', 'Twilio Auth Token.');
        }

        // Phase 5 — multi-WABA dispatcher toggle + Graph API version.
        \App\Models\SystemSetting::set('waba_dispatch_v2_enabled', $request->boolean('waba_dispatch_v2_enabled'), 'bool', 'Route /chat sends through wa_provider_configs instead of .env (multi-tenant).');
        if (! empty($data['waba_graph_api_version'])) {
            \App\Models\SystemSetting::set('waba_graph_api_version', $data['waba_graph_api_version'], 'string', 'Meta Cloud API version for outbound sends.');
        }

        // Phase 6 — templates v2: Meta submission + status sync.
        \App\Models\SystemSetting::set('waba_templates_v2_enabled', $request->boolean('waba_templates_v2_enabled'), 'bool', 'POST templates to Meta /message_templates on save + sync status via webhook/poll.');
        if (array_key_exists('waba_template_polling_min', $data)) {
            \App\Models\SystemSetting::set('waba_template_polling_min', (int) $data['waba_template_polling_min'], 'int', 'Server-side sweep interval (minutes) for PENDING templates older than 1h.');
        }
        \App\Models\SystemSetting::set('waba_template_lint_strict', $request->boolean('waba_template_lint_strict'), 'bool', 'When ON, lint warnings (trigger phrases, long bodies) block submit instead of just warning.');

        // Sender pacing — exact Node-side key names so the bridge
        // picks them up without translation. Node reads these via
        // /api/whatsapp-message-settings (WaConnectController::nodeMessageSettings).
        if (array_key_exists('msg_gap',     $data)) {
            \App\Models\SystemSetting::set('msg_gap',     (int) $data['msg_gap'],     'int', 'Seconds between consecutive outbound sends (Node bridge).');
        }
        if (array_key_exists('batches_gap', $data)) {
            \App\Models\SystemSetting::set('batches_gap', (int) $data['batches_gap'], 'int', 'Recipients per send batch (Node bridge).');
        }
        if (array_key_exists('bw_msg_gap',  $data)) {
            \App\Models\SystemSetting::set('bw_msg_gap',  (int) $data['bw_msg_gap'],  'int', 'Minutes between batches (Node bridge).');
        }
        \App\Models\SystemSetting::set('enable_batches', $request->boolean('enable_batches'), 'bool', 'Whether the Node bridge breaks large sends into batches.');

        // Shared Node-bridge secret (X-Node-Token). Only update when the admin
        // actually typed a value — the field is masked + blank on load, so an
        // empty submit must KEEP the existing token, not wipe it. Stored
        // encrypted at rest (node_webhook_token ∈ SystemSetting::ENCRYPTED_KEYS).
        if (filled($data['node_webhook_token'] ?? null)) {
            \App\Models\SystemSetting::set('node_webhook_token', trim((string) $data['node_webhook_token']), 'string', 'Shared secret authenticating Node <-> Laravel bridge calls (X-Node-Token).');
            // Mirror into .env so DB and Laravel's env stay identical — no
            // manual .env editing. (Node's own node/.env must still match.)
            \App\Support\EnvWriter::set('NODE_WEBHOOK_TOKEN', trim((string) $data['node_webhook_token']));
        }

        \Illuminate\Support\Facades\Log::info('[WADESK-MSG] providers.update SAVED', [
            'enable_batches' => (bool) \App\Models\SystemSetting::get('enable_batches', false),
            'msg_gap'        => (int)  \App\Models\SystemSetting::get('msg_gap', 3),
            'node_token_set' => node_token() !== '',
        ]);

        // Push the new pacing + provider settings to the Node bridge NOW
        // instead of waiting up to an hour for its auto-refresh timer.
        // refreshNodeSettings() reloads Node's global app.locals.messageSettings
        // (msg_gap/batches_gap/bw_msg_gap) + whatsappSettings; bustAll() flushes
        // the per-phone WABA settings cache since provider creds may have
        // changed. Both are best-effort and never block the save.
        \App\Services\NodeCacheBuster::refreshNodeSettings();
        \App\Services\NodeCacheBuster::bustAll();

        return redirect()->route('admin.settings.wadesk-message')->with('status', 'Provider settings saved.');
    }

    /**
     * Quick "Update timing" save — persists ONLY the sender-pacing fields
     * (msg_gap / batches_gap / bw_msg_gap / enable_batches) and pushes them to
     * the Node bridge immediately, WITHOUT re-submitting the whole providers
     * form. Returns JSON so the admin page updates inline. Same SystemSetting
     * keys + Node refresh as settingsProvidersUpdate(), just scoped to pacing.
     */
    public function settingsPacingUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\JsonResponse
    {
        $data = $request->validate([
            'msg_gap'        => 'required|integer|min:1|max:600',
            'batches_gap'    => 'required|integer|min:1|max:10000',
            'bw_msg_gap'     => 'required|integer|min:1|max:1440',
            'enable_batches' => 'sometimes|boolean',
        ]);

        \App\Models\SystemSetting::set('msg_gap',     (int) $data['msg_gap'],     'int', 'Seconds between consecutive outbound sends (Node bridge).');
        \App\Models\SystemSetting::set('batches_gap', (int) $data['batches_gap'], 'int', 'Recipients per send batch (Node bridge).');
        \App\Models\SystemSetting::set('bw_msg_gap',  (int) $data['bw_msg_gap'],  'int', 'Minutes between batches (Node bridge).');
        \App\Models\SystemSetting::set('enable_batches', $request->boolean('enable_batches'), 'bool', 'Whether the Node bridge breaks large sends into batches.');

        \Illuminate\Support\Facades\Log::info('[WADESK-MSG] pacing quick-save', [
            'msg_gap'        => (int) $data['msg_gap'],
            'batches_gap'    => (int) $data['batches_gap'],
            'bw_msg_gap'     => (int) $data['bw_msg_gap'],
            'enable_batches' => $request->boolean('enable_batches'),
            'admin_id'       => optional($request->user())->id,
        ]);

        // Push to the Node bridge NOW so the new gap takes effect on the next
        // send without waiting for the bridge's hourly auto-refresh. The call
        // returns what the bridge NOW actually holds (or null if unreachable),
        // so the admin can confirm the value landed instead of guessing.
        $bridge = \App\Services\NodeCacheBuster::refreshNodeSettings();

        return response()->json([
            'ok'      => true,
            'message' => 'Sending timing updated and pushed to the bridge.',
            'values'  => [
                'msg_gap'        => (int) $data['msg_gap'],
                'batches_gap'    => (int) $data['batches_gap'],
                'bw_msg_gap'     => (int) $data['bw_msg_gap'],
                'enable_batches' => $request->boolean('enable_batches'),
            ],
            // What the Node bridge reports holding after the push. null = the
            // bridge is unreachable (wrong baileys_server_url / token / offline).
            'bridge'  => $bridge ? [
                'msg_gap'        => $bridge['msg_gap']        ?? null,
                'batches_gap'    => $bridge['batches_gap']    ?? null,
                'bw_msg_gap'     => $bridge['bw_msg_gap']     ?? null,
                'enable_batches' => $bridge['enable_batches'] ?? null,
            ] : null,
        ]);
    }

    public function settingsAffiliateUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate([
            'referral_signup_credits'    => 'required|integer|min:0|max:1000000',
            'credits_per_message'        => 'required|integer|min:1|max:1000',
            'credits_per_currency_minor' => 'nullable|numeric|min:0|max:100000',
        ]);
        \App\Models\SystemSetting::set('referral_signup_credits',    $data['referral_signup_credits'],    'int',  'Credits awarded to the referrer when their referee signs up.');
        \App\Models\SystemSetting::set('credits_per_message',        $data['credits_per_message'],        'int',  'Credits charged per outbound message.');
        // Top-up conversion rate — only overwrite when a value is provided so a
        // blank submit never zeroes the rate.
        if ($request->filled('credits_per_currency_minor')) {
            \App\Models\SystemSetting::set('credits_per_currency_minor', (float) $data['credits_per_currency_minor'], 'float', 'Credits granted per major unit of currency at top-up.');
        }
        return redirect()->route('admin.settings.wallet-rules')->with('status', 'Wallet rules saved.');
    }

    public function settingWalletRules(): \Illuminate\Contracts\View\View
    {
        $settings = [
            'referral_signup_credits'    => (int)   \App\Models\SystemSetting::get('referral_signup_credits', 100),
            'credits_per_message'        => (int)   \App\Models\SystemSetting::get('credits_per_message', 1),
            'credits_per_currency_minor' => (float) \App\Models\SystemSetting::get('credits_per_currency_minor', 0.1),
        ];
        return view('admin.settings.wallet-rules', compact('settings'));
    }
    /**
     * Themes that get their own logo upload slot. Keep this in sync with
     * resources/js/wadesk.js THEMES array — adding a theme there means
     * adding it here too. The id is what `[data-theme="<id>"]` uses on
     * the html element; falsy default ("paper") means no attribute set.
     */
    public const BRAND_THEMES = [
        ['id' => 'paper',  'label' => 'Paper (default)', 'note' => 'Light, off-white. The default look.'],
        ['id' => 'bright', 'label' => 'Bright white',    'note' => 'Higher-contrast white background.'],
        ['id' => 'dark',   'label' => 'Dark (beta)',     'note' => 'Use a light/inverted version of your logo.'],
        ['id' => 'doodle', 'label' => 'Doodle (fancy)',  'note' => 'Pastel mint background.'],
    ];

    public function settingGeneral(): View
    {
        $settings = [
            'app_name'        => (string) \App\Models\SystemSetting::get('app_name',        config('app.name', 'WaDesk')),
            'app_url'         => (string) \App\Models\SystemSetting::get('app_url',         config('app.url', '')),
            'support_email'   => (string) \App\Models\SystemSetting::get('support_email',   ''),
            'contact_number'  => (string) \App\Models\SystemSetting::get('contact_number',  ''),
            'from_email'      => (string) \App\Models\SystemSetting::get('from_email',      ''),
            'default_timezone'=> (string) \App\Models\SystemSetting::get('default_timezone',config('app.timezone', 'Asia/Kolkata')),
            'default_language'=> (string) \App\Models\SystemSetting::get('default_language',config('app.locale', 'en')),
            'default_currency'=> (string) \App\Models\SystemSetting::get('default_currency','USD'),
            'font_family'     => (string) \App\Models\SystemSetting::get('font_family',     ''),
            'address'         => (string) \App\Models\SystemSetting::get('address',         ''),
            'map_iframe_url'  => (string) \App\Models\SystemSetting::get('map_iframe_url',  ''),
            'preloader'       => (bool)   \App\Models\SystemSetting::get('preloader',       true),
            'maintenance_mode'=> (bool)   \App\Models\SystemSetting::get('maintenance_mode',false),
            'public_registration' => (bool) \App\Models\SystemSetting::get('public_registration', true),
            // When ON, new accounts are stamped `email_verified_at = now()`
            // at signup and skip the verify-email screen entirely. Useful
            // for dev installs without SMTP and for invite-only deployments
            // where the admin has already vetted operators out of band.
            'auto_verify_email'   => (bool) \App\Models\SystemSetting::get('auto_verify_email', true),
            // Plan assigned to a workspace the moment its owner finishes
            // signup. Empty = fall back to the first active free plan.
            // When the chosen plan is FREE, the workspace gets a trial
            // window of `registration_trial_days` days.
            'registration_default_plan_id' => (string) \App\Models\SystemSetting::get('registration_default_plan_id', ''),
            'registration_trial_days'      => (int)    \App\Models\SystemSetting::get('registration_trial_days', 14),
            // How plans apply across an owner's workspaces:
            //   workspace = each workspace billed separately (default)
            //   account   = owner's paid plan unlocks all their workspaces
            'billing_plan_scope'           => (string) \App\Models\SystemSetting::get('billing_plan_scope', 'workspace'),
            // Brand assets — single favicon shared across themes, one logo
            // per theme so dark mode can have an inverted variant etc.
            'brand_favicon'   => (string) \App\Models\SystemSetting::get('brand.favicon', ''),
            // Platform-fixed message footer (applies to workspaces without
            // remove_branding). Workspaces with that plan flag can override
            // their own from /account → Branding.
            'platform_branding_footer' => (string) \App\Models\SystemSetting::get('platform_branding_footer', ''),
        ];
        foreach (self::BRAND_THEMES as $t) {
            $settings['brand_logo_' . $t['id']] = (string) \App\Models\SystemSetting::get('brand.logo.' . $t['id'], '');
        }
        $languages  = \App\Models\Language::where('is_active', true)->orderBy('sort_order')->orderBy('name')->get();
        $currencies = \App\Models\Currency::where('is_active', true)->orderBy('code')->get();
        $brandThemes = self::BRAND_THEMES;
        // Active plans for the "default signup plan" picker. isFreePlan()
        // drives the (free → trial) hint shown next to each option.
        $signupPlans = \App\Models\Package::where('status', 1)
            ->orderBy('sort_order')->orderBy('pname')->get();
        return view('admin.settings.general', compact('settings', 'languages', 'currencies', 'brandThemes', 'signupPlans'));
    }

    public function settingGeneralUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $rules = [
            'app_name'            => 'required|string|max:80',
            'app_url'             => 'nullable|url|max:191',
            'support_email'       => 'nullable|email|max:160',
            'contact_number'      => 'nullable|string|max:40',
            'from_email'          => 'nullable|email|max:160',
            'default_timezone'    => 'nullable|string|max:64',
            'default_language'    => 'nullable|string|max:12',
            'default_currency'    => 'nullable|string|max:8',
            // UI font family (key from app_font_catalog(); '' = theme default).
            'font_family'         => 'nullable|string|max:40',
            'address'             => 'nullable|string|max:500',
            'map_iframe_url'      => 'nullable|string|max:1000',
            'preloader'           => 'sometimes|boolean',
            'maintenance_mode'    => 'sometimes|boolean',
            'public_registration' => 'sometimes|boolean',
            'auto_verify_email'   => 'sometimes|boolean',
            // Default plan for new signups (plan_id slug) + free-trial length.
            'registration_default_plan_id' => 'nullable|string|max:64|exists:packages,plan_id',
            'registration_trial_days'      => 'nullable|integer|min:0|max:365',
            'billing_plan_scope'           => 'nullable|in:workspace,account',
            // Brand assets — favicon is shared. Per-theme logo files key on
            // the theme id (paper / bright / dark / doodle / future).
            'favicon'             => 'nullable|file|mimes:png,ico,jpg,jpeg,svg,webp|max:512',
            'logos'               => 'nullable|array',
            'logos.*'             => 'nullable|file|mimes:png,jpg,jpeg,svg,webp|max:1024',
            // Platform-fixed outbound message footer — string used when a
            // workspace's plan doesn't grant remove_branding.
            'platform_branding_footer' => 'nullable|string|max:60',
        ];
        $data = $request->validate($rules);

        // Persist scalar fields first (excluding files).
        $scalar = \Illuminate\Support\Arr::except($data, ['favicon', 'logos']);
        foreach ($scalar as $key => $value) {
            $type = match (true) {
                is_bool($value)  => 'bool',
                is_int($value)   => 'int',
                default          => 'string',
            };
            \App\Models\SystemSetting::set($key, $value, $type, 'General settings: ' . $key);
        }

        // Favicon upload — single shared file.
        if ($request->hasFile('favicon')) {
            $path = $request->file('favicon')->store('brand', 'public');
            \App\Models\SystemSetting::set('brand.favicon', $path, 'string', 'Brand favicon (shared across themes)');
        }

        // Per-theme logo uploads. `logos[<theme-id>]` from the form.
        if ($request->hasFile('logos')) {
            foreach ($request->file('logos') as $themeId => $file) {
                $themeId = preg_replace('/[^a-z0-9_-]/i', '', (string) $themeId);
                if ($themeId === '' || !$file) continue;
                $path = $file->store('brand', 'public');
                \App\Models\SystemSetting::set(
                    'brand.logo.' . $themeId,
                    $path,
                    'string',
                    'Logo for theme: ' . $themeId
                );
            }
        }

        \App\Support\Audit::log('admin.settings.general_updated', [
            'layer' => 'platform',
            'meta'  => [
                'fields'     => array_keys($scalar),
                'favicon'    => $request->hasFile('favicon'),
                'logos'      => $request->hasFile('logos') ? array_keys($request->file('logos')) : [],
            ],
        ]);
        // Platform footer might have changed → flush Node's entire
        // settings cache so every workspace picks up the new default.
        if (array_key_exists('platform_branding_footer', $scalar)) {
            \App\Services\NodeCacheBuster::bustAll();
        }
        return redirect()->route('admin.settings.general')->with('status', 'General settings saved.');
    }
    public function settingWaDeskMessage(): View
    {
        // Provider toggles + per-provider creds live here. Admin picks
        // single or multiple methods workspaces are allowed to connect
        // with — workspaces then see only the enabled tabs in the
        // /devices add-device modal.
        $allowed = \App\Models\SystemSetting::get('allowed_send_methods', ['waba', 'baileys', 'twilio']);
        $allowed = is_array($allowed) ? $allowed : ['waba', 'baileys', 'twilio'];

        $settings = [
            'default_send_method'        => (string) \App\Models\SystemSetting::get('default_send_method', 'baileys'),
            'allowed_send_methods'       => $allowed,
            'waba_app_id'                => (string) \App\Models\SystemSetting::get('waba_app_id', ''),
            'waba_app_secret_set'        => \App\Models\SystemSetting::where('key', 'waba_app_secret')->exists(),
            'waba_config_id'             => (string) \App\Models\SystemSetting::get('waba_config_id', ''),
            'waba_coexistence'           => (bool) \App\Models\SystemSetting::get('waba_coexistence', false),
            'waba_webhook_verify_token'  => (string) \App\Models\SystemSetting::get('waba_webhook_verify_token', ''),
            'baileys_server_url'         => (string) \App\Models\SystemSetting::get('baileys_server_url', env('SERVER_URL', '')),
            'twilio_account_sid'         => (string) \App\Models\SystemSetting::get('twilio_account_sid', ''),
            'twilio_auth_token_set'      => \App\Models\SystemSetting::where('key', 'twilio_auth_token')->exists(),
            'twilio_whatsapp_number'     => (string) \App\Models\SystemSetting::get('twilio_whatsapp_number', ''),
            // Shared Node-bridge secret (X-Node-Token). Stored encrypted — only
            // expose whether it's set so the field shows a masked placeholder.
            'node_webhook_token_set'     => node_token() !== '',
            // WABA dispatcher + templates v2 flags the blade renders.
            'waba_dispatch_v2_enabled'   => (bool)   \App\Models\SystemSetting::get('waba_dispatch_v2_enabled', false),
            'waba_graph_api_version'     => (string) \App\Models\SystemSetting::get('waba_graph_api_version', 'v23.0'),
            'waba_connected_count'       => (int)    \DB::table('wa_provider_configs')->where('provider', 'waba')->where('status', 'connected')->count(),
            'waba_templates_v2_enabled'  => (bool)   \App\Models\SystemSetting::get('waba_templates_v2_enabled', false),
            'waba_template_polling_min'  => (int)    \App\Models\SystemSetting::get('waba_template_polling_min', 30),
            'waba_template_lint_strict'  => (bool)   \App\Models\SystemSetting::get('waba_template_lint_strict', true),
            // Sender pacing — WITHOUT these the blade read undefined keys, so the
            // Enable-batches toggle always rendered OFF and the gap fields showed
            // defaults even after a successful save. This is the real cause of
            // "toggle won't save": the save worked, the read-back was missing.
            'msg_gap'                    => (int)    \App\Models\SystemSetting::get('msg_gap', 3),
            'batches_gap'                => (int)    \App\Models\SystemSetting::get('batches_gap', 50),
            'bw_msg_gap'                 => (int)    \App\Models\SystemSetting::get('bw_msg_gap', 5),
            'enable_batches'             => (bool)   \App\Models\SystemSetting::get('enable_batches', false),
        ];
        return view('admin.settings.wadesk-message', compact('settings'));
    }
    public function settingMessage(): View
    {
        $settings = [
            'message_twilio_enabled'   => (bool) \App\Models\SystemSetting::get('message_twilio_enabled', false),
            'message_whatsapp_enabled' => (bool) \App\Models\SystemSetting::get('message_whatsapp_enabled', false),
            'message_facebook_id'      => (string) \App\Models\SystemSetting::get('message_facebook_id', ''),
            'message_has_facebook_token' => \App\Models\SystemSetting::get('message_facebook_token', '') !== '',
            'message_whatsapp_api_version' => (string) \App\Models\SystemSetting::get('message_whatsapp_api_version', 'v23.0'),
            'message_whatsapp_business_id' => (string) \App\Models\SystemSetting::get('message_whatsapp_business_id', ''),
            'message_twilio_sid'       => (string) \App\Models\SystemSetting::get('message_twilio_sid', ''),
            'message_has_twilio_token' => \App\Models\SystemSetting::get('message_twilio_token', '') !== '',
            'message_twilio_sender'    => (string) \App\Models\SystemSetting::get('message_twilio_sender', ''),
            'message_twilio_webhook'   => (string) \App\Models\SystemSetting::get('message_twilio_webhook', url('/webhooks/twilio')),
        ];
        return view('admin.settings.message', compact('settings'));
    }

    public function settingMessageUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate([
            'message_twilio_enabled'       => 'nullable|boolean',
            'message_whatsapp_enabled'     => 'nullable|boolean',
            'message_facebook_id'          => 'nullable|string|max:191',
            'message_facebook_token'       => 'nullable|string|max:1024',
            'message_whatsapp_api_version' => 'nullable|string|max:12',
            'message_whatsapp_business_id' => 'nullable|string|max:191',
            'message_twilio_sid'           => 'nullable|string|max:191',
            'message_twilio_token'         => 'nullable|string|max:191',
            'message_twilio_sender'        => 'nullable|string|max:32',
            'message_twilio_webhook'       => 'nullable|url|max:300',
        ]);

        \App\Models\SystemSetting::set('message_twilio_enabled',   $request->boolean('message_twilio_enabled'), 'bool', 'Message provider: Twilio enabled');
        \App\Models\SystemSetting::set('message_whatsapp_enabled', $request->boolean('message_whatsapp_enabled'), 'bool', 'Message provider: WhatsApp Business API enabled');
        foreach (['message_facebook_id', 'message_whatsapp_api_version', 'message_whatsapp_business_id', 'message_twilio_sid', 'message_twilio_sender', 'message_twilio_webhook'] as $k) {
            \App\Models\SystemSetting::set($k, (string) ($data[$k] ?? ''), 'string', 'Message provider ' . str_replace('message_', '', $k));
        }
        // Secrets: only overwrite when a new value is actually typed (the form
        // shows a masked placeholder, not the stored value).
        foreach (['message_facebook_token', 'message_twilio_token'] as $secret) {
            if ($request->filled($secret)) {
                \App\Models\SystemSetting::set($secret, (string) $request->input($secret), 'string', 'Message provider ' . str_replace('message_', '', $secret));
            }
        }

        \App\Support\Audit::log('settings.message_update', ['meta' => ['fields' => array_keys($data)]]);
        return redirect()->route('admin.settings.message')->with('success', __('Message provider settings saved.'));
    }

    public function settingMail(): View
    {
        // Every field falls back to the legacy config()/.env value so a
        // fresh install (where /admin/settings/mail hasn't been saved
        // yet) still shows whatever the env was configured with.
        $mail = [
            'mailer'      => (string) \App\Models\SystemSetting::get('mail_mailer',      config('mail.default', 'smtp')),
            'host'        => (string) \App\Models\SystemSetting::get('mail_host',        config('mail.mailers.smtp.host', '')),
            'port'        => (string) \App\Models\SystemSetting::get('mail_port',        config('mail.mailers.smtp.port', '587')),
            'username'    => (string) \App\Models\SystemSetting::get('mail_username',    config('mail.mailers.smtp.username', '')),
            'encryption'  => (string) \App\Models\SystemSetting::get('mail_encryption',  config('mail.mailers.smtp.encryption', 'tls')),
            'from_name'   => (string) \App\Models\SystemSetting::get('mail_from_name',   config('mail.from.name', \App\Models\SystemSetting::get('app_name', 'WaDesk'))),
            'from_address'=> (string) \App\Models\SystemSetting::get('mail_from_address',config('mail.from.address', '')),
            // Password is encrypted at rest — show "(set)" placeholder
            // in the form, never the actual value.
            'password_set'=> (bool) \App\Models\SystemSetting::get('mail_password',      ''),
        ];
        return view('admin.settings.mail', compact('mail'));
    }

    public function settingMailUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate([
            'mail_mailer'       => 'nullable|in:smtp,sendmail,log,array,mailgun,ses,postmark,resend',
            'mail_host'         => 'nullable|string|max:200',
            'mail_port'         => 'nullable|integer|min:1|max:65535',
            'mail_username'     => 'nullable|string|max:200',
            'mail_password'     => 'nullable|string|max:500',
            'mail_encryption'   => 'nullable|in:tls,ssl,starttls,',
            'mail_from_name'    => 'nullable|string|max:120',
            'mail_from_address' => 'nullable|email|max:200',
        ]);

        foreach ($data as $key => $value) {
            // Skip password when blank — never overwrite a saved one
            // with empty just because the form didn't submit it.
            if ($key === 'mail_password') {
                if ($value === null || $value === '') continue;
                $value = \Illuminate\Support\Facades\Crypt::encryptString((string) $value);
            }
            \App\Models\SystemSetting::set($key, (string) ($value ?? ''), 'string', str_replace('_', ' ', $key));
        }
        \App\Support\Audit::log('settings.mail_update', ['meta' => ['fields' => array_keys($data)]]);

        // Re-apply config immediately so the redirect target reflects
        // the new values without a second request boot.
        \App\Support\MailConfig::apply();

        return redirect()->route('admin.settings.mail')->with('success', __('Mail settings saved.'));
    }

    /**
     * POST /admin/settings/mail/test — send a one-line test email to
     * the admin's address with the currently-saved SMTP creds. Useful
     * to verify host/port/credentials without leaving the page.
     */
    public function settingMailTest(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate(['to' => 'required|email|max:200']);
        \App\Support\MailConfig::apply();
        try {
            \Illuminate\Support\Facades\Mail::raw(
                __('This is a test email from :app to confirm your SMTP settings are working. Sent at :time.', [
                    'app'  => (string) \App\Models\SystemSetting::get('app_name', config('app.name', 'WaDesk')),
                    'time' => now()->toDateTimeString(),
                ]),
                function ($m) use ($data) {
                    $m->to($data['to'])->subject(__('Mail settings test'));
                }
            );
            \App\Support\Audit::log('settings.mail_test', ['meta' => ['to' => $data['to'], 'ok' => true]]);
            return back()->with('success', __('Test email sent to :to.', ['to' => $data['to']]));
        } catch (\Throwable $e) {
            \App\Support\Audit::log('settings.mail_test', ['meta' => ['to' => $data['to'], 'ok' => false, 'error' => $e->getMessage()]], 'failure');
            return back()->with('error', __('Test email failed: ') . $e->getMessage());
        }
    }
    public function settingIntegration(): View
    {
        // Reuses the SAME keys the dedicated Shopify/Woo pages own, so this
        // master-switch summary stays in sync with them (one source of truth).
        $settings = [
            'shopify_enabled'        => (bool) \App\Models\SystemSetting::get('shopify_enabled', false),
            'woocommerce_enabled'    => (bool) \App\Models\SystemSetting::get('woocommerce_enabled', false),
            'shopify_client_id'      => (string) \App\Models\SystemSetting::get('shopify_client_id', ''),
            'shopify_has_secret'     => \App\Models\SystemSetting::get('shopify_client_secret', '') !== '',
            'woocommerce_instructions' => (string) \App\Models\SystemSetting::get('woocommerce_instructions', ''),
        ];
        return view('admin.settings.integration', compact('settings'));
    }

    public function settingIntegrationUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate([
            'shopify_enabled'          => 'nullable|boolean',
            'woocommerce_enabled'      => 'nullable|boolean',
            'shopify_client_id'        => 'nullable|string|max:191',
            'shopify_client_secret'    => 'nullable|string|max:191',
            'woocommerce_instructions' => 'nullable|string|max:2000',
        ]);

        \App\Models\SystemSetting::set('shopify_enabled',     $request->boolean('shopify_enabled'), 'bool', 'Toggle Shopify OAuth integration');
        \App\Models\SystemSetting::set('woocommerce_enabled', $request->boolean('woocommerce_enabled'), 'bool', 'Toggle WooCommerce integration');
        \App\Models\SystemSetting::set('shopify_client_id',   (string) ($data['shopify_client_id'] ?? ''), 'string', 'Shopify app client ID');
        \App\Models\SystemSetting::set('woocommerce_instructions', (string) ($data['woocommerce_instructions'] ?? ''), 'string', 'WooCommerce connection instructions shown to workspaces');
        // Masked secret: only overwrite when a new value is typed.
        if ($request->filled('shopify_client_secret')) {
            \App\Models\SystemSetting::set('shopify_client_secret', (string) $request->input('shopify_client_secret'), 'string', 'Shopify app client secret');
        }

        \App\Support\Audit::log('settings.integration_update', ['meta' => ['fields' => array_keys($data)]]);
        return redirect()->route('admin.settings.integration')->with('success', __('Integration settings saved.'));
    }

    public function settingShopify(): View
    {
        return view('admin.settings.shopify', [
            'enabled'      => (bool) \App\Models\SystemSetting::get('shopify_enabled', false),
            'clientId'     => (string) \App\Models\SystemSetting::get('shopify_client_id', ''),
            // Never echo the secret back into the form HTML — only expose whether one is set.
            'hasSecret'    => \App\Models\SystemSetting::get('shopify_client_secret', '') !== '',
            'scopes'       => (string) \App\Models\SystemSetting::get('shopify_scopes', \App\Services\Shopify\ShopifyService::DEFAULT_SCOPES),
            'redirectUri'  => (string) (\App\Models\SystemSetting::get('shopify_redirect_uri') ?: url('/shopify/oauth/callback')),
            'integrationsCount' => \App\Models\ShopifyIntegration::count(),
            'eventsCount'       => \App\Models\ShopifyIntegrationEvent::count(),
            'logsCount'         => \App\Models\ShopifyIntegrationLog::count(),
        ]);
    }

    public function settingShopifyUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $request->validate([
            'shopify_enabled'       => 'nullable|in:0,1',
            'shopify_client_id'     => 'nullable|string|max:191',
            'shopify_client_secret' => 'nullable|string|max:191',
            'shopify_scopes'        => 'nullable|string|max:500',
            'shopify_redirect_uri'  => 'nullable|url|max:255',
        ]);

        \App\Models\SystemSetting::set('shopify_enabled',       $request->boolean('shopify_enabled'), 'bool', 'Toggle Shopify OAuth integration');
        \App\Models\SystemSetting::set('shopify_client_id',     (string) $request->input('shopify_client_id', ''), 'string', 'Shopify app client ID');
        // Leave the secret untouched when the field is submitted blank (it's masked in the form).
        if ($request->filled('shopify_client_secret')) {
            \App\Models\SystemSetting::set('shopify_client_secret', (string) $request->input('shopify_client_secret'), 'string', 'Shopify app client secret');
        }
        \App\Models\SystemSetting::set('shopify_scopes',        (string) $request->input('shopify_scopes', \App\Services\Shopify\ShopifyService::DEFAULT_SCOPES), 'string', 'Shopify OAuth scopes');
        \App\Models\SystemSetting::set('shopify_redirect_uri',  (string) $request->input('shopify_redirect_uri', ''), 'string', 'Shopify OAuth redirect URI');

        return back()->with('success', 'Shopify settings saved.');
    }
    public function settingHubspot(): View
    {
        return view('admin.settings.hubspot', [
            'enabled'      => (bool) \App\Models\SystemSetting::get('hubspot_enabled', false),
            'clientId'     => (string) \App\Models\SystemSetting::get('hubspot_client_id', ''),
            // Never echo the secret back into the form HTML — only expose whether one is set.
            'hasSecret'    => \App\Models\SystemSetting::get('hubspot_client_secret', '') !== '',
            'scopes'       => (string) \App\Models\SystemSetting::get('hubspot_scopes', \App\Services\Hubspot\HubspotService::DEFAULT_SCOPES),
            'redirectUri'  => (string) (\App\Models\SystemSetting::get('hubspot_redirect_uri') ?: url('/hubspot/oauth/callback')),
            'integrationsCount' => \App\Models\HubspotIntegration::count(),
            'activeCount'       => \App\Models\HubspotIntegration::where('status', 'active')->count(),
            'logsCount'         => \App\Models\HubspotIntegrationLog::count(),
        ]);
    }

    public function settingHubspotUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $request->validate([
            'hubspot_enabled'       => 'nullable|in:0,1',
            'hubspot_client_id'     => 'nullable|string|max:191',
            'hubspot_client_secret' => 'nullable|string|max:191',
            'hubspot_scopes'        => 'nullable|string|max:500',
            'hubspot_redirect_uri'  => 'nullable|url|max:255',
        ]);

        \App\Models\SystemSetting::set('hubspot_enabled',   $request->boolean('hubspot_enabled'), 'bool', 'Toggle HubSpot OAuth integration');
        \App\Models\SystemSetting::set('hubspot_client_id', (string) $request->input('hubspot_client_id', ''), 'string', 'HubSpot app client ID');
        // Leave the secret untouched when the field is submitted blank (it's masked in the form).
        if ($request->filled('hubspot_client_secret')) {
            \App\Models\SystemSetting::set('hubspot_client_secret', (string) $request->input('hubspot_client_secret'), 'string', 'HubSpot app client secret');
        }
        \App\Models\SystemSetting::set('hubspot_scopes',       (string) $request->input('hubspot_scopes', \App\Services\Hubspot\HubspotService::DEFAULT_SCOPES), 'string', 'HubSpot OAuth scopes');
        \App\Models\SystemSetting::set('hubspot_redirect_uri', (string) $request->input('hubspot_redirect_uri', ''), 'string', 'HubSpot OAuth redirect URI');

        \App\Support\Audit::log('settings.hubspot_update', ['meta' => [
            'enabled'    => $request->boolean('hubspot_enabled'),
            'has_id'     => $request->filled('hubspot_client_id'),
            'set_secret' => $request->filled('hubspot_client_secret'),
        ]]);

        return back()->with('success', 'HubSpot settings saved.');
    }

    // ---- Slack (per-workspace creds; admin holds the platform on/off + setup) ----
    public function settingSlack(): View
    {
        return view('admin.settings.slack', [
            'enabled'           => (bool) \App\Models\SystemSetting::get('slack_enabled', true),
            'integrationsCount' => \App\Models\SlackIntegration::count(),
            'activeCount'       => \App\Models\SlackIntegration::where('status', 'active')->count(),
            'logsCount'         => \App\Models\SlackIntegrationLog::count(),
            'commandUrl'        => url('/webhooks/slack/command'),
        ]);
    }

    public function settingSlackUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $request->validate(['slack_enabled' => 'nullable|in:0,1']);
        \App\Models\SystemSetting::set('slack_enabled', $request->boolean('slack_enabled'), 'bool', 'Toggle Slack integration platform-wide');
        \App\Support\Audit::log('settings.slack_update', ['meta' => ['enabled' => $request->boolean('slack_enabled')]]);
        return back()->with('success', 'Slack settings saved.');
    }

    // ---- Trello (per-workspace creds; admin holds the platform on/off + setup) ----
    public function settingTrello(): View
    {
        return view('admin.settings.trello', [
            'enabled'           => (bool) \App\Models\SystemSetting::get('trello_enabled', true),
            'integrationsCount' => \App\Models\TrelloIntegration::count(),
            'activeCount'       => \App\Models\TrelloIntegration::where('status', 'active')->count(),
            'logsCount'         => \App\Models\TrelloIntegrationLog::count(),
            'callbackUrl'       => url('/webhooks/trello'),
        ]);
    }

    public function settingTrelloUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $request->validate(['trello_enabled' => 'nullable|in:0,1']);
        \App\Models\SystemSetting::set('trello_enabled', $request->boolean('trello_enabled'), 'bool', 'Toggle Trello integration platform-wide');
        \App\Support\Audit::log('settings.trello_update', ['meta' => ['enabled' => $request->boolean('trello_enabled')]]);
        return back()->with('success', 'Trello settings saved.');
    }

    /**
     * Google integration platform config — the ONE place that writes the
     * google_calendar_* SystemSettings that GoogleCalendarService /
     * GoogleApiService read. Without these the whole Google suite
     * (Appointments, Meet, Sheets/Docs/Forms flow nodes) is dead because
     * clientId() resolves to '' and every OAuth start fails.
     */
    public function settingGoogleCalendar(): View
    {
        $gcal = app(\App\Services\GoogleCalendar\GoogleCalendarService::class);

        // KPI: how many workspaces have already completed the per-workspace
        // OAuth (token lives in appointment_settings.google_oauth.access_token).
        $connected = \App\Models\Workspace::query()
            ->whereNotNull('appointment_settings')
            ->get(['appointment_settings'])
            ->filter(fn ($w) => !empty(($w->appointment_settings['google_oauth']['access_token'] ?? null)))
            ->count();

        return view('admin.settings.google-calendar', [
            'enabled'        => (bool) \App\Models\SystemSetting::get('google_calendar_enabled', false),
            'clientId'       => (string) \App\Models\SystemSetting::get('google_calendar_client_id', ''),
            // Never echo the secret back into the form HTML — only expose whether one is set.
            'hasSecret'      => \App\Models\SystemSetting::get('google_calendar_client_secret', '') !== '',
            'scopes'         => $gcal->scopes(),
            'redirectUri'    => $gcal->redirectUri(),
            'defaultScopes'  => \App\Services\GoogleCalendar\GoogleCalendarService::DEFAULT_SCOPES,
            'connectedCount' => $connected,
        ]);
    }

    public function settingGoogleCalendarUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $request->validate([
            'google_calendar_enabled'       => 'nullable|in:0,1',
            'google_calendar_client_id'     => 'nullable|string|max:255',
            'google_calendar_client_secret' => 'nullable|string|max:255',
            'google_calendar_scopes'        => 'nullable|string|max:1000',
            'google_calendar_redirect_uri'  => 'nullable|url|max:255',
        ]);

        \App\Models\SystemSetting::set('google_calendar_enabled',   $request->boolean('google_calendar_enabled'), 'bool', 'Toggle Google integration (Calendar/Meet/Sheets/Docs/Forms)');
        \App\Models\SystemSetting::set('google_calendar_client_id', (string) $request->input('google_calendar_client_id', ''), 'string', 'Google OAuth client ID');
        // Leave the secret untouched when the field is submitted blank (it's masked in the form). Stored encrypted via ENCRYPTED_KEYS.
        if ($request->filled('google_calendar_client_secret')) {
            \App\Models\SystemSetting::set('google_calendar_client_secret', (string) $request->input('google_calendar_client_secret'), 'string', 'Google OAuth client secret');
        }
        \App\Models\SystemSetting::set('google_calendar_scopes',       (string) ($request->input('google_calendar_scopes') ?: \App\Services\GoogleCalendar\GoogleCalendarService::DEFAULT_SCOPES), 'string', 'Google OAuth scopes');
        \App\Models\SystemSetting::set('google_calendar_redirect_uri', (string) $request->input('google_calendar_redirect_uri', ''), 'string', 'Google OAuth redirect URI');

        \App\Support\Audit::log('settings.google_calendar_update', ['meta' => [
            'enabled'    => $request->boolean('google_calendar_enabled'),
            'has_id'     => $request->filled('google_calendar_client_id'),
            'set_secret' => $request->filled('google_calendar_client_secret'),
        ]]);

        return back()->with('success', 'Google integration settings saved.');
    }

    public function settingSocialLogin(): View
    {
        $g = fn ($k, $d = '') => \App\Models\SystemSetting::get($k, $d);
        return view('admin.settings.social-login', [
            'googleEnabled'   => (bool) $g('social_google_enabled', false),
            'googleClientId'  => (string) $g('social_google_client_id', ''),
            'googleHasSecret' => $g('social_google_client_secret', '') !== '',
            'fbEnabled'       => (bool) $g('social_facebook_enabled', false),
            'fbClientId'      => (string) $g('social_facebook_client_id', ''),
            'fbHasSecret'     => $g('social_facebook_client_secret', '') !== '',
            'reEnabled'       => (bool) $g('recaptcha_enabled', false),
            'reVersion'       => (string) ($g('recaptcha_version', 'v2') ?: 'v2'),
            'reSiteKey'       => (string) $g('recaptcha_site_key', ''),
            'reHasSecret'     => $g('recaptcha_secret', '') !== '',
            'reThreshold'     => (string) ($g('recaptcha_v3_threshold', '0.5') ?: '0.5'),
            'googleRedirect'  => url('/auth/google/callback'),
            'fbRedirect'      => url('/auth/facebook/callback'),
        ]);
    }

    public function settingSocialLoginUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $request->validate([
            'social_google_enabled'        => 'nullable|in:0,1',
            'social_google_client_id'      => 'nullable|string|max:255',
            'social_google_client_secret'  => 'nullable|string|max:255',
            'social_facebook_enabled'      => 'nullable|in:0,1',
            'social_facebook_client_id'    => 'nullable|string|max:255',
            'social_facebook_client_secret'=> 'nullable|string|max:255',
            'recaptcha_enabled'            => 'nullable|in:0,1',
            'recaptcha_version'            => 'nullable|in:v2,v3',
            'recaptcha_site_key'           => 'nullable|string|max:255',
            'recaptcha_secret'             => 'nullable|string|max:255',
            'recaptcha_v3_threshold'       => 'nullable|numeric|min:0|max:1',
        ]);

        $S = fn ($k, $v, $t = 'string', $d = '') => \App\Models\SystemSetting::set($k, $v, $t, $d);
        $S('social_google_enabled',   $request->boolean('social_google_enabled'), 'bool', 'Enable Google sign-in');
        $S('social_google_client_id', (string) $request->input('social_google_client_id', ''), 'string', 'Google OAuth client ID');
        if ($request->filled('social_google_client_secret')) $S('social_google_client_secret', (string) $request->input('social_google_client_secret'), 'string', 'Google OAuth client secret');

        $S('social_facebook_enabled',   $request->boolean('social_facebook_enabled'), 'bool', 'Enable Facebook sign-in');
        $S('social_facebook_client_id', (string) $request->input('social_facebook_client_id', ''), 'string', 'Facebook app ID');
        if ($request->filled('social_facebook_client_secret')) $S('social_facebook_client_secret', (string) $request->input('social_facebook_client_secret'), 'string', 'Facebook app secret');

        $S('recaptcha_enabled',      $request->boolean('recaptcha_enabled'), 'bool', 'Enable Google reCAPTCHA');
        $S('recaptcha_version',      $request->input('recaptcha_version', 'v2') === 'v3' ? 'v3' : 'v2', 'string', 'reCAPTCHA version');
        $S('recaptcha_site_key',     (string) $request->input('recaptcha_site_key', ''), 'string', 'reCAPTCHA site key');
        if ($request->filled('recaptcha_secret')) $S('recaptcha_secret', (string) $request->input('recaptcha_secret'), 'string', 'reCAPTCHA secret key');
        $S('recaptcha_v3_threshold', (string) ($request->input('recaptcha_v3_threshold') ?: '0.5'), 'string', 'reCAPTCHA v3 score threshold');

        \App\Support\Audit::log('settings.social_login_update', ['meta' => [
            'google'   => $request->boolean('social_google_enabled'),
            'facebook' => $request->boolean('social_facebook_enabled'),
            'recaptcha'=> $request->boolean('recaptcha_enabled'),
        ]]);

        return back()->with('success', 'Social login & captcha settings saved.');
    }
    public function settingWoocommerce(): View
    {
        return view('admin.settings.woocommerce', [
            'enabled'           => (bool) \App\Models\SystemSetting::get('woocommerce_enabled', false),
            'integrationsCount' => \App\Models\WoocommerceIntegration::count(),
            'eventsCount'       => \App\Models\WoocommerceIntegrationEvent::count(),
            'logsCount'         => \App\Models\WoocommerceIntegrationLog::count(),
        ]);
    }

    public function settingWoocommerceUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $request->validate(['woocommerce_enabled' => 'nullable|in:0,1']);
        \App\Models\SystemSetting::set('woocommerce_enabled', $request->boolean('woocommerce_enabled'), 'bool', 'Toggle WooCommerce integration');
        return back()->with('success', 'WooCommerce settings saved.');
    }
    /**
     * GET /admin/settings/export — stream every system_settings row as a
     * JSON file the admin can back up. Secrets are redacted to a fixed
     * placeholder so the export is safe to attach to a support ticket.
     * Secret keys: anything ending in _secret, _password, _token, _key.
     */
    public function settingsExport(): \Symfony\Component\HttpFoundation\StreamedResponse
    {
        $rows = \App\Models\SystemSetting::query()->orderBy('key')->get(['key', 'value', 'type']);
        $brand = (string) \App\Models\SystemSetting::get('app_name', config('app.name', 'WaDesk'));
        $filename = \Illuminate\Support\Str::slug($brand) . '-settings-' . now()->format('Ymd-Hi') . '.json';

        $isSecret = function (string $key): bool {
            $key = strtolower($key);
            foreach (['_secret', '_password', '_token', '_apikey', '_api_key'] as $suffix) {
                if (str_ends_with($key, $suffix)) return true;
            }
            // Cover provider keys explicitly so we don't leak BYOK.
            foreach (['posthog_key', 'mixpanel_token', 'mail_password',
                      'shopify_client_secret', 'catalog_meta_app_secret',
                      'twilio_auth', 'razorpay_key_secret', 'stripe_secret_key'] as $exact) {
                if ($key === $exact) return true;
            }
            return false;
        };

        $payload = [
            'app'        => $brand,
            'exported_at'=> now()->toIso8601String(),
            'note'       => 'Secret values are redacted as "[REDACTED]" — this export is safe to share with support.',
            'settings'   => $rows->map(function ($r) use ($isSecret) {
                $val = $r->value;
                if ($isSecret((string) $r->key) && $val !== null && $val !== '') {
                    $val = '[REDACTED]';
                }
                return ['key' => $r->key, 'value' => $val, 'type' => $r->type];
            })->all(),
        ];

        \App\Support\Audit::log('settings.export', ['meta' => ['rows' => count($payload['settings'])]]);

        return response()->streamDownload(function () use ($payload) {
            echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        }, $filename, ['Content-Type' => 'application/json; charset=UTF-8']);
    }

    public function settingCatalog(): View
    {
        // Platform-level catalog knobs only. Per-merchant Catalog ID +
        // WABA ID + Phone Number ID live on each workspace's /catalog
        // page — Meta requires the catalog be connected to the
        // merchant's own Commerce Account, so there's no single
        // platform-wide catalog to configure.
        $catalog = [
            'enabled'         => (bool)   \App\Models\SystemSetting::get('catalog_enabled', true),
            'graph_api_version' => (string) \App\Models\SystemSetting::get('catalog_graph_api_version', 'v23.0'),
            'meta_app_id'     => (string) \App\Models\SystemSetting::get('catalog_meta_app_id', ''),
            'meta_app_secret' => (string) \App\Models\SystemSetting::get('catalog_meta_app_secret', ''),
            'default_currency'=> (string) \App\Models\SystemSetting::get('catalog_default_currency', 'USD'),
            'commerce_enabled'=> (bool)   \App\Models\SystemSetting::get('catalog_commerce_enabled', false),
        ];
        $stats = [
            'connected' => 0,
            'products'  => 0,
            'sends'     => 0,
        ];
        try {
            $stats['connected'] = (int) \App\Models\WaCatalog::query()->whereNotNull('catalog_id')->count();
        } catch (\Throwable $e) {}
        try {
            $stats['products']  = (int) \DB::table('wa_catalog_products')->count();
        } catch (\Throwable $e) {}
        try {
            $stats['sends']     = (int) \DB::table('wa_catalog_send_logs')->count();
        } catch (\Throwable $e) {}
        return view('admin.settings.catalog', compact('catalog', 'stats'));
    }

    public function settingCatalogUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate([
            'catalog_enabled'           => 'sometimes|boolean',
            'catalog_commerce_enabled'  => 'sometimes|boolean',
            'catalog_graph_api_version' => 'nullable|regex:/^v\d{1,2}\.\d{1,2}$/',
            'catalog_meta_app_id'       => 'nullable|string|max:60',
            'catalog_meta_app_secret'   => 'nullable|string|max:120',
            'catalog_default_currency'  => 'nullable|string|size:3',
        ]);

        // Encrypt the app secret at rest; blank submission keeps the
        // saved value (mirrors the mail settings pattern).
        if (! empty($data['catalog_meta_app_secret'])) {
            $data['catalog_meta_app_secret'] = \Illuminate\Support\Facades\Crypt::encryptString($data['catalog_meta_app_secret']);
        } else {
            unset($data['catalog_meta_app_secret']);
        }

        $boolKeys = ['catalog_enabled', 'catalog_commerce_enabled'];
        $this->persistSettingsGroup($request, $data, $boolKeys, 'settings.catalog_update');

        return redirect()->route('admin.settings.catalog')->with('success', __('Catalog settings saved.'));
    }
    public function settingPwa(): View
    {
        $brand = (string) \App\Models\SystemSetting::get('app_name', config('app.name', 'WaDesk'));
        $pwa = [
            'enabled'             => (bool)   \App\Models\SystemSetting::get('pwa_enabled', false),
            'install_prompt'      => (bool)   \App\Models\SystemSetting::get('pwa_install_prompt', true),
            'offline_enabled'     => (bool)   \App\Models\SystemSetting::get('pwa_offline_enabled', true),
            'app_name'            => (string) \App\Models\SystemSetting::get('pwa_app_name', $brand),
            'short_name'          => (string) \App\Models\SystemSetting::get('pwa_short_name', $brand),
            'description'         => (string) \App\Models\SystemSetting::get('pwa_description', ''),
            'start_url'           => (string) \App\Models\SystemSetting::get('pwa_start_url', '/'),
            'scope'               => (string) \App\Models\SystemSetting::get('pwa_scope', '/'),
            'display'             => (string) \App\Models\SystemSetting::get('pwa_display', 'standalone'),
            'orientation'         => (string) \App\Models\SystemSetting::get('pwa_orientation', 'portrait'),
            'theme_color'         => (string) \App\Models\SystemSetting::get('pwa_theme_color', '#075E54'),
            'background_color'    => (string) \App\Models\SystemSetting::get('pwa_background_color', '#FBFAF6'),
            'icon_192'            => (string) \App\Models\SystemSetting::get('pwa_icon_192', ''),
            'icon_512'            => (string) \App\Models\SystemSetting::get('pwa_icon_512', ''),
            'splash'              => (string) \App\Models\SystemSetting::get('pwa_splash', ''),
        ];
        return view('admin.settings.pwa', compact('pwa', 'brand'));
    }

    public function settingPwaUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate([
            'pwa_enabled'             => 'sometimes|boolean',
            'pwa_install_prompt'      => 'sometimes|boolean',
            'pwa_offline_enabled'     => 'sometimes|boolean',
            'pwa_app_name'            => 'nullable|string|max:60',
            'pwa_short_name'          => 'nullable|string|max:12',
            'pwa_description'         => 'nullable|string|max:300',
            'pwa_start_url'           => 'nullable|string|max:200',
            'pwa_scope'               => 'nullable|string|max:200',
            'pwa_display'             => 'nullable|in:standalone,fullscreen,minimal-ui,browser',
            'pwa_orientation'         => 'nullable|in:any,portrait,landscape,natural,portrait-primary,landscape-primary',
            'pwa_theme_color'         => 'nullable|regex:/^#[0-9A-Fa-f]{3,8}$/',
            'pwa_background_color'    => 'nullable|regex:/^#[0-9A-Fa-f]{3,8}$/',
            // Icons are uploaded files now (replacing the old URL fields). The
            // stored public URL is saved back into the pwa_icon_* settings.
            'pwa_icon_192_file'       => 'nullable|image|mimes:png,webp|max:1024',
            'pwa_icon_512_file'       => 'nullable|image|mimes:png,webp|max:2048',
        ]);

        // Strip the upload fields — they are not settings themselves.
        unset($data['pwa_icon_192_file'], $data['pwa_icon_512_file']);

        // Store any newly uploaded icon and point its setting at the public
        // URL. If no new file was sent, the existing value is left untouched
        // (the key simply isn't included in $data).
        foreach (['pwa_icon_192' => 'pwa_icon_192_file', 'pwa_icon_512' => 'pwa_icon_512_file'] as $settingKey => $fileField) {
            if ($request->hasFile($fileField)) {
                $path = $request->file($fileField)->store('pwa', 'public');
                $data[$settingKey] = \Illuminate\Support\Facades\Storage::disk('public')->url($path);
            }
        }

        $boolKeys = ['pwa_enabled','pwa_install_prompt','pwa_offline_enabled'];
        $this->persistSettingsGroup($request, $data, $boolKeys, 'settings.pwa_update');
        return redirect()->route('admin.settings.pwa')->with('success', __('PWA settings saved.'));
    }

    public function settingPrivacy(): View
    {
        $privacy = [
            'cookie_banner_enabled' => (bool)   \App\Models\SystemSetting::get('privacy_cookie_banner_enabled', true),
            'cookie_banner_style'   => (string) \App\Models\SystemSetting::get('privacy_cookie_banner_style', 'bottom-bar'),
            'cookie_message'        => (string) \App\Models\SystemSetting::get('privacy_cookie_message',
                'We use cookies to enhance your browsing experience, serve personalized ads or content, and analyze our traffic. By clicking "Accept All", you consent to our use of cookies.'),
            'privacy_policy_url'    => (string) \App\Models\SystemSetting::get('privacy_policy_url', ''),
            'terms_url'             => (string) \App\Models\SystemSetting::get('privacy_terms_url', ''),
            'cookies_policy_url'    => (string) \App\Models\SystemSetting::get('privacy_cookies_policy_url', ''),
            'gdpr_compliance'       => (bool)   \App\Models\SystemSetting::get('privacy_gdpr_compliance', true),
            'ccpa_compliance'       => (bool)   \App\Models\SystemSetting::get('privacy_ccpa_compliance', true),
            'ada_skip_link'         => (bool)   \App\Models\SystemSetting::get('privacy_ada_skip_link', true),
            'ada_high_contrast'     => (bool)   \App\Models\SystemSetting::get('privacy_ada_high_contrast', false),
            'ada_large_text'        => (bool)   \App\Models\SystemSetting::get('privacy_ada_large_text', false),
            'ada_reduced_motion'    => (bool)   \App\Models\SystemSetting::get('privacy_ada_reduced_motion', false),
            'dnt_respect'           => (bool)   \App\Models\SystemSetting::get('privacy_dnt_respect', true),
        ];
        return view('admin.settings.privacy', compact('privacy'));
    }

    public function settingPrivacyUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate([
            'privacy_cookie_banner_enabled' => 'sometimes|boolean',
            'privacy_cookie_banner_style'   => 'nullable|in:modal,bottom-bar,top-bar',
            'privacy_cookie_message'        => 'nullable|string|max:1000',
            'privacy_policy_url'            => 'nullable|url|max:300',
            'privacy_terms_url'             => 'nullable|url|max:300',
            'privacy_cookies_policy_url'    => 'nullable|url|max:300',
            'privacy_gdpr_compliance'       => 'sometimes|boolean',
            'privacy_ccpa_compliance'       => 'sometimes|boolean',
            'privacy_ada_skip_link'         => 'sometimes|boolean',
            'privacy_ada_high_contrast'     => 'sometimes|boolean',
            'privacy_ada_large_text'        => 'sometimes|boolean',
            'privacy_ada_reduced_motion'    => 'sometimes|boolean',
            'privacy_dnt_respect'           => 'sometimes|boolean',
        ]);
        $boolKeys = ['privacy_cookie_banner_enabled','privacy_gdpr_compliance','privacy_ccpa_compliance',
            'privacy_ada_skip_link','privacy_ada_high_contrast','privacy_ada_large_text',
            'privacy_ada_reduced_motion','privacy_dnt_respect'];
        $this->persistSettingsGroup($request, $data, $boolKeys, 'settings.privacy_update');
        return redirect()->route('admin.settings.privacy')->with('success', __('Privacy, GDPR & ADA settings saved.'));
    }

    public function settingAnalytics(): View
    {
        $analytics = [
            'google_analytics_id'   => (string) \App\Models\SystemSetting::get('analytics_google_ga4', ''),
            'google_tag_manager_id' => (string) \App\Models\SystemSetting::get('analytics_google_gtm', ''),
            'meta_pixel_id'         => (string) \App\Models\SystemSetting::get('analytics_meta_pixel', ''),
            'microsoft_clarity_id'  => (string) \App\Models\SystemSetting::get('analytics_microsoft_clarity', ''),
            'plausible_domain'      => (string) \App\Models\SystemSetting::get('analytics_plausible_domain', ''),
            'posthog_api_key'       => (string) \App\Models\SystemSetting::get('analytics_posthog_key', ''),
            'posthog_host'          => (string) \App\Models\SystemSetting::get('analytics_posthog_host', 'https://app.posthog.com'),
            'hotjar_site_id'        => (string) \App\Models\SystemSetting::get('analytics_hotjar_site_id', ''),
            'tiktok_pixel_id'       => (string) \App\Models\SystemSetting::get('analytics_tiktok_pixel', ''),
            'linkedin_partner_id'   => (string) \App\Models\SystemSetting::get('analytics_linkedin_partner', ''),
            'twitter_pixel_id'      => (string) \App\Models\SystemSetting::get('analytics_twitter_pixel', ''),
            'mixpanel_token'        => (string) \App\Models\SystemSetting::get('analytics_mixpanel_token', ''),
        ];
        return view('admin.settings.analytics', compact('analytics'));
    }

    public function settingAnalyticsUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate([
            'analytics_google_ga4'        => 'nullable|string|max:50',
            'analytics_google_gtm'        => 'nullable|string|max:50',
            'analytics_meta_pixel'        => 'nullable|string|max:50',
            'analytics_microsoft_clarity' => 'nullable|string|max:50',
            'analytics_plausible_domain'  => 'nullable|string|max:120',
            'analytics_posthog_key'       => 'nullable|string|max:120',
            'analytics_posthog_host'      => 'nullable|url|max:200',
            'analytics_hotjar_site_id'    => 'nullable|string|max:30',
            'analytics_tiktok_pixel'      => 'nullable|string|max:50',
            'analytics_linkedin_partner'  => 'nullable|string|max:50',
            'analytics_twitter_pixel'     => 'nullable|string|max:50',
            'analytics_mixpanel_token'    => 'nullable|string|max:120',
        ]);
        $this->persistSettingsGroup($request, $data, [], 'settings.analytics_update');
        return redirect()->route('admin.settings.analytics')->with('success', __('Analytics integrations saved.'));
    }

    /**
     * Shared persistence helper for the three settings update methods.
     * Bool keys absent from $data (toggle off + browser drops the field)
     * are explicitly written as false so the persisted state mirrors the
     * form state exactly.
     */
    private function persistSettingsGroup(\Illuminate\Http\Request $request, array $data, array $boolKeys, string $auditAction): void
    {
        foreach ($data as $key => $value) {
            if (in_array($key, $boolKeys, true)) {
                \App\Models\SystemSetting::set($key, (bool) $request->boolean($key), 'bool', str_replace('_', ' ', $key));
            } else {
                \App\Models\SystemSetting::set($key, (string) ($value ?? ''), 'string', str_replace('_', ' ', $key));
            }
        }
        foreach ($boolKeys as $b) {
            if (! array_key_exists($b, $data)) {
                \App\Models\SystemSetting::set($b, false, 'bool', str_replace('_', ' ', $b));
            }
        }
        \App\Support\Audit::log($auditAction, ['meta' => ['fields' => array_keys($data)]]);
    }

    /**
     * GET /manifest.json — built from system_settings so a fresh PWA
     * install picks up whatever the admin most recently saved.
     */
    public function pwaManifest(): \Illuminate\Http\JsonResponse
    {
        $brand = (string) \App\Models\SystemSetting::get('app_name', config('app.name', 'WaDesk'));
        $icons = [];
        $i192 = (string) \App\Models\SystemSetting::get('pwa_icon_192', '');
        $i512 = (string) \App\Models\SystemSetting::get('pwa_icon_512', '');
        if ($i192) $icons[] = ['src' => $i192, 'sizes' => '192x192', 'type' => 'image/png', 'purpose' => 'any maskable'];
        if ($i512) $icons[] = ['src' => $i512, 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'any maskable'];

        return response()->json([
            'name'             => (string) \App\Models\SystemSetting::get('pwa_app_name', $brand),
            'short_name'       => (string) \App\Models\SystemSetting::get('pwa_short_name', $brand),
            'description'      => (string) \App\Models\SystemSetting::get('pwa_description', ''),
            'start_url'        => (string) \App\Models\SystemSetting::get('pwa_start_url', '/'),
            'scope'            => (string) \App\Models\SystemSetting::get('pwa_scope', '/'),
            'display'          => (string) \App\Models\SystemSetting::get('pwa_display', 'standalone'),
            'orientation'      => (string) \App\Models\SystemSetting::get('pwa_orientation', 'portrait'),
            'theme_color'      => (string) \App\Models\SystemSetting::get('pwa_theme_color', '#075E54'),
            'background_color' => (string) \App\Models\SystemSetting::get('pwa_background_color', '#FBFAF6'),
            'icons'            => $icons,
        ], 200, ['Content-Type' => 'application/manifest+json']);
    }
    public function settingSeo(): View
    {
        // Every field pulls its current value from system_settings via
        // the Seo helper. A fresh install shows sensible defaults that
        // still describe what each field does, so the preview card
        // doesn't render as an empty rectangle on first load.
        $seo = [
            'meta_title'          => (string) \App\Support\Seo::get('meta_title', ''),
            'meta_description'    => (string) \App\Support\Seo::get('meta_description', ''),
            'meta_keywords'       => (string) \App\Support\Seo::get('meta_keywords', ''),
            'robots'              => (string) \App\Support\Seo::get('robots', 'index, follow'),
            'canonical'           => (string) \App\Support\Seo::get('canonical', ''),
            'author'              => (string) \App\Support\Seo::get('author', ''),
            'og_title'            => (string) \App\Support\Seo::get('og_title', ''),
            'og_description'      => (string) \App\Support\Seo::get('og_description', ''),
            'og_image'            => (string) \App\Support\Seo::get('og_image', ''),
            'og_type'             => (string) \App\Support\Seo::get('og_type', 'website'),
            'og_url'              => (string) \App\Support\Seo::get('og_url', ''),
            'twitter_card'        => (string) \App\Support\Seo::get('twitter_card', 'summary_large_image'),
            'twitter_site'        => (string) \App\Support\Seo::get('twitter_site', ''),
            'twitter_creator'     => (string) \App\Support\Seo::get('twitter_creator', ''),
            'google_verification' => (string) \App\Support\Seo::get('google_verification', ''),
            'bing_verification'   => (string) \App\Support\Seo::get('bing_verification', ''),
        ];
        $brandName = (string) \App\Models\SystemSetting::get('app_name', config('app.name', 'WaDesk'));
        return view('admin.settings.seo', compact('seo', 'brandName'));
    }

    public function settingSeoUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate([
            'meta_title'          => 'nullable|string|max:160',
            'meta_description'    => 'nullable|string|max:320',
            'meta_keywords'       => 'nullable|string|max:500',
            'robots'              => 'nullable|string|max:120',
            'canonical'           => 'nullable|url|max:300',
            'author'              => 'nullable|string|max:120',
            'og_title'            => 'nullable|string|max:160',
            'og_description'      => 'nullable|string|max:320',
            'og_image'            => 'nullable|url|max:500',
            'og_type'             => 'nullable|in:website,article,product,profile',
            'og_url'              => 'nullable|url|max:300',
            'twitter_card'        => 'nullable|in:summary,summary_large_image,app,player',
            'twitter_site'        => 'nullable|string|max:60',
            'twitter_creator'     => 'nullable|string|max:60',
            'google_verification' => 'nullable|string|max:200',
            'bing_verification'   => 'nullable|string|max:200',
        ]);

        foreach ($data as $key => $value) {
            \App\Models\SystemSetting::set('seo_' . $key, (string) ($value ?? ''), 'string', 'SEO ' . str_replace('_', ' ', $key));
        }
        \App\Support\Audit::log('settings.seo_update', ['meta' => ['fields' => array_keys($data)]]);
        return redirect()->route('admin.settings.seo')->with('success', __('SEO settings saved.'));
    }
    public function settingFooter(): View
    {
        $settings = [
            'footer_title'       => (string) \App\Models\SystemSetting::get('footer_title', ''),
            'footer_copyright'   => (string) \App\Models\SystemSetting::get('footer_copyright', ''),
            'footer_description' => (string) \App\Models\SystemSetting::get('footer_description', ''),
            // Social URLs share the site.* keys site_info()/the public footer read.
            'social_facebook'    => (string) site_info('social_facebook', ''),
            'social_linkedin'    => (string) site_info('social_linkedin', ''),
            'social_instagram'   => (string) site_info('social_instagram', ''),
            'social_x'           => (string) site_info('social_x', ''),
        ];
        return view('admin.settings.footer', compact('settings'));
    }

    public function settingFooterUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate([
            'footer_title'       => 'nullable|string|max:120',
            'footer_copyright'   => 'nullable|string|max:300',
            'footer_description' => 'nullable|string|max:500',
            'social_facebook'    => 'nullable|url|max:300',
            'social_linkedin'    => 'nullable|url|max:300',
            'social_instagram'   => 'nullable|url|max:300',
            'social_x'           => 'nullable|url|max:300',
        ]);

        // Footer text → dedicated SystemSetting keys (read by the public footer).
        foreach (['footer_title', 'footer_copyright', 'footer_description'] as $k) {
            \App\Models\SystemSetting::set($k, (string) ($data[$k] ?? ''), 'string', 'Footer ' . str_replace('footer_', '', $k));
        }
        // Social URLs → site.* keys: the SAME keys site_info() + the public footer
        // + the Site Settings page already read, so one edit updates everywhere.
        foreach (['facebook', 'linkedin', 'instagram', 'x'] as $net) {
            \App\Models\SystemSetting::set('site.social_' . $net, (string) ($data['social_' . $net] ?? ''), 'string', 'Footer social ' . $net);
        }

        \App\Support\Audit::log('settings.footer_update', ['meta' => ['fields' => array_keys($data)]]);
        return redirect()->route('admin.settings.footer')->with('success', __('Footer settings saved.'));
    }

    public function settingCustom(): View
    {
        $settings = [
            'custom_css'          => (string) \App\Models\SystemSetting::get('custom_css', ''),
            'custom_js'           => (string) \App\Models\SystemSetting::get('custom_js', ''),
            'custom_code_enabled' => (bool) \App\Models\SystemSetting::get('custom_code_enabled', false),
        ];
        return view('admin.settings.custom', compact('settings'));
    }

    public function settingCustomUpdate(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
    {
        $data = $request->validate([
            'custom_css'          => 'nullable|string|max:50000',
            'custom_js'           => 'nullable|string|max:50000',
            'custom_code_enabled' => 'nullable|boolean',
        ]);
        \App\Models\SystemSetting::set('custom_css', (string) ($data['custom_css'] ?? ''), 'string', 'Custom CSS injected into public pages');
        \App\Models\SystemSetting::set('custom_js', (string) ($data['custom_js'] ?? ''), 'string', 'Custom JS injected into public pages');
        \App\Models\SystemSetting::set('custom_code_enabled', $request->boolean('custom_code_enabled'), 'bool', 'Inject custom CSS/JS into public pages');
        \App\Support\Audit::log('settings.custom_update', ['meta' => ['css_len' => strlen((string) ($data['custom_css'] ?? '')), 'js_len' => strlen((string) ($data['custom_js'] ?? ''))]]);
        return redirect()->route('admin.settings.custom')->with('success', __('Custom code saved.'));
    }
}
