<?php

namespace App\Providers;

use App\Events\Conversations\HumanRequestedEvent;
use App\Events\Leads\LeadCapturedEvent;
use App\Listeners\Leads\PushLeadToWordPress;
use App\Listeners\LiveChat\LiveChatNotifier;
use App\Listeners\Queue\RecordJobRun;
use App\Models\Workspace;
use App\Services\Billing\PayPalClient;
use App\Services\Billing\RazorpayClient;
use App\Services\Changelog\ChangelogStore;
use App\Services\Crawl\BrowserlessClient;
use App\Services\Crawl\CloudflareBrowserClient;
use App\Services\Crawl\Contracts\Crawler;
use App\Services\Crawl\PlainHttpCrawler;
use App\Services\I18n\LocaleResolver;
use App\Services\Integrations\Google\GoogleClient;
use App\Services\Integrations\Notion\NotionClient;
use App\Services\Llm\Contracts\OpenAiClient;
use App\Services\Llm\Fakes\FakeOpenAi;
use App\Services\Llm\OpenAiHttpClient;
use App\Services\Llm\WorkersAiClient;
use App\Services\Parsers\ParserRegistry;
use App\Services\Rag\CloudflareReranker;
use App\Services\Rag\Contracts\Reranker;
use App\Services\Rag\Fakes\FakeReranker;
use App\Services\Tools\ToolRegistry;
use App\Services\Vector\Contracts\QdrantClient;
use App\Services\Vector\Fakes\FakeQdrant;
use App\Services\Vector\QdrantHttpClient;
use App\Services\Vector\VectorizeClient;
use App\Services\Vertical\VerticalPresetRegistry;
use App\Services\Widget\WidgetJwt;
use App\Support\AppBranding;
use App\Support\CurrentWorkspace;
use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Cashier;
use Stripe\StripeClient;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->singleton(CurrentWorkspace::class);

        // File-backed changelog store. Reads/writes
        // storage/app/private/changelog-entries.json + auto-seeds
        // from database/changelog-entries/v*.md on first read. The
        // bootstrap dir is config-driven so tests can disable
        // seeding by setting `changelog.bootstrap_dir` to null in
        // their setup — it survives the HTTP boundary that a
        // container `instance()` rebinding can't.
        $this->app->scoped(ChangelogStore::class, function () {
            $configured = config('changelog.bootstrap_dir', '__default__');
            $bootstrapDir = $configured === '__default__'
                ? base_path('database/changelog-entries')
                : $configured;

            return new ChangelogStore(
                disk: 'local',
                bootstrapDir: $bootstrapDir,
            );
        });

        // Single Stripe SDK instance per request — Cashier uses its own
        // internal client too, but we lean on this one for product/price
        // provisioning where we want explicit control.
        // The SDK rejects an empty api_key at construction, so when Stripe
        // isn't configured yet we hand it a harmless placeholder; any real
        // network call would 401 long before we reach this client.
        $this->app->singleton(StripeClient::class, function () {
            $secret = (string) (config('cashier.secret') ?? env('STRIPE_SECRET', ''));

            return new StripeClient([
                'api_key' => $secret !== '' ? $secret : 'sk_unconfigured_placeholder',
                'stripe_version' => '2024-12-18.acacia',
            ]);
        });

        // PayPal + Razorpay clients are bound as transient (`bind`, not
        // `singleton`) so a credentials rotation in the admin UI takes
        // effect on the next resolve without needing an Octane reload.
        $this->app->bind(PayPalClient::class, fn () => PayPalClient::fromConfig());
        $this->app->bind(RazorpayClient::class, fn () => RazorpayClient::fromConfig());

        $this->app->singleton(OpenAiClient::class, function () {
            if (app()->runningUnitTests()) {
                return new FakeOpenAi;
            }

            $provider = (string) config('services.llm.provider', env('LLM_PROVIDER', ''));

            // Cloudflare Workers AI (preferred). Read from config first so
            // AppSettingsOverrideServiceProvider's DB-backed values take
            // effect; fall through to env() for fresh installs.
            $cfAccount = (string) (config('services.cloudflare.account_id') ?: env('CLOUDFLARE_ACCOUNT_ID', ''));
            $cfToken = (string) (config('services.cloudflare.api_token') ?: env('CLOUDFLARE_API_TOKEN', ''));
            if ($provider === 'cloudflare' || ($provider === '' && $cfAccount !== '' && $cfToken !== '')) {
                return new WorkersAiClient(
                    accountId: $cfAccount,
                    apiToken: $cfToken,
                    chatModel: (string) (config('services.cloudflare.chat_model') ?: env('CLOUDFLARE_CHAT_MODEL', '@cf/meta/llama-3.3-70b-instruct-fp8-fast')),
                    embedModel: (string) (config('services.cloudflare.embed_model') ?: env('CLOUDFLARE_EMBED_MODEL', '@cf/baai/bge-base-en-v1.5')),
                    aiGatewayUrl: env('CLOUDFLARE_AI_GATEWAY_URL') ?: null,
                );
            }

            // OpenRouter (free models available — Llama 3.3 70B free,
            // Mistral 7B free, etc). OpenAI-compatible REST surface so
            // we can reuse OpenAiHttpClient by pointing baseUri at
            // OpenRouter and adding their ranking headers. Embeddings
            // aren't on the free tier — IndexDocumentJob falls back to
            // OPENAI_API_KEY when set.
            //
            // Only auto-binds when LLM_PROVIDER=openrouter is explicit;
            // we don't want a stale OPENROUTER_API_KEY in .env to
            // silently take precedence over a working Cloudflare /
            // OpenAI setup.
            $openRouterKey = (string) (config('services.openrouter.key') ?: env('OPENROUTER_API_KEY', ''));
            if ($provider === 'openrouter') {
                if ($openRouterKey === '') {
                    return new FakeOpenAi;
                }

                return new OpenAiHttpClient(
                    apiKey: $openRouterKey,
                    chatModel: (string) (config('services.openrouter.chat_model') ?: env('OPENROUTER_CHAT_MODEL', 'meta-llama/llama-3.3-70b-instruct:free')),
                    embedModel: (string) env('OPENROUTER_EMBED_MODEL', 'text-embedding-3-small'),
                    baseUri: 'https://openrouter.ai/api/v1',
                    extraHeaders: [
                        'HTTP-Referer' => (string) env('APP_URL', 'https://pitchbar.dev'),
                        'X-Title' => AppBranding::siteTitle(),
                    ],
                );
            }

            // OpenAI (fallback / quality bump)
            $openAiKey = (string) config('services.openai.key', env('OPENAI_API_KEY', ''));
            if ($openAiKey !== '') {
                return new OpenAiHttpClient(
                    apiKey: $openAiKey,
                    chatModel: (string) config('services.openai.chat_model', env('OPENAI_CHAT_MODEL', 'gpt-4o-mini')),
                    embedModel: (string) config('services.openai.embed_model', env('OPENAI_EMBED_MODEL', 'text-embedding-3-small')),
                );
            }

            // Nothing configured → fake (so dev never fails outright)
            return new FakeOpenAi;
        });

        // Embedding fallback — resolved by IndexDocumentJob when the
        // primary embed call (usually Workers AI) fails. We default to
        // OpenAI when OPENAI_API_KEY is set; otherwise no fallback and
        // the primary error propagates as before.
        $this->app->bind('embedding.fallback', function () {
            if (app()->runningUnitTests()) {
                return null;
            }
            $key = (string) (config('services.openai.key') ?? env('OPENAI_API_KEY', ''));
            if ($key === '') {
                return null;
            }

            return new OpenAiHttpClient(
                apiKey: $key,
                chatModel: (string) (config('services.openai.chat_model') ?? env('OPENAI_CHAT_MODEL', 'gpt-4o-mini')),
                embedModel: (string) (config('services.openai.embed_model') ?? env('OPENAI_EMBED_MODEL', 'text-embedding-3-small')),
            );
        });

        $this->app->singleton(QdrantClient::class, function () {
            if (app()->runningUnitTests()) {
                return new FakeQdrant;
            }

            $provider = (string) config('services.vector.provider', env('VECTOR_PROVIDER', ''));

            // Cloudflare Vectorize (preferred). Read from config first
            // so AppSettingsOverrideServiceProvider's DB-backed Settings
            // → AI providers → Cloudflare values take effect; fall
            // through to env() only on fresh installs that never
            // touched the admin UI. The LLM resolver above does the
            // same — without this parity, admins who entered creds
            // in the UI got LLM working but Vectorize 10000ing
            // (Authentication error) because Vectorize used a stale
            // / empty env value.
            $cfAccount = (string) (config('services.cloudflare.account_id') ?: env('CLOUDFLARE_ACCOUNT_ID', ''));
            $cfToken = (string) (config('services.cloudflare.api_token') ?: env('CLOUDFLARE_API_TOKEN', ''));
            if ($provider === 'cloudflare' || ($provider === '' && $cfAccount !== '' && $cfToken !== '')) {
                return VectorizeClient::default($cfAccount, $cfToken);
            }

            // Qdrant (fallback)
            $url = (string) config('services.qdrant.url', env('QDRANT_URL', ''));
            if ($url !== '') {
                return new QdrantHttpClient(
                    baseUrl: $url,
                    apiKey: env('QDRANT_API_KEY') ?: null,
                );
            }

            return new FakeQdrant;
        });

        $this->app->singleton(Crawler::class, function () {
            // Same DB-first → env-fallback pattern as the Vectorize
            // resolver. Without this, an admin-entered Cloudflare key
            // would silently fail to drive the Browser Rendering
            // crawler — the app would fall through to Browserless or
            // plain HTTP without telling anyone.
            $cfAccount = (string) (config('services.cloudflare.account_id') ?: env('CLOUDFLARE_ACCOUNT_ID', ''));
            $cfToken = (string) (config('services.cloudflare.api_token') ?: env('CLOUDFLARE_API_TOKEN', ''));
            if ($cfAccount !== '' && $cfToken !== '' && env('CLOUDFLARE_BROWSER_RENDERING', true)) {
                return CloudflareBrowserClient::default($cfAccount, $cfToken);
            }

            // Browserless (legacy)
            $browserlessToken = (string) config('services.browserless.token', env('BROWSERLESS_TOKEN', ''));
            if ($browserlessToken !== '') {
                return BrowserlessClient::default();
            }

            // Plain HTTP — free, works for server-rendered sites
            return new PlainHttpCrawler;
        });

        $this->app->singleton(Reranker::class, function () {
            if (app()->runningUnitTests()) {
                return new FakeReranker;
            }

            // DB-first → env-fallback for parity with LLM and Vectorize
            // resolvers. The reranker is optional (FakeReranker is a
            // safe no-op passthrough) but admins who entered CF
            // credentials in the UI expect reranking to work too.
            $cfAccount = (string) (config('services.cloudflare.account_id') ?: env('CLOUDFLARE_ACCOUNT_ID', ''));
            $cfToken = (string) (config('services.cloudflare.api_token') ?: env('CLOUDFLARE_API_TOKEN', ''));
            if ($cfAccount !== '' && $cfToken !== '' && env('RAG_RERANK_ENABLED', true)) {
                return CloudflareReranker::default($cfAccount, $cfToken);
            }

            // No CF keys → passthrough, retrieval still works.
            return new FakeReranker;
        });

        $this->app->singleton(NotionClient::class, fn () => NotionClient::default(
            (string) config('services.notion.client_id', ''),
            (string) config('services.notion.client_secret', ''),
        ));

        $this->app->singleton(GoogleClient::class, fn () => GoogleClient::default(
            (string) config('services.google.client_id', ''),
            (string) config('services.google.client_secret', ''),
        ));

        $this->app->singleton(WidgetJwt::class, function () {
            // firebase/php-jwt v6.10+ enforces a 256-bit (32-byte)
            // minimum key length for HS256 and throws DomainException
            // "Provided key is too short" otherwise. A fresh install
            // with the default placeholder or a hand-picked short
            // string (<32 bytes) would 500 every /widget/init call.
            // SHA-256 the configured secret unconditionally — same
            // signing key across requests (deterministic), always
            // 32 bytes, no change to existing tokens because the
            // signing key derivation is stable.
            $rawSecret = (string) config('services.widget.jwt_secret');
            if ($rawSecret === '' || $rawSecret === 'change-me-in-production') {
                // Fall through to APP_KEY so a fresh install with
                // only `php artisan key:generate` already has a real
                // signing key. APP_KEY is 32 bytes base64 by default.
                $rawSecret = (string) config('app.key');
            }
            $derived = hash('sha256', $rawSecret, true);

            return new WidgetJwt(
                secret: $derived,
                ttlMinutes: (int) config('services.widget.jwt_ttl_minutes', 60),
            );
        });

        $this->app->singleton(ParserRegistry::class);

        // Vertical preset registry — pure in-memory lookup table of preset
        // classes. `scoped` keeps it cheap under Octane: built once per
        // worker, reused per request, no transitive request-scoped state.
        $this->app->scoped(VerticalPresetRegistry::class, fn () => new VerticalPresetRegistry);

        // Tool registry depends on the preset registry to resolve which
        // tools an agent's capabilities allow. `scoped` for the same
        // Octane reasons.
        $this->app->scoped(ToolRegistry::class, fn ($app) => new ToolRegistry(
            $app->make(VerticalPresetRegistry::class),
        ));

        // LocaleResolver caches its `lang/*.json` directory scan on the
        // instance. `scoped` reuses that memo per request (zero-cost
        // dispatch from middleware → Inertia share → suggestion banner)
        // while still recalculating on the next request — so dropping a
        // new `lang/<code>.json` is picked up on the next page load
        // without restarting Octane.
        $this->app->scoped(LocaleResolver::class, fn () => new LocaleResolver);
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureDefaults();
        $this->configureRateLimiting();

        // Workspace is the billing entity, not User. One Stripe customer per
        // workspace, so the same person can own/admin multiple workspaces
        // each with its own subscription and plan.
        Cashier::useCustomerModel(Workspace::class);

        // We register our own webhook route at /billing/webhook (extending
        // Cashier's controller). Suppress Cashier's default route under
        // /stripe/* to avoid two endpoints handling the same payload.
        Cashier::ignoreRoutes();

        // Live-chat handoff: ping configured Slack / Teams webhooks
        // when a visitor asks for a human. Listener runs on the queue
        // so the visitor's request-human HTTP latency stays predictable.
        Event::listen(HumanRequestedEvent::class, LiveChatNotifier::class);

        // Mirror captured leads back into the WordPress site behind
        // the agent (when one is attached). Queued — never blocks the
        // visitor's lead-capture HTTP turn.
        Event::listen(LeadCapturedEvent::class, PushLeadToWordPress::class);

        // Per-job lifecycle log → the platform-admin "Queue health"
        // widget tails this table to show RUNNING / DONE / FAILED rows
        // live. Listener is synchronous (NOT queued) — it has to
        // record events from inside the queue worker itself.
        Event::subscribe(RecordJobRun::class);
    }

    /**
     * Configure default behaviors for production-ready applications.
     */
    protected function configureDefaults(): void
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null,
        );
    }

    /**
     * Public widget routes need their own throttles. These endpoints are hit
     * from third-party sites, so we segment by IP for init and by widget token
     * for follow-up session calls to avoid one noisy visitor suppressing every
     * other active conversation.
     */
    protected function configureRateLimiting(): void
    {
        // Widget endpoints used to ALSO segment by IP. That fails on the
        // very-common case of many visitors sitting behind one NAT or
        // proxy (corporate VPN, mobile carriers, shared WiFi, CDN edge
        // gateway) — the FIRST visitor's request count poisons every
        // other visitor on the same IP and they all start getting 429
        // even though they're separate people on different conversations.
        //
        // Fix: segment by the WIDGET TOKEN (issued per-conversation,
        // per-visitor at /widget/init). Real abuse from one conversation
        // still trips; benign sharing across NAT does not. The /init
        // endpoint runs BEFORE a token exists, so it keeps a soft IP
        // bucket — but at a level high enough that a small office
        // bursting through the marketing site never sees 429.
        RateLimiter::for('widget-init', function (Request $request) {
            $ip = $request->ip() ?? 'unknown';
            $agentId = Str::lower((string) $request->input('agent_id', 'missing-agent'));

            // Soft per-IP limit on init only. 1000/min/IP+agent +
            // 30000/hr/IP comfortably absorbs a packed coworking
            // office or a college campus on one outbound IP.
            return [
                Limit::perMinute(1000)
                    ->by("widget-init:minute:{$ip}:{$agentId}")
                    ->response($this->widgetThrottleResponse('Too many widget initialization requests. Please try again shortly.')),
                Limit::perHour(30000)
                    ->by("widget-init:hour:{$ip}")
                    ->response($this->widgetThrottleResponse('Too many widget initialization requests. Please try again later.')),
            ];
        });

        RateLimiter::for('widget-session', function (Request $request) {
            // PER-TOKEN ONLY. 300/min/token is generous for a real
            // visitor (streaming chat fires a handful of requests per
            // turn, not hundreds). The token is unique to one
            // (visitor, conversation, agent) tuple — abuse from one
            // bucket is the same visitor flooding their own session.
            $tokenKey = $this->widgetTokenThrottleKey($request);

            return [
                Limit::perMinute(300)
                    ->by("widget-session:token:{$tokenKey}")
                    ->response($this->widgetThrottleResponse('Too many widget requests for this conversation. Please slow down.')),
            ];
        });

        RateLimiter::for('widget-leads', function (Request $request) {
            // Per-token only. A real visitor submits one lead per
            // conversation; 30/min is the abuse floor without making
            // NAT-shared lead-form bursts look like 429-able traffic.
            $tokenKey = $this->widgetTokenThrottleKey($request);

            return [
                Limit::perMinute(30)
                    ->by("widget-leads:token:{$tokenKey}")
                    ->response($this->widgetThrottleResponse('Too many lead submissions. Please wait before trying again.')),
            ];
        });
    }

    /**
     * @return \Closure(Request, array<string, string>): JsonResponse
     */
    protected function widgetThrottleResponse(string $message): \Closure
    {
        return fn (Request $request, array $headers) => response()->json([
            'error' => [
                'code' => 'rate_limited',
                'message' => $message,
            ],
        ], 429, $headers);
    }

    protected function widgetTokenThrottleKey(Request $request): string
    {
        $token = $request->bearerToken() ?? $request->header('X-Widget-Token');

        if (! is_string($token) || $token === '') {
            return 'missing-token';
        }

        return substr(hash('sha256', $token), 0, 20);
    }
}
