<?php

namespace App\Providers;

use App\Helpers\SvgSanitizer;
use App\Http\Responses\LogoutResponse;
use App\Listeners\IntegrationNotificationListener;
use App\Listeners\WorkflowTriggerListener;
use App\Models\Message;
use App\Models\Plan;
use App\Models\Tag;
use App\Models\Workspace;
use App\Observers\MessageObserver;
use App\Observers\PlanObserver;
use App\Observers\TagObserver;
use App\Observers\WorkspaceObserver;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        // Bind StripeService as a singleton (reuses the same Stripe client)
        $this->app->singleton(\App\Services\Billing\StripeService::class);

        // Bind FileSecurityService as a singleton
        $this->app->singleton(\App\Services\FileSecurityService::class);

        // Register custom logout response to clean up impersonation sessions
        $this->app->singleton(LogoutResponseContract::class, LogoutResponse::class);
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        // Cap the default VARCHAR length to 191 characters so unique
        // indexes on string columns stay under MySQL 5.7's 767-byte
        // limit (with utf8mb4 each char takes 4 bytes → 191×4 = 764).
        //
        // Customers running on shared hosting / cPanel installs that
        // ship with MySQL 5.6 or older MariaDB (where innodb_large_prefix
        // is OFF by default) hit "Specified key was too long; max key
        // length is 767 bytes" during `php artisan migrate` — most
        // visibly on the Spatie permissions table which creates
        // unique(`name`, `guard_name`) on two VARCHAR(255) columns
        // (2 × 255 × 4 = 2040 bytes, way over the cap).
        //
        // 191 is the standard Laravel community workaround; it works on
        // every MySQL/MariaDB version we support without any other
        // changes, and the lost capacity is negligible for the columns
        // that actually use it (role names, guard names, email keys —
        // never user-typed long-form content).
        Schema::defaultStringLength(191);

        // Register the hand-rolled Microsoft Socialite driver. Laravel
        // Socialite only ships with Google / GitHub / Facebook / Bitbucket
        // / GitLab / LinkedIn out of the box — calling
        // Socialite::driver('microsoft') without this throws
        // "Driver [microsoft] not supported." (the exact error
        // production logs showed for customers trying to use "Sign in
        // with Microsoft"). We extend at boot so the driver is available
        // before any auth route fires.
        //
        // The require_once below is a deliberate autoload safety net.
        // Customers who deploy via the System Update ZIP don't always
        // run `composer dump-autoload` afterward, so the PSR-4 mapper
        // in vendor/composer/autoload_psr4.php has no entry for any
        // newly-added App\Support\Socialite\* class — and Composer
        // returns null without falling back to a filesystem scan,
        // giving them "Class \"App\\Support\\Socialite\\MicrosoftProvider\"
        // not found" the moment they click the Microsoft login button.
        // Loading the file directly bypasses the autoloader for this
        // one class so social login keeps working on stale autoload
        // caches. Harmless on properly-dumped installs (require_once
        // no-ops on the second call).
        $microsoftProviderFile = app_path('Support/Socialite/MicrosoftProvider.php');
        if (is_file($microsoftProviderFile)) {
            require_once $microsoftProviderFile;
        }
        \Laravel\Socialite\Facades\Socialite::extend('microsoft', function ($app) {
            $config = $app['config']['services.microsoft'];
            return \Laravel\Socialite\Facades\Socialite::buildProvider(
                \App\Support\Socialite\MicrosoftProvider::class,
                $config,
            );
        });

        // Apply admin-configured S3/CDN settings to the runtime filesystem
        // config BEFORE anything that resolves the filesystem manager. This
        // is what lets file uploads route to S3 without controllers caring
        // which provider is configured.
        \App\Helpers\StorageHelper::applyRuntimeConfig();

        // Super Admin bypasses all Spatie permission checks (like SnapNest).
        //
        // Gate::before fires for EVERY ability check across the app. It must
        // handle three model shapes safely:
        //   - User       (has Spatie HasRoles trait → hasRole() exists)
        //   - Admin      (admin guard model, has $role column, NO hasRole)
        //   - null       (guest — gate checks can run before auth resolves)
        //
        // The old `$user->hasRole(...)` call exploded when the admin guard
        // resolved to an Admin instance ("Call to undefined method
        // App\Models\Admin::hasRole()"). Now we check the model type first.
        Gate::before(function ($user, $ability) {
            if (! $user) {
                return null;
            }
            if ($user instanceof \App\Models\Admin) {
                return $user->role === 'super_admin' ? true : null;
            }
            if (method_exists($user, 'hasRole')) {
                try {
                    if ($user->hasRole('Super Admin')) {
                        return true;
                    }
                } catch (\Throwable $e) {
                    // Spatie role table missing or guard mismatch — fall through
                }
            }
            // User model with admin_role column — last-resort fallback so a
            // bridged-via-LoginResponse super_admin still bypasses gates.
            if (! empty($user->admin_role) && $user->admin_role === 'super_admin') {
                return true;
            }
            return null;
        });

        // ── Per-user rate limiters (fall back to IP for guests) ──────
        RateLimiter::for('api', function (Request $request) {
            return Limit::perMinute(600)->by($request->user()?->id ?: $request->ip());
        });

        RateLimiter::for('sensitive', function (Request $request) {
            return Limit::perMinute(600)->by($request->user()?->id ?: $request->ip());
        });

        // Configure public disk URL for subdirectory deployment support
        $this->configurePublicDiskUrl();
        $this->ensureStorageLink();

        // White-label: override Laravel's app.name + mail.from.name with the
        // admin-configured site_name from the database. This means EVERY
        // transactional email, every config('app.name') reference, and every
        // generic notification automatically picks up the platform brand
        // without touching individual templates.
        $this->applyBrandOverride();
        $this->applyAdminSmtpSettings();

        // Override config/services with admin settings from database
        // so admin panel keys actually work (not just .env)
        $this->applyAdminIntegrationKeys();

        // Register middleware aliases at runtime so v1.3+ routes work on
        // customer installs whose bootstrap/app.php is still the v1.2
        // version. The update wizard skips bootstrap/app.php to preserve
        // their customisations, so any alias added there alone never
        // propagates via the update ZIP. Registering here ensures the
        // alias is available regardless of bootstrap/app.php state.
        // aliasMiddleware() is idempotent on duplicates.
        $this->registerLegacyMiddlewareAliases();

        // Boot any installed + active addons. Runs AFTER the SMTP / brand
        // overrides above so addons see the final config, and is fault-
        // tolerant so a broken addon can't take down the rest of the app.
        try {
            app(\App\Services\Addons\AddonManager::class)->bootActiveAddons();
        } catch (\Throwable $e) {
            \Illuminate\Support\Facades\Log::error('AddonManager boot failed: ' . $e->getMessage());
        }

        // Register the workflow trigger event subscriber
        Event::subscribe(WorkflowTriggerListener::class);

        // Register integration notification subscriber (Slack, Zapier dispatches on domain events)
        Event::subscribe(IntegrationNotificationListener::class);

        // Synchronous keyword auto-reply — fires immediately when a message
        // arrives so the customer gets their reply within the same request,
        // not on the minute-tick queue worker.
        Event::listen(
            \App\Events\MessageReceived::class,
            [\App\Listeners\AutoReplyListener::class, 'handle']
        );

        // Sanitize Message body_html on every create/update — defense in depth
        Message::observe(MessageObserver::class);

        // Cache invalidation observers — bust stale cache entries on model writes
        Workspace::observe(WorkspaceObserver::class);
        Tag::observe(TagObserver::class);
        Plan::observe(PlanObserver::class);

        // Add a convenience macro: $request->workspace()
        Request::macro('workspace', function () {
            /** @var Request $this */
            return $this->attributes->get('workspace');
        });

        // Register @safeSvg Blade directive for XSS-safe SVG icon rendering
        Blade::directive('safeSvg', function (string $expression) {
            return "<?php echo \App\Helpers\SvgSanitizer::sanitize($expression); ?>";
        });

        // Register @sanitize Blade directive for whitelist-based HTML sanitization
        Blade::directive('sanitize', function (string $expression) {
            return "<?php echo \App\Helpers\HtmlSanitizer::sanitize($expression); ?>";
        });

        // Register @currency Blade directive for dynamic currency formatting
        // Usage: @currency($amount) or @currency($amount, 'EUR')
        Blade::directive('currency', function (string $expression) {
            return "<?php echo \App\Helpers\CurrencyHelper::display($expression); ?>";
        });
    }

    /**
     * Register middleware aliases that v1.3+ route files depend on.
     *
     * Why this lives in a service provider instead of bootstrap/app.php:
     * the v1.3 update wizard does NOT overwrite bootstrap/app.php (so
     * admins don't lose their custom middleware tweaks), which means any
     * alias added only to bootstrap/app.php silently misses every legacy
     * install. Registering at runtime via the router guarantees the
     * alias exists regardless of which bootstrap/app.php version the
     * customer has on disk.
     *
     * Each alias is wrapped in a class_exists check so a partial deploy
     * (alias defined here, middleware file not yet uploaded) downgrades
     * to "alias not registered" instead of a hard 500.
     */
    private function registerLegacyMiddlewareAliases(): void
    {
        $aliases = [
            'frontend.enabled' => \App\Http\Middleware\EnsureFrontendEnabled::class,
        ];

        try {
            $router = $this->app->make('router');
            foreach ($aliases as $name => $class) {
                if (class_exists($class)) {
                    $router->aliasMiddleware($name, $class);
                }
            }
        } catch (\Throwable $e) {
            \Illuminate\Support\Facades\Log::warning('Middleware alias registration failed: ' . $e->getMessage());
        }
    }

    /**
     * Pull the admin-configured site_name (and SMTP from-name overrides
     * if set) out of system_settings and apply them to:
     *   - config('app.name')         → used by every transactional email
     *                                    template, notification subject, etc.
     *   - config('mail.from.name')   → the visible sender name on every
     *                                    outbound email from the platform
     *
     * Skips silently when the DB isn't reachable yet (during initial install).
     * Cached at the SystemSetting layer so this stays cheap on every request.
     */
    private function applyBrandOverride(): void
    {
        // Skip if not installed — DB tables don't exist yet
        if (!file_exists(storage_path('installed')) && env('INSTALLED', '0') !== '1') {
            return;
        }

        try {
            $siteName = \App\Models\SystemSetting::get('site_name', '');
            if (!empty($siteName)) {
                config(['app.name' => $siteName]);
                // Only override mail.from.name if the admin hasn't explicitly
                // set a different SMTP from-name in settings.
                $smtpFromName = \App\Models\SystemSetting::get('smtp_from_name', '');
                $mailFromName = \App\Models\SystemSetting::get('mail_from_name', '');
                if (empty($smtpFromName) && empty($mailFromName)) {
                    config(['mail.from.name' => $siteName]);
                }
            }
        } catch (\Throwable $e) {
            // DB not available — fall back to env values silently
        }
    }

    /**
     * Apply the admin's SMTP settings (saved via Admin → Settings → Email)
     * to the runtime mail config.
     *
     * Without this, even though the admin filled in smtp_host / username /
     * password / encryption in the UI, the mail facade keeps reading the
     * env values — which are usually `MAIL_MAILER=log` on shared hosting
     * — so every transactional email (OTP, password reset, welcome,
     * notifications) silently lands in storage/logs/laravel-*.log instead
     * of the user's inbox.
     *
     * Only takes effect when smtp_host is non-empty AND the install marker
     * exists. Falls back silently otherwise.
     */
    private function applyAdminSmtpSettings(): void
    {
        if (!file_exists(storage_path('installed')) && env('INSTALLED', '0') !== '1') {
            return;
        }

        try {
            $driver = strtolower((string) \App\Models\SystemSetting::get('mail_driver', config('mail.default', 'smtp')));
            if (in_array($driver, ['smtp', 'sendmail', 'log', 'array', 'ses', 'mailgun', 'postmark', 'resend'], true)) {
                config(['mail.default' => $driver]);
            }

            $host = trim((string) \App\Models\SystemSetting::get('smtp_host'));
            if ($driver !== 'smtp' || $host === '') {
                return; // admin hasn't configured SMTP yet — leave env values alone
            }

            $port = (int) \App\Models\SystemSetting::get('smtp_port', 587);
            $user = (string) \App\Models\SystemSetting::get('smtp_username');
            $pass = (string) \App\Models\SystemSetting::get('smtp_password');
            $enc  = strtolower((string) \App\Models\SystemSetting::get('smtp_encryption', 'tls'));

            $fromAddr = \App\Models\SystemSetting::get(
                'smtp_from_address',
                \App\Models\SystemSetting::get('mail_from_email', config('mail.from.address'))
            );
            $fromName = \App\Models\SystemSetting::get(
                'smtp_from_name',
                \App\Models\SystemSetting::get('mail_from_name', config('mail.from.name'))
            );

            config([
                'mail.default' => 'smtp',
                'mail.mailers.smtp' => \App\Support\SmtpConfig::build([
                    'host' => $host,
                    'port' => $port,
                    'username' => $user,
                    'password' => $pass,
                    'encryption' => $enc,
                    'timeout' => 30,
                    'local_domain' => parse_url((string) config('app.url', ''), PHP_URL_HOST),
                ]),
            ]);

            if (!empty($fromAddr)) config(['mail.from.address' => $fromAddr]);
            if (!empty($fromName)) config(['mail.from.name'    => $fromName]);
        } catch (\Throwable $e) {
            \Illuminate\Support\Facades\Log::warning('SMTP boot config failed: ' . $e->getMessage());
        }
    }

    /**
     * Auto-detect subdirectory deployment and configure all URL
     * generators (url(), asset(), route(), Storage::url()) to
     * include the correct base path.
     */
    private function configurePublicDiskUrl(): void
    {
        // Use APP_URL as the single source of truth for base URL
        $baseUrl = rtrim(config('app.url', ''), '/');

        // During web requests, prefer the actual host+base path over a
        // misconfigured APP_URL. This auto-corrects subdirectory installs
        // where the admin set APP_URL to the bare domain (e.g.
        // https://example.com) instead of including the subdir
        // (https://example.com/mailtrixy/public). Without this override,
        // url() / asset() / route() generated links pointing at the root
        // domain instead of the subdirectory and broke navigation.
        //
        // Only override when:
        //   - we have a request context (skip CLI / queue jobs)
        //   - the request has a non-empty base path (we're in a subdir)
        //   - APP_URL doesn't already include that base path
        if (app()->bound('request')) {
            try {
                $request = request();
                $requestBase = rtrim($request->getSchemeAndHttpHost() . $request->getBasePath(), '/');
                $basePath = $request->getBasePath(); // e.g. "/mailtrixy/public"

                if ($basePath !== ''
                    && (empty($baseUrl) || !str_contains($baseUrl, $basePath))
                ) {
                    $baseUrl = $requestBase;
                }
            } catch (\Throwable $e) {
                // Request not actually available — keep APP_URL value
            }
        }

        // Fallback to request detection if APP_URL not set
        if (empty($baseUrl) && app()->bound('request')) {
            $request = request();
            $baseUrl = rtrim($request->getSchemeAndHttpHost() . $request->getBasePath(), '/');
        }

        if (empty($baseUrl)) {
            return;
        }

        // Force the URL generator root so url(), asset(), route() all
        // respect the subdirectory (e.g. /MailTrixy/public)
        URL::forceRootUrl($baseUrl);

        // Shared hosting fix: if storage symlink doesn't exist, use direct path
        $storagePath = public_path('storage');
        if (!is_link($storagePath)) {
            if (!file_exists($storagePath)) {
                @mkdir($storagePath, 0755, true);
            }
            config(['filesystems.disks.public.root' => $storagePath]);
        }

        // Fix Storage::url() for subdirectory path
        config(['filesystems.disks.public.url' => $baseUrl . '/storage']);
    }

    /**
     * Auto-create storage:link if missing (shared hosting compatibility).
     */
    private function ensureStorageLink(): void
    {
        $storagePath = public_path('storage');

        if (!is_link($storagePath) && !file_exists($storagePath)) {
            try {
                \Illuminate\Support\Facades\Artisan::call('storage:link');
            } catch (\Throwable $e) {
                // Fail silently — likely file permission issue on some hostings
            }
        }
    }

    /**
     * Read admin-configured OAuth/API keys from system_settings
     * and apply them to config('services.*') at runtime.
     * This bridges the admin UI → config/services.php gap.
     */
    private function applyAdminIntegrationKeys(): void
    {
        // Skip if not installed (no DB yet)
        if (!file_exists(storage_path('installed')) && env('INSTALLED', '0') !== '1') {
            return;
        }

        try {
            $map = [
                'google_client_id'       => 'services.google.client_id',
                'google_client_secret'   => 'services.google.client_secret',
                'microsoft_client_id'    => 'services.microsoft.client_id',
                'microsoft_client_secret'=> 'services.microsoft.client_secret',
                'github_client_id'       => 'services.github.client_id',
                'github_client_secret'   => 'services.github.client_secret',
                'facebook_client_id'     => 'services.facebook.client_id',
                'facebook_client_secret' => 'services.facebook.client_secret',
                'slack_client_id'        => 'services.slack.client_id',
                'slack_client_secret'    => 'services.slack.client_secret',
                'slack_signing_secret'   => 'services.slack.signing_secret',
                'salesforce_client_id'   => 'services.salesforce.client_id',
                'salesforce_client_secret'=> 'services.salesforce.client_secret',
                'stripe_key'             => 'services.stripe.key',
                'stripe_secret'          => 'services.stripe.secret',
                'stripe_webhook_secret'  => 'services.stripe.webhook_secret',
                'pusher_app_id'          => 'services.pusher.app_id',
                'pusher_key'             => 'services.pusher.key',
                'pusher_secret'          => 'services.pusher.secret',
                'pusher_cluster'         => 'services.pusher.cluster',
                // AI provider keys (platform defaults — workspace keys override these)
                'openai_api_key'         => 'services.openai.api_key',
                'anthropic_api_key'      => 'services.anthropic.api_key',
                'gemini_api_key'         => 'services.gemini.api_key',
                'mistral_api_key'        => 'services.mistral.api_key',
            ];

            foreach ($map as $settingKey => $configKey) {
                $value = \App\Models\SystemSetting::get($settingKey);
                if (!empty($value)) {
                    config([$configKey => $value]);
                }
            }

            // Hot-swap the broadcaster to Pusher when the admin has
            // saved valid Pusher credentials AND flipped the integration
            // toggle on. Previously even with keys saved the platform
            // wrote broadcasts to `storage/logs/laravel.log` because
            // .env still had BROADCAST_CONNECTION=log — making Pusher
            // appear "configured but not working" from the admin's POV.
            //
            // We only flip when both:
            //   - integration_pusher_enabled = true (admin opted in)
            //   - app_id + key + secret are all non-empty (no half-config
            //     handoff to broadcasting that would 500 on every send)
            $pusherEnabled = \App\Models\SystemSetting::get('integration_pusher_enabled', 'false') === 'true';
            if ($pusherEnabled
                && !empty(config('services.pusher.app_id'))
                && !empty(config('services.pusher.key'))
                && !empty(config('services.pusher.secret'))
            ) {
                // Inject the admin-saved values into the broadcasting
                // pusher connection so the driver picks them up without
                // needing them in .env. config/broadcasting.php reads
                // these from env() at boot — we overwrite at runtime.
                config([
                    'broadcasting.default'                         => 'pusher',
                    'broadcasting.connections.pusher.key'          => config('services.pusher.key'),
                    'broadcasting.connections.pusher.secret'       => config('services.pusher.secret'),
                    'broadcasting.connections.pusher.app_id'       => config('services.pusher.app_id'),
                    'broadcasting.connections.pusher.options.cluster' => config('services.pusher.cluster', 'mt1'),
                ]);
            }

            // Auto-derive OAuth redirect URIs from APP_URL unless the admin
            // explicitly set one in .env. Saves every admin from having to
            // hand-configure these for each provider — the path is always
            // /integrations/{provider}/callback and must match what the
            // vendor app (Slack/Salesforce/Google) has registered.
            $base = rtrim(config('app.url', ''), '/');
            if ($base !== '') {
                $defaults = [
                    // Social sign-in (Sign in with Google / Microsoft on the
                    // public /login page). These were missing earlier — admin
                    // could save the OAuth client_id/secret in the admin
                    // panel but Socialite still crashed with a null redirect
                    // URI, showing customers a generic 500 page. The path is
                    // matched 1:1 by routes/web.php → /auth/{provider}/callback.
                    'services.google.redirect'         => "{$base}/auth/google/callback",
                    'services.microsoft.redirect'      => "{$base}/auth/microsoft/callback",
                    'services.github.redirect'         => "{$base}/auth/github/callback",
                    'services.facebook.redirect'       => "{$base}/auth/facebook/callback",
                    // Workspace integrations (different callback path).
                    'services.slack.redirect'          => "{$base}/integrations/slack/callback",
                    'services.salesforce.redirect'     => "{$base}/integrations/salesforce/callback",
                    'services.google_calendar.redirect'=> "{$base}/integrations/google-calendar/callback",
                ];
                foreach ($defaults as $key => $url) {
                    if (empty(config($key))) {
                        config([$key => $url]);
                    }
                }
            }
        } catch (\Throwable $e) {
            // DB not available yet — silently skip
        }
    }
}
