<?php

namespace App\Services\Product;

use App\DTO\PlanChangeResult;
use App\Jobs\ProcessProductChangeJob;
use App\Models\ClientProduct;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\Product;
use App\Services\Billing\InvoiceNumberService;
use App\Services\ProrationService;
use App\Services\TaxCalculationService;
use Illuminate\Support\Facades\DB;

class PlanChangeService
{
    public function __construct(
        private ProrationService $proration,
        private TaxCalculationService $taxService,
    ) {}

    public function initiate(
        ClientProduct $service,
        Product $target,
        int $newPeriod,
        array $selections = []
    ): PlanChangeResult {
        // Reject when the service was already upgraded/downgraded in the
        // current billing period. The lock is set after a plan-change is
        // applied and cleared when the next renewal invoice is paid (see
        // ProcessProductChangeJob + HandleInvoiceStatusChange).
        if ($service->upgrade_locked) {
            return new PlanChangeResult(
                outcome:   'locked_until_renewal',
                direction: 'locked',
                diff:      0.0,
            );
        }

        $preview = $this->proration->preview($service, $target, $newPeriod, $selections);

        // No-op guard — same product, same period, and no add/drop/change of
        // options or custom fields. Without this, picking the current plan
        // with no edits would still dispatch ProcessProductChangeJob and
        // create churn (and potentially a zero-diff invoice).
        if ($this->isNoOpChange($service, $target, $newPeriod, $preview, $selections)) {
            return new PlanChangeResult(
                outcome:   'no_change',
                direction: 'no_change',
                diff:      0.0,
            );
        }

        return DB::transaction(function () use ($service, $target, $newPeriod, $preview, $selections) {
            // Step 1: Auto-cancel any existing pending plan-change invoice (Task 12 will add tests)
            $cancelOutcome = $this->cancelExistingPending($service);

            if ($cancelOutcome === 'aborted') {
                return new PlanChangeResult(
                    outcome:   'aborted_paid_in_progress',
                    direction: $preview['direction'],
                    diff:      (float) $preview['diff'],
                );
            }
            $cancelledInvoiceId = $cancelOutcome ?: null;

            // Step 2: Branch by diff sign
            if ($preview['diff'] > 0) {
                $invoice = $this->createUpgradeInvoice($service, $target, $newPeriod, $preview, $selections);

                // PRC-622 — 'product_change_invoice_created' client maili
                // (config/communication_events.php eşlemesi).
                app(\App\Core\Hook\HookBridge::class)->fireAction('ProductChangeInvoiceCreated', [
                    'invoiceid' => $invoice->id,
                    'invoice_id' => $invoice->id,
                    'client_id' => $invoice->client_id,
                    'service_id' => $service->id,
                    'old_product' => $service->product->name ?? '',
                    'new_product' => $target->name ?? '',
                    'amount' => number_format((float) $preview['diff'], 2),
                ]);

                return new PlanChangeResult(
                    outcome:            'pending_payment',
                    direction:          'upgrade',
                    diff:               (float) $preview['diff'],
                    invoiceId:          $invoice->id,
                    cancelledInvoiceId: $cancelledInvoiceId,
                );
            }

            // diff <= 0 → directly dispatch (Task 13 tests this)
            $skipRefund = $preview['same_product']
                && ! ($service->product->same_product_downgrade_refund ?? false);

            // Persist pending_options so ProcessProductChangeJob can read them.
            $service->update([
                'pending_options' => $this->buildPendingOptionsPayload($preview, $selections),
            ]);

            ProcessProductChangeJob::dispatch(
                clientProductId: $service->id,
                targetProductId: $target->id,
                newPeriod:       $newPeriod,
                skipRefund:      $skipRefund,
                // PRC-1095/PRC-1097 — downgrade credit = abs of the negative
                // proration diff. The job credits the ClientBalance ledger
                // (balance_only), reachable now that a real amount is passed.
                refundAmount:    $skipRefund ? 0.0 : abs((float) $preview['diff']),
            );

            return new PlanChangeResult(
                outcome:            'applied',
                direction:          $preview['direction'],
                diff:               (float) $preview['diff'],
                cancelledInvoiceId: $cancelledInvoiceId,
            );
        });
    }

