<?php

namespace App\Jobs;

use App\Core\Product\ModuleParamsBuilder;
use App\Core\Hook\HookBridge;
use App\Core\Hook\HookManager;
use App\Models\Admin;
use App\Models\ClientProduct;
use App\Models\Product;
use App\Jobs\SendEmailJob;
use App\Notifications\ProductChangeFailedNotification;
use App\Services\ProrationService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class ProcessProductChangeJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;

    public function __construct(
        public readonly int $clientProductId,
        public readonly int $targetProductId,
        public readonly int $newPeriod,
        public readonly bool $skipRefund = false,
        // PRC-1095/PRC-1097 — the downgrade credit amount computed by
        // PlanChangeService (abs of the negative proration diff). The old
        // implementation looked for an upgrade_type='downgrade' InvoiceItem that
        // nothing ever created, so the refund was permanently unreachable.
        public readonly float $refundAmount = 0.0,
    ) {}

    public function handle(): void
    {
        $newBillingCycle = ProrationService::monthsToBillingCycle($this->newPeriod);

        $service = ClientProduct::with(['product', 'server'])->find($this->clientProductId);

        if (! $service) {
            Log::error('ProcessProductChangeJob: Service not found', ['id' => $this->clientProductId]);
            return;
        }

        $newProduct = Product::find($this->targetProductId);

        if (! $newProduct) {
            Log::error('ProcessProductChangeJob: New product not found', ['id' => $this->targetProductId]);
            return;
        }

        $isSameProduct = (int) $service->product_id === (int) $newProduct->id;

        // Same-product change: skip module action entirely. Just update billing record.
        if ($isSameProduct) {
            $oldProduct      = $service->product;
            $oldBillingCycle = $service->billingcycle;
            $oldProductId    = $service->product_id;
            $this->updateServiceRecord($service, $newProduct, $newBillingCycle);
            $this->maybeDispatchDowngradeRefund($service, $newProduct);
            $this->sendCompletedEmail($service, $newProduct, $oldProduct, $oldBillingCycle, $newBillingCycle);
            $this->fireProductUpgraded($service, $oldProductId, $newProduct);
            return;
        }

        // If no module or module doesn't support package changes, just update the record
        if (! $newProduct->module) {
            $this->updateServiceRecord($service, $newProduct, $newBillingCycle);
            return;
        }

        // Get module instance
        $moduleConfig = $newProduct->getModuleConfigArray($newProduct->module);
        $moduleClass = $this->getProductModule($newProduct->module, $moduleConfig);

        // Check if module supports changeAccountPackage
        if (! method_exists($moduleClass, 'changeAccountPackage')) {
            $this->updateServiceRecord($service, $newProduct, $newBillingCycle);
            return;
        }

        $params = $this->buildModuleParams($service, $newProduct, $newBillingCycle);

        try {
            $hookResult = app(HookManager::class)->filter('PreModuleChangePackage', $params);
            if (is_array($hookResult) && ($hookResult['abortcmd'] ?? false) === true) {
                Log::info('ProcessProductChangeJob: aborted by PreModuleChangePackage hook', [
                    'client_product_id' => $service->id,
                ]);
                return;
            }
            $params = is_array($hookResult) ? $hookResult : $params;

            $result = $moduleClass->changeAccountPackage($params);

            if (isset($result['status']) && $result['status'] === 'success') {
                $oldProduct      = $service->product;
                $oldBillingCycle = $service->billingcycle;
                $oldProductId    = $service->product_id;
                $this->updateServiceRecord($service, $newProduct, $newBillingCycle);

                // If this change corresponds to a downgrade invoice item, dispatch a refund
                // using the product's downgrade_credit_mode. Refund failures are logged but
                // must not fail the job — the product change itself succeeded.
                $this->maybeDispatchDowngradeRefund($service, $newProduct);

                $this->sendCompletedEmail($service, $newProduct, $oldProduct, $oldBillingCycle, $newBillingCycle);

                Log::info('Product change successful', [
                    'client_product_id' => $service->id,
                    'old_product_id' => $service->product_id,
                    'new_product_id' => $newProduct->id,
                ]);

                // Wrap the hook fire so a listener exception can't force a retry
                // of a change that already succeeded (module changed + record
                // updated + refund credited). Sibling hooks are already guarded.
                try {
                    /** @var HookBridge $bridge */
                    $bridge = app(HookBridge::class);
                    $bridge->fireAction('AfterModuleChangePackage', array_merge(
                        $bridge->buildServiceContext($service),
                        ['params' => $params]
                    ));
                } catch (\Throwable $hookEx) {
                    Log::warning('AfterModuleChangePackage hook fire failed', [
                        'client_product_id' => $service->id,
                        'error' => $hookEx->getMessage(),
                    ]);
                }

                $this->fireProductUpgraded($service, $oldProductId, $newProduct);
            } else {
                $errorMessage = $result['message'] ?? 'Unknown error';
                Log::error('Product change failed on module', [
                    'client_product_id' => $service->id,
                    'error' => $errorMessage,
                ]);

                $service->update([
                    'pending_product_id' => null,
                    'pending_billingcycle' => null,
                    'pending_options' => null,
                ]);

                // Notify the client that the plan change could not be completed.
                try {
                    SendEmailJob::dispatch('Product Change Failed', 'client', $service->client_id, [
                        'CLIENT_NAME' => trim(($service->client?->first_name ?? '') . ' ' . ($service->client?->last_name ?? '')),
                        'OLD_PRODUCT' => $service->product->name ?? '',
                        'NEW_PRODUCT' => $newProduct->name,
                    ]);
                } catch (\Throwable $mailEx) {
                    Log::warning('product_change_failed email dispatch failed', [
                        'client_product_id' => $service->id,
                        'error' => $mailEx->getMessage(),
                    ]);
                }

                /** @var HookBridge $bridge */
                $bridge = app(HookBridge::class);
                $bridge->fireAction('AfterModuleChangePackageFailed', array_merge(
                    $bridge->buildServiceContext($service),
                    [
                        'params' => $params,
                        'failureResponseMessage' => $errorMessage,
                    ]
                ));
            }
        } catch (\Exception $e) {
            Log::error('Product change exception', [
                'client_product_id' => $service->id,
                'error' => $e->getMessage(),
            ]);

            // Mark service provisioning as failed so admin UI / cron can surface it.
            $service->update([
                'provisioning_status' => 'failed',
                'provisioning_error' => $e->getMessage(),
                'last_provisioning_attempt' => now(),
                'provisioning_attempt' => ($service->provisioning_attempt ?? 0) + 1,
            ]);

            // Notify admins who can view product-change failures.
            // Permission `view product change failures` is registered in PermissionSeeder
            // and granted to super-admins via AdminSeeder.
            try {
                Admin::query()
                    ->whereHas('roles.permissions', fn ($q) => $q->where('name', 'view product change failures'))
                    ->orWhereHas('permissions', fn ($q) => $q->where('name', 'view product change failures'))
                    ->get()
                    ->each(fn ($admin) => $admin->notify(new ProductChangeFailedNotification($service)));
            } catch (\Throwable $notifyEx) {
                Log::warning('Failed to notify admins of product change failure', [
                    'client_product_id' => $service->id,
                    'error' => $notifyEx->getMessage(),
                ]);
            }

            throw $e;
        }
    }

    /**
     * Dispatch the product_change_completed email. Extracted for reuse from both
     * the same-product path and the module-success path.
     *
     * Merge fields written:
     *   CLIENT_NAME, OLD_PRODUCT, NEW_PRODUCT, OLD_PERIOD, NEW_PERIOD,
     *   CONFIG_OPTIONS (HTML <ul> of resolved options + values + custom
     *   fields after the change is applied).
     *
     * Names/values are rendered in the client's saved language so the email
     * matches the locale they read in their portal.
     */
    private function sendCompletedEmail(
        ClientProduct $service,
        Product $newProduct,
        Product|string|null $oldProduct = null,
        ?string $oldBillingCycle = null,
        ?string $newBillingCycle = null,
    ): void {
        try {
            $locale = $service->client?->language?->a2_iso ?? app()->getLocale();
            $cycleLabel = function (?string $cycle) use ($locale): string {
                if (! $cycle) return '';
                $key = 'store.service_billing_cycle_' . str_replace('-', '_', $cycle);
                $label = \Illuminate\Support\Facades\Lang::get($key, [], $locale);
                return $label === $key ? $cycle : $label;
            };
            $localizedName = function ($product, string $locale): string {
                if ($product instanceof Product) {
                    return method_exists($product, 'getTranslation')
                        ? ($product->getTranslation('name', $locale) ?? $product->name)
                        : ($product->name ?? '');
                }
                return is_string($product) ? $product : '';
            };

            SendEmailJob::dispatch('Product Change Completed', 'client', $service->client_id, [
                'CLIENT_NAME'    => trim(($service->client?->first_name ?? '') . ' ' . ($service->client?->last_name ?? '')),
                'OLD_PRODUCT'    => $localizedName($oldProduct, $locale),
                'NEW_PRODUCT'    => $localizedName($newProduct, $locale),
                'OLD_PERIOD'     => $cycleLabel($oldBillingCycle),
                'NEW_PERIOD'     => $cycleLabel($newBillingCycle ?? $service->billingcycle),
                'CONFIG_OPTIONS' => $this->buildConfigOptionsMergeField($service, $locale),
            ]);
        } catch (\Throwable $mailEx) {
            Log::warning('product_change_completed email dispatch failed', [
                'client_product_id' => $service->id,
                'error' => $mailEx->getMessage(),
            ]);
        }
    }

    /**
     * Fire the AfterProductUpgrade action hook once a product change has fully
     * completed (record updated + completion email sent). Covers both the
     * same-product and module-success paths. The Hook→Event bridge (PRC-630)
     * turns this into a typed Hooks\Service\ProductUpgraded event.
     *
     * 'type' reflects the change direction recorded on the upgrade invoice item
     * (upgrade/downgrade); defaults to 'upgrade' when no item is found.
     */
    private function fireProductUpgraded(ClientProduct $service, int $oldProductId, Product $newProduct): void
    {
        try {
            $type = \App\Models\InvoiceItem::query()
                ->where('related_service_id', $service->id)
                ->whereIn('upgrade_type', ['upgrade', 'downgrade'])
                ->latest('id')
                ->value('upgrade_type') ?? 'upgrade';

            /** @var HookBridge $bridge */
            $bridge = app(HookBridge::class);
            $bridge->fireAction('AfterProductUpgrade', array_merge(
                $bridge->buildServiceContext($service),
                [
                    'old_product_id' => $oldProductId,
                    'new_product_id' => $newProduct->id,
                    'type'           => $type,
                ]
            ));
        } catch (\Throwable $e) {
            Log::warning('AfterProductUpgrade hook fire failed', [
                'client_product_id' => $service->id,
                'error'             => $e->getMessage(),
            ]);
        }
    }

    /**
     * Render the client's current (post-change) config option values as an
     * HTML <ul>. Returns an empty string when the service has none, so the
     * email template can wrap {CONFIG_OPTIONS} unconditionally.
     */
    private function buildConfigOptionsMergeField(ClientProduct $service, string $locale): string
    {
        $items = [];

        $optionRows = \App\Models\ClientProductConfigOptionValue::where('product_id', $service->id)->get();
        foreach ($optionRows as $row) {
            $opt = \App\Models\ProductConfigOption::find($row->option_id);
            $val = \App\Models\ProductConfigOptionValue::find($row->value);
            if (! $opt || ! $val) continue;
            $optName = method_exists($opt, 'getTranslation')
                ? ($opt->getTranslation('name', $locale) ?? $opt->name)
                : $opt->name;
            $valLabel = method_exists($val, 'getTranslation')
                ? ($val->getTranslation('value', $locale) ?? $val->value)
                : $val->value;
            $items[] = '<li><strong>' . e($optName) . ':</strong> ' . e($valLabel) . '</li>';
        }

        return $items === [] ? '' : '<ul>' . implode('', $items) . '</ul>';
    }

    /**
     * If a corresponding invoice_item exists with upgrade_type='downgrade' for this
     * service, dispatch a refund via RefundDispatcher using the product's
     * downgrade_credit_mode. Errors are logged but never propagated — the product
     * change has already succeeded at this point.
     */
    private function maybeDispatchDowngradeRefund(ClientProduct $service, Product $newProduct): void
    {
        if ($this->skipRefund) {
            return;
        }

        try {
            // PRC-1095/PRC-1097 — use the credit amount computed by
            // PlanChangeService (passed via the job) instead of hunting for a
            // downgrade InvoiceItem that nothing ever created. balance_only (the
            // default) credits the ClientBalance ledger, which the checkout
            // credit-apply flow (PRC-1097) then lets the client spend.
            $amount = round((float) $this->refundAmount, 2);

            if ($amount <= 0) {
                return;
            }

            $mode = $newProduct->downgrade_credit_mode ?? 'balance_only';

            // Skip when configured to never refund.
            if ($mode === 'none') {
                return;
            }

            // Idempotency (job $tries = 3): resolve the source downgrade
            // InvoiceItem so we can anchor the refund credit to it. If a refund
            // ClientBalance already exists for this item, a prior attempt already
            // credited it — return early to avoid double-crediting on retry.
            $sourceItemId = \App\Models\InvoiceItem::query()
                ->where('related_service_id', $service->id)
                ->where('upgrade_type', 'downgrade')
                ->latest('id')
                ->value('id');

            if ($sourceItemId !== null) {
                $alreadyRefunded = \App\Models\ClientBalance::query()
                    ->where('client_id', $service->client_id)
                    ->where('reference_type', 'refund')
                    ->where('reference_id', $sourceItemId)
                    ->exists();

                if ($alreadyRefunded) {
                    return;
                }
            }

            $result = app(\App\Services\Refund\RefundDispatcher::class)->dispatch(
                $service,
                $amount,
                $mode,
                "Downgrade refund for service #{$service->id}",
                null,
                $sourceItemId !== null ? (int) $sourceItemId : null
            );

            // Notify the client about the refund outcome. The template engine
            // does not support conditionals, so we format OUTCOME to a
            // human-readable string here before passing it in.
            try {
                if ($result instanceof \App\Services\Refund\RefundResult && $result->outcome !== 'skipped') {
                    SendEmailJob::dispatch('Product Change Credit Issued', 'client', $service->client_id, [
                        'CLIENT_NAME' => trim(($service->client?->first_name ?? '') . ' ' . ($service->client?->last_name ?? '')),
                        'AMOUNT' => number_format($result->amount, 2),
                        'OUTCOME' => $this->formatRefundOutcome($result->outcome),
                    ]);
                }
            } catch (\Throwable $mailEx) {
                Log::warning('product_change_credit_issued email dispatch failed', [
                    'client_product_id' => $service->id,
                    'error' => $mailEx->getMessage(),
                ]);
            }
        } catch (\Throwable $e) {
            \App\Models\Log::create([
                'type' => \App\Models\Log::TYPE_SYSTEM,
                'observer' => self::class,
                'module' => 'ProductChange',
                'method' => 'maybeDispatchDowngradeRefund',
                'client_id' => $service->client_id,
                'ip_address' => '0.0.0.0',
                'description' => "Downgrade refund dispatch failed for service #{$service->id}: " . $e->getMessage(),
                'request' => [
                    'client_product_id' => $service->id,
                    'error' => $e->getMessage(),
                ],
            ]);
        }
    }

    private function updateServiceRecord(ClientProduct $service, Product $newProduct, string $newBillingCycle): void
    {
        // Resolve the new recurring price (per ProrationService) for the new product/cycle.
        $newPeriodMonths = ProrationService::billingCycleToMonths($newBillingCycle);

        try {
            $newPrice = app(ProrationService::class)
                ->getNewPrice($newProduct, $newPeriodMonths, $service);
        } catch (\Throwable $e) {
            // Don't block the product change if pricing lookup fails — just log.
            Log::warning('ProcessProductChangeJob: getNewPrice failed; recurring_amount not updated', [
                'client_product_id' => $service->id,
                'new_product_id' => $newProduct->id,
                'error' => $e->getMessage(),
            ]);
            $newPrice = null;
        }

        // Apply pending_options (Task 16) — add/drop config option values and
        // custom field values that were captured at invoice creation time. Once
        // applied, the pending_options field will be cleared via $updates below.
        $pending = $service->pending_options ?? [];

        foreach ($pending['drop'] ?? [] as $optionId) {
            \App\Models\ClientProductConfigOptionValue::where('product_id', $service->id)
                ->where('option_id', $optionId)
                ->delete();
        }

        foreach ($pending['add'] ?? [] as $entry) {
            if (empty($entry['option_id']) || empty($entry['value_id'])) continue;
            \App\Models\ClientProductConfigOptionValue::create([
                'product_id' => $service->id,
                'option_id'  => $entry['option_id'],
                'value'      => $entry['value_id'],
            ]);
        }

        foreach ($pending['change'] ?? [] as $entry) {
            if (empty($entry['option_id']) || empty($entry['to_value_id'])) continue;
            \App\Models\ClientProductConfigOptionValue::where('product_id', $service->id)
                ->where('option_id', $entry['option_id'])
                ->update(['value' => $entry['to_value_id']]);
        }

        foreach ($pending['custom_fields_add'] ?? [] as $cf) {
            if (empty($cf['field_id'])) continue;
            \App\Models\ClientCustomFieldValue::create([
                'custom_field_id' => $cf['field_id'],
                'client_id'       => $service->client_id,
                'field_value'     => $cf['value'] ?? '',
            ]);
        }

        foreach ($pending['custom_fields_drop'] ?? [] as $fieldId) {
            \App\Models\ClientCustomFieldValue::where('client_id', $service->client_id)
                ->where('custom_field_id', $fieldId)
                ->delete();
        }

        $updates = [
            'product_id' => $newProduct->id,
            'module' => $newProduct->module,
            'billingcycle' => $newBillingCycle,
            'pending_product_id' => null,
            'pending_billingcycle' => null,
            'pending_options' => null,
            // Lock the service from further plan changes until the next renewal
            // invoice is paid. HandleInvoiceStatusChange::updateRecurringAmounts
            // clears the lock when it sees a renewal invoice item for this service.
            'upgrade_locked' => true,
        ];

        if ($newPrice !== null) {
            $updates['recurring_amount'] = $newPrice;
        }

        $service->update($updates);
    }

    private function buildModuleParams(ClientProduct $service, Product $newProduct, string $newBillingCycle): array
    {
        // New product'ın module config'inden plan/package/server_type çıkar
        $newProductModuleConfigs = $newProduct->moduleConfigs()
            ->where('server_id', $service->server_id)
            ->pluck('value', 'key')
            ->toArray();

        $extras = [
            // Change-specific context
            'old_product_id' => $service->product_id,
            'new_product_id' => $newProduct->id,
            'new_product_name' => $newProduct->name,
            'new_product_module' => $newProduct->module,
            'new_billing_cycle' => $newBillingCycle,
            'new_product_module_config' => $newProductModuleConfigs,
        ];

        // Plan/package keys — legacy calling convention support
        if (isset($newProductModuleConfigs['plan'])) {
            $extras['plan'] = $newProductModuleConfigs['plan'];
        }
        if (isset($newProductModuleConfigs['package'])) {
            $extras['plan'] = $newProductModuleConfigs['package'];
        }
        if (isset($newProductModuleConfigs['server_type'])) {
            $extras['server_type'] = $newProductModuleConfigs['server_type'];
        }

        return ModuleParamsBuilder::build($service, $extras);
    }

    /**
     * Convert a RefundResult outcome enum into a human-readable string for
     * email rendering. The email template engine does not support conditionals
     * so we resolve the variant here.
     */
    private function formatRefundOutcome(string $outcome): string
    {
        return match ($outcome) {
            'gateway_refunded' => __('store.refund_outcome_gateway_refunded'),
            'gateway_failed_balance_fallback' => __('store.refund_outcome_gateway_failed_balance_fallback'),
            'balance_credited' => __('store.refund_outcome_balance_credited'),
            'manual_queued' => __('store.refund_outcome_manual_queued'),
            default => $outcome,
        };
    }

    private function getProductModule(string $moduleName, array $config = [])
    {
        $moduleName = strtolower($moduleName);
        $moduleClass = 'App\\Modules\\Product\\' . ucfirst($moduleName) . '\\' . ucfirst($moduleName);

        if (! class_exists($moduleClass)) {
            throw new \Exception("Product module not found: {$moduleName}");
        }

        return new $moduleClass($config);
    }
}