    /**
     * @return int|string  Cancelled invoice id, 0 if none, 'aborted' if paid-in-progress detected.
     *
     * Two distinct cases must be handled here:
     *  1. An UNPAID upgrade invoice already exists for this service →
     *     cancel it so we can issue a fresh one for the current selection.
     *  2. A PAID upgrade invoice exists but the corresponding job
     *     hasn't applied the change yet → abort, otherwise we'd race the
     *     in-flight upgrade.  ProcessProductChangeJob clears
     *     `pending_product_id` when it finishes, so a stale `pending_*`
     *     is the canonical "paid but not yet applied" signal. Historical
     *     paid upgrades (pending_* already cleared) must NOT block the
     *     new request.
     */
    private function cancelExistingPending(ClientProduct $service): int|string
    {
        $pendingUnpaid = InvoiceItem::where('related_service_id', $service->id)
            ->where('upgrade_type', 'upgrade')
            ->whereHas('invoice', fn ($q) => $q->where('status', 'unpaid'))
            ->lockForUpdate()
            ->first();

        if ($pendingUnpaid) {
            $invoice = $pendingUnpaid->invoice;
            $invoice->status = Invoice::STATUS_CANCELLED;
            $invoice->save();

            // PRC-622 — hook-only (review F2): eşleme yok; client zaten yukarıdaki
            // cancelled geçişiyle standart 'Invoice Cancelled' mailini alıyor.
            // Hook addon/3rd-party dinleyiciler için fire edilmeye devam eder.
            app(\App\Core\Hook\HookBridge::class)->fireAction('UpgradeOrderCancelled', [
                'invoiceid' => $invoice->id,
                'invoice_id' => $invoice->id,
                'client_id' => $invoice->client_id,
                'service_id' => $service->id,
            ]);

            return $invoice->id;
        }

        // Only treat a paid invoice as "in-flight" when the matching job
        // hasn't yet processed it (pending_* still set on the service).
        if ($service->pending_product_id !== null) {
            $paidInFlight = InvoiceItem::where('related_service_id', $service->id)
                ->where('upgrade_type', 'upgrade')
                ->whereHas('invoice', fn ($q) => $q->where('status', 'paid'))
                ->exists();

            if ($paidInFlight) {
                return 'aborted';
            }
        }

        return 0;
    }

    /**
     * True when the requested change resolves to the exact current state —
     * same product, same period, no option add/drop/change, and no custom
     * field add/drop. Used by initiate() to short-circuit before creating
     * any invoice or dispatching the apply-job.
     */
    private function isNoOpChange(
        ClientProduct $service,
        Product $target,
        int $newPeriod,
        array $preview,
        array $selections
    ): bool {
        if ((int) $service->product_id !== (int) $target->id) {
            return false;
        }
        $currentMonths = ProrationService::billingCycleToMonths($service->billingcycle ?? 'monthly');
        if ($currentMonths !== $newPeriod) {
            return false;
        }

        // Run the same translation the invoice path uses so the test mirrors
        // exactly what would have been written to pending_options.
        $payload = $this->buildPendingOptionsPayload($preview, $selections);

        return empty($payload['add'])
            && empty($payload['drop'])
            && empty($payload['change'])
            && empty($payload['custom_fields_add'])
            && empty($payload['custom_fields_drop']);
    }

    private function generateInvoiceNumber(): string
    {
        return app(InvoiceNumberService::class)->generate();
    }

    private function createUpgradeInvoice(
        ClientProduct $service,
        Product $target,
        int $newPeriod,
        array $preview,
        array $selections = []
    ): Invoice {
        $currencyId = $service->client->currency_id ?? 1;
        $subtotal   = $preview['diff'];

        // Calculate tax once for the upgrade amount.
        // productType is derived from the target product's type so server/ssl
        // products participate in the right taxable_types_* setting; falls
        // back to 'service' for non-domain types (TaxCalculationService treats
        // 'hosting' and 'service' identically).
        $productType = $this->resolveTaxProductType($target);
        $taxInfo   = $this->taxService->calculateTax(
            client:      $service->client,
            productType: $productType,
            product:     $target,
            countryCode: $service->client->country?->a2_iso,
        );
        $taxRate   = ($taxInfo['taxable'] && $taxInfo['rate'] > 0) ? (float) $taxInfo['rate'] : 0.0;
        $taxAmount = $taxRate > 0 ? round($subtotal * ($taxRate / 100), 2) : 0.0;
        $total     = round($subtotal + $taxAmount, 2);

        // Resolve a default payment method so PaymentController can display the page.
        $paymentMethod = $this->resolveDefaultPaymentMethod($service);

        // PRC-195: server-computed proration invoice; unsealed() block silences the
        // SealsAdminAttributes audit warning for this trusted plan-change flow.
        $invoice = Invoice::unsealed(fn () => Invoice::create([
            'client_id'      => $service->client_id,
            'currency_id'    => $currencyId,
            'invoice_number' => $this->generateInvoiceNumber(),
            'subtotal'       => $subtotal,
            'tax_rate'       => $taxRate,
            'tax_amount'     => $taxAmount,
            'total_amount'   => $total,
            'status'         => Invoice::STATUS_UNPAID,
            'due_date'       => now()->addDays(7),
            'issued_at'      => now(),
            'payment_method' => $paymentMethod,
        ]));

        InvoiceItem::create([
            'invoice_id'          => $invoice->id,
            'type'                => 'product',
            'upgrade_type'        => 'upgrade',
            'description'         => $this->buildInvoiceDescription($preview, $service, $target, $newPeriod),
            'quantity'            => 1,
            'unit_price'          => $subtotal,
            'total'               => $subtotal,
            'tax_rate'            => $taxRate,
            'tax_amount'          => $taxAmount,
            'line_total'          => $total,
            'product_id'          => $target->id,
            'original_product_id' => $service->product_id,
            'related_service_id'  => $service->id,
            'period'              => $newPeriod,
            'config_options'      => $this->buildTargetOptionSet($preview, $selections, $service),
            'config_options_total' => round(collect($preview['lines'] ?? [])
                ->whereIn('kind', ['option_carry', 'option_add'])
                ->sum('target_period_total'), 2),
        ]);

        // Stamp the pending change on the service so HandleInvoiceStatusChange::triggerUpgrades
        // can pick it up after the invoice is paid. Without this, the listener finds null and skips.
        // PRC-182: pending_* are sealed admin-only attrs; unsealed() since values
        // here are derived server-side (preview snapshot + ProrationService).
        \App\Models\ClientProduct::unsealed(fn () => $service->update([
            'pending_product_id'   => $target->id,
            'pending_billingcycle' => ProrationService::monthsToBillingCycle($newPeriod),
            'pending_options'      => $this->buildPendingOptionsPayload($preview, $selections),
        ]));

        return $invoice;
    }

    /**
     * Map product->type to the productType bucket TaxCalculationService understands.
     * Non-domain products fall under 'service' (which shares the 'hosting' taxable setting +
     * tax_excluded flag handling).
     */
    private function resolveTaxProductType(Product $product): string
    {
        $type = strtolower((string) ($product->type ?? ''));
        return $type === 'domain' ? 'domain' : 'service';
    }

    /**
     * Determine the payment method to stamp on the upgrade invoice.
     * Priority: client's most-recent paid invoice gateway → first active gateway.
     */
    private function resolveDefaultPaymentMethod(ClientProduct $service): ?string
    {
        $lastPaid = Invoice::where('client_id', $service->client_id)
            ->where('status', Invoice::STATUS_PAID)
            ->whereNotNull('payment_method')
            ->latest('id')
            ->value('payment_method');

        if ($lastPaid) {
            return $lastPaid;
        }

        $allGateways = app(\App\Services\PaymentManager::class)->getAvailableGateways();
        foreach ($allGateways as $name => $info) {
            if ($info['active'] ?? false) {
                return $name;
            }
        }

        return null;
    }

    /**
     * Build pending_options JSON for ProcessProductChangeJob to apply after the
     * invoice is paid. Lines from preview tell us add/drop; selections carry
     * custom-field changes.
     */
    private function buildPendingOptionsPayload(array $preview, array $selections): array
    {
        $add = [];
        $drop = [];
        $change = [];
        foreach ($preview['lines'] ?? [] as $line) {
            if (($line['kind'] ?? null) === 'option_drop') {
                $drop[] = $line['option_id'];
            }
            if (($line['kind'] ?? null) === 'option_add') {
                $add[] = ['option_id' => $line['option_id'], 'value_id' => $line['value_id'] ?? null];
            }
            if (($line['kind'] ?? null) === 'option_change') {
                $change[] = [
                    'option_id'   => $line['option_id'],
                    'to_value_id' => $line['to_value_id'] ?? null,
                ];
            }
        }
        return [
            'add'                => $add,
            'drop'               => $drop,
            'change'             => $change,
            'custom_fields_add'  => $selections['custom_fields'] ?? [],
            'custom_fields_drop' => [],
        ];
    }

    /**
     * Build the multi-line description text for the upgrade invoice item.
     * Each preview line becomes a bullet with its own diff.
     */
    private function buildInvoiceDescription(array $preview, ClientProduct $service, Product $target, int $newPeriod): string
    {
        // Render in the client's preferred language so the stored invoice text
        // matches what they'll see in their portal / emails. Falls back to the
        // current app locale (admin context) when the client has no language.
        $locale = $this->resolveClientLocale($service);
        $tr = fn (string $key, array $repl = []) => \Illuminate\Support\Facades\Lang::get($key, $repl, $locale);

        $cycleFrom = $service->billingcycle;
        $cycleTo   = ProrationService::monthsToBillingCycle($newPeriod);
        $cycleLabel = function (string $cycle) use ($tr): string {
            $key = 'store.service_billing_cycle_' . str_replace('-', '_', $cycle);
            $label = $tr($key);
            // Lang::get returns the key when missing — fall back to the raw cycle name.
            return $label === $key ? $cycle : $label;
        };

        $sourceProductName = $service->product?->getTranslation('name', $locale) ?? $service->product?->name ?? '';

        $lines = [
            $tr('store/upgrade.invoice_desc_title', ['product' => $sourceProductName]),
            '  ' . $tr('store/upgrade.invoice_desc_proration', [
                'from' => $cycleLabel($cycleFrom),
                'to'   => $cycleLabel($cycleTo),
                'days' => $preview['remaining_days'],
            ]),
            '',
        ];

        foreach ($preview['lines'] ?? [] as $line) {
            $sign = $line['line_diff'] >= 0 ? '+' : '';

            // Resolve label in the client's locale. For the product line use
            // the target product's translated name; for option lines look up
            // the option's translation by id.
            $localizedLabel = $this->localizedLineLabel($line, $target, $locale);

            $note = match ($line['kind']) {
                'option_carry' => '(' . $tr('store/upgrade.line_kind_option_carry') . ')',
                'option_drop'  => '(' . $tr(! empty($line['refunded'])
                    ? 'store/upgrade.line_kind_option_drop_refunded'
                    : 'store/upgrade.line_kind_option_drop_no_refund') . ')',
                'option_add'   => '(' . $tr('store/upgrade.line_kind_option_add') . ')',
                'option_change' => '(' . $this->localizedValueName($line['from_value_id'] ?? null, $line['from_value_label'] ?? '?', $locale)
                    . ' → ' . $this->localizedValueName($line['to_value_id'] ?? null, $line['to_value_label'] ?? '?', $locale) . ')',
                default        => '',
            };
            $label = trim($localizedLabel . ' ' . $note);
            $lines[] = sprintf('  • %-30s %s%.2f', $label, $sign, $line['line_diff']);
        }

        return implode("\n", $lines);
    }

    private function resolveClientLocale(ClientProduct $service): string
    {
        return $service->client?->language?->a2_iso ?? app()->getLocale();
    }

    /**
     * Translate a preview line's label using the model's HasTranslations trait.
     * Product line uses the target product's translated name; option lines
     * look up ProductConfigOption by option_id. Falls back to the raw label.
     */
    private function localizedLineLabel(array $line, Product $target, string $locale): string
    {
        $kind = $line['kind'] ?? null;
        $raw  = (string) ($line['label'] ?? '-');

        if ($kind === 'product') {
            return $target->getTranslation('name', $locale) ?? $raw;
        }

        $optionId = $line['option_id'] ?? null;
        if ($optionId) {
            $opt = \App\Models\ProductConfigOption::find($optionId);
            if ($opt) {
                return $opt->getTranslation('name', $locale) ?? $raw;
            }
        }

        return $raw;
    }

    private function localizedValueName(?int $valueId, string $fallback, string $locale): string
    {
        if (! $valueId) {
            return $fallback;
        }
        $val = \App\Models\ProductConfigOptionValue::find($valueId);
        return $val?->getTranslation('value', $locale) ?? $fallback;
    }

    /**
     * Translate an option line's label (without product-line handling).
     */
    private function localizedOptionLabel(array $line, string $locale): string
    {
        $raw = (string) ($line['label'] ?? '');
        $optionId = $line['option_id'] ?? null;
        if ($optionId) {
            $opt = \App\Models\ProductConfigOption::find($optionId);
            if ($opt) {
                return $opt->getTranslation('name', $locale) ?? $raw;
            }
        }
        return $raw;
    }

    /**
     * Final option set the service will have after the change applies — used
     * for the invoice item's display snapshot. Format mirrors the basket /
     * order pipeline: each entry is {name: "Option - Value", price: float}
     * which InvoiceItem::getFormattedConfigOptionsAttribute() splits on " - "
     * to render. Writing only {option_id, value_id} (the apply-time format)
     * leaves the renderer with no labels and produces empty cog rows on the
     * invoice details page.
     */
    private function buildTargetOptionSet(array $preview, array $selections, ?ClientProduct $service = null): array
    {
        $locale = $service ? $this->resolveClientLocale($service) : app()->getLocale();

        $set = [];
        foreach ($preview['lines'] ?? [] as $line) {
            $kind = $line['kind'] ?? null;
            if (in_array($kind, ['option_carry', 'option_add'], true)) {
                $set[] = [
                    'name'  => trim($this->localizedOptionLabel($line, $locale) . ' - ' . $this->localizedValueName($line['value_id'] ?? null, $line['value_label'] ?? '', $locale), ' -'),
                    'price' => (float) ($line['target_period_total'] ?? 0),
                ];
            }
            if ($kind === 'option_change') {
                $set[] = [
                    'name'  => trim($this->localizedOptionLabel($line, $locale) . ' - ' . $this->localizedValueName($line['to_value_id'] ?? null, $line['to_value_label'] ?? '', $locale), ' -'),
                    'price' => (float) ($line['target_period_total'] ?? 0),
                ];
            }
        }
        return $set;
    }
}
