<?php

namespace Modules\ProductCompat\Entities\Services;

use App\Product;
use Modules\ProductCompat\Entities\PcChangeReport;
use Modules\ProductCompat\Entities\PcDataCandidate;
use Modules\ProductCompat\Entities\PcDeviceCompat;
use Modules\ProductCompat\Entities\PcDeviceModel;
use Modules\ProductCompat\Entities\PcPartNumber;
use Modules\ProductCompat\Entities\PcProductBarcode;

/**
 * The "aggressive" per-product enrichment run. Unlike the classic first-win
 * enrichProduct(), this gathers data from ALL providers plus the AI pass, merges
 * and corroborates it, applies the structured identifiers (add-only, needs_review
 * — same semantics as the classic pipeline), and lands the richer fields
 * (name / description / brand / specs / images / corrections) as review-first
 * pc_data_candidates. Nothing auto-applies to the product itself.
 */
class AggressiveEnrichmentService
{
    protected ProductCompatService $service;
    protected EnrichmentManager $manager;
    protected SearchTextCollector $collector;
    protected AiExtractionService $extractor;

    public function __construct(
        ProductCompatService $service,
        EnrichmentManager $manager,
        ?SearchTextCollector $collector = null,
        ?AiExtractionService $extractor = null
    ) {
        $this->service   = $service;
        $this->manager   = $manager;
        $this->collector = $collector ?? new SearchTextCollector();
        $this->extractor = $extractor ?? new AiExtractionService();
    }

    /**
     * Run the aggressive enrichment for one product.
     *
     * @return array compact per-run summary (counts + statuses)
     */
    public function run(Product $product, string $runId): array
    {
        $businessId = (int) $product->business_id;
        $productId  = (int) $product->id;

        // 1) Snapshot existing data.
        $snapshot = $this->snapshot($product);

        // 2) Collect from ALL providers (each logged; no apply, no first-win).
        $results = $this->manager->collectAll($product);

        // 3) Merge/dedupe structured candidates across providers with a
        //    corroboration bonus, then apply in one pass.
        [$merged, $topSource] = $this->mergeStructured($results);

        $applied = $this->service->applyEnrichmentCandidates(
            $businessId, $productId, $merged, 'aggr:' . $topSource
        );

        // 4) AI pass — collector (WITH provider results) + extraction.
        $aiStatus  = 'skipped';
        $aiError   = null;
        $aiCandCount = 0;
        $candidatesWritten = 0;

        if ($this->extractor->available($businessId)) {
            $sources = $this->collector->collect($product, $results);
            $extract = $this->extractor->extract($product, $sources, $snapshot);

            if (! empty($extract['ok'])) {
                $data = $extract['data'] ?? [];

                // AI structured candidates (cap-60 already applied by extractor)
                // flow through the SAME add-only apply path.
                $aiStructured = [
                    'part_numbers'  => $data['part_numbers']  ?? [],
                    'device_models' => $data['device_models'] ?? [],
                    'barcodes'      => $data['barcodes']      ?? [],
                ];
                $aiCandCount = count($aiStructured['part_numbers'])
                    + count($aiStructured['device_models'])
                    + count($aiStructured['barcodes']);

                $aiApplied = $this->service->applyEnrichmentCandidates(
                    $businessId, $productId, $aiStructured, 'aggr:ai'
                );
                $applied['parts']    += $aiApplied['parts'];
                $applied['devices']  += $aiApplied['devices'];
                $applied['barcodes'] += $aiApplied['barcodes'] ?? 0;

                // 5–6) Rich fields + corrections → review-first candidates.
                $candidatesWritten = $this->writeRichCandidates(
                    $product, $data, $runId, (string) ($extract['model_used'] ?? '')
                );

                // Compatible-product refs → pending candidates for the later
                // cross-product matching phase (productcompat:match-compatibility).
                $candidatesWritten += $this->writeCompatibleCandidates(
                    $product, $data['compatible_products'] ?? [], $runId, (string) ($extract['model_used'] ?? '')
                );

                $aiStatus = 'done';
            } else {
                $aiStatus = 'error';
                $aiError  = (string) ($extract['error'] ?? 'ai_error');
            }
        }

        // Also fold the classic Icecat rich data (source 'icecat', conf 80) into
        // candidates when the AI pass didn't already cover the same values.
        $candidatesWritten += $this->writeIcecatRich($product, $results, $runId);

        // Auto-collapse exact-duplicate part numbers / barcodes on this product.
        $this->service->dedupeIdentifiers($businessId, $productId);

        // 7) Audit row.
        $summary = [
            'parts'         => $applied['parts'],
            'devices'       => $applied['devices'],
            'barcodes'      => $applied['barcodes'] ?? 0,
            'ai'            => $aiStatus,
            'ai_candidates' => $aiCandCount,
            'candidates'    => $candidatesWritten,
            'top_source'    => $topSource,
        ];
        if ($aiError !== null) {
            $summary['ai_error'] = $aiError;
        }

        PcChangeReport::create([
            'business_id'  => $businessId,
            'product_id'   => $productId,
            'run_id'       => $runId,
            'action'       => 'ai_enrich',
            'old_data'     => $snapshot,
            'new_data'     => $summary,
            'removed_data' => null,
            'missing_data' => null,
        ]);

        $this->service->ensureStatus($businessId, $productId);

        return $summary;
    }

    /**
     * Merge structured candidates from every provider result. Dedupe by
     * normalized value, awarding a corroboration bonus (+12 per extra source,
     * capped at 95) and tracking the best (highest-confidence) source. Returns
     * [candidates, topSourceKey].
     *
     * @param  array<string, \Modules\ProductCompat\Entities\Enrichment\EnrichmentResult>  $results
     * @return array{0:array, 1:string}
     */
    protected function mergeStructured(array $results): array
    {
        $parts    = [];
        $devices  = [];
        $barcodes = [];
        $sourceScore = [];

        foreach ($results as $key => $result) {
            if (! $result->ok) {
                continue;
            }
            $sourceScore[$key] = $sourceScore[$key] ?? 0;

            foreach ($result->candidates['part_numbers'] as $pn) {
                $value = trim((string) ($pn['value'] ?? ''));
                if ($value === '') {
                    continue;
                }
                $norm = PcPartNumber::normalize($value);
                if ($norm === '') {
                    continue;
                }
                $this->corroborate($parts, $norm, $pn, $key);
                $sourceScore[$key]++;
            }

            foreach ($result->candidates['device_models'] as $dm) {
                $model = trim((string) ($dm['model'] ?? ($dm['label'] ?? '')));
                if ($model === '') {
                    continue;
                }
                $norm = strtolower(preg_replace('/[^A-Za-z0-9]/', '', $model)) . '|' . strtolower(trim((string) ($dm['brand'] ?? '')));
                $this->corroborate($devices, $norm, $dm, $key);
                $sourceScore[$key]++;
            }

            foreach ($result->candidates['barcodes'] as $bc) {
                $digits = preg_replace('/\D/', '', (string) ($bc['value'] ?? ''));
                if ($digits === '') {
                    continue;
                }
                $this->corroborate($barcodes, $digits, $bc, $key);
                $sourceScore[$key]++;
            }
        }

        $topSource = 'none';
        if ($sourceScore) {
            arsort($sourceScore);
            $topSource = (string) array_key_first($sourceScore);
        }

        return [[
            'part_numbers'  => array_values($parts),
            'device_models' => array_values($devices),
            'barcodes'      => array_values($barcodes),
        ], $topSource];
    }

    /**
     * Fold one candidate into the merge bucket keyed by $normKey. On the first
     * sighting it's stored as-is; each additional sighting adds a +12
     * corroboration bonus (capped at 95) and keeps the richest field set +
     * best source.
     */
    protected function corroborate(array &$bucket, string $normKey, array $cand, string $sourceKey): void
    {
        $conf = (int) ($cand['confidence'] ?? 70);

        if (! isset($bucket[$normKey])) {
            $cand['confidence'] = min(95, max(0, $conf));
            $cand['_best_source'] = $sourceKey;
            $cand['_best_conf']   = $conf;
            $bucket[$normKey] = $cand;

            return;
        }

        $row = $bucket[$normKey];
        // Corroboration bonus for the extra source.
        $row['confidence'] = min(95, (int) $row['confidence'] + 12);
        // Track the highest-confidence source seen for this value.
        if ($conf > (int) ($row['_best_conf'] ?? 0)) {
            $row['_best_source'] = $sourceKey;
            $row['_best_conf']   = $conf;
            // Prefer the richer source's field values.
            foreach (['type', 'brand', 'series', 'model'] as $f) {
                if (! empty($cand[$f])) {
                    $row[$f] = $cand[$f];
                }
            }
        }
        $bucket[$normKey] = $row;
    }

    /**
     * Write the AI's rich product fields + corrections as review-first
     * pc_data_candidates. Never resurrects a rejected/applied row; only creates
     * when no row exists for (product_id, field, value_hash).
     *
     * @return int number of candidate rows created
     */
    protected function writeRichCandidates(Product $product, array $data, string $runId, string $modelUsed): int
    {
        $businessId = (int) $product->business_id;
        $productId  = (int) $product->id;
        $conf       = (int) config('productcompat.ai.ai_only_confidence_cap', 60);

        $created = 0;
        $p = (array) ($data['product'] ?? []);

        // ---- name — only if materially different from the current name. ----
        $name = trim((string) ($p['name'] ?? ''));
        if ($name !== '' && $this->materiallyDifferent($name, (string) $product->name)) {
            $created += $this->upsertCandidate($businessId, $productId, 'name', $name, [], 'ai', $conf, $runId, $modelUsed);
        }

        // ---- brand — only if it differs from the current brand name. ----
        $brand = trim((string) ($p['brand'] ?? ''));
        $currentBrand = trim((string) optional($product->brand)->name);
        if ($brand !== '' && strcasecmp($brand, $currentBrand) !== 0) {
            $created += $this->upsertCandidate($businessId, $productId, 'brand', $brand, [], 'ai', $conf, $runId, $modelUsed);
        }

        // ---- description ----
        $desc = trim((string) ($p['description'] ?? ''));
        if ($desc !== '') {
            $created += $this->upsertCandidate($businessId, $productId, 'description', $desc, [], 'ai', $conf, $runId, $modelUsed);
        }

        // ---- specs — value = json_encode of the list. ----
        $specs = (array) ($p['specs'] ?? []);
        if ($specs) {
            $created += $this->upsertCandidate(
                $businessId, $productId, 'specs', json_encode($specs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
                ['rows' => count($specs)], 'ai', $conf, $runId, $modelUsed
            );
        }

        // ---- image_url — one row per URL. ----
        foreach ((array) ($p['image_urls'] ?? []) as $url) {
            $url = trim((string) $url);
            if ($url === '') {
                continue;
            }
            $created += $this->upsertCandidate($businessId, $productId, 'image_url', $url, [], 'ai', $conf, $runId, $modelUsed);
        }

        // ---- corrections — field='correction', extra payload; skip verified targets. ----
        foreach ((array) ($data['corrections'] ?? []) as $c) {
            $kind    = (string) ($c['kind'] ?? '');
            $current = (string) ($c['current_value'] ?? '');
            if ($kind === '' || $current === '') {
                continue;
            }
            $target = $this->resolveCorrectionTarget($productId, $kind, $current);
            if ($target === 'verified') {
                continue; // never propose correcting a human-verified value
            }
            $extra = [
                'kind'            => $kind,
                'current_value'   => $current,
                'suggested_value' => (string) ($c['suggested_value'] ?? ''),
                'problem'         => (string) ($c['problem'] ?? ''),
                'target_table'    => $target['table'] ?? null,
                'target_id'       => $target['id'] ?? null,
            ];
            $value = $kind . ': ' . $current . ' -> ' . (string) ($c['suggested_value'] ?? '');
            $created += $this->upsertCandidate($businessId, $productId, 'correction', $value, $extra, 'ai', $conf, $runId, $modelUsed);
        }

        return $created;
    }

    /**
     * Fold Icecat's rich payload (description / brand / images) into candidates,
     * source 'icecat' at confidence 80. Same add-only, never-resurrect rules.
     *
     * @param  array<string, \Modules\ProductCompat\Entities\Enrichment\EnrichmentResult>  $results
     * @return int number of candidate rows created
     */
    protected function writeIcecatRich(Product $product, array $results, string $runId): int
    {
        $result = $results['icecat'] ?? null;
        if (! $result || ! $result->ok || empty($result->rich)) {
            return 0;
        }

        $businessId = (int) $product->business_id;
        $productId  = (int) $product->id;
        $rich       = $result->rich;
        $created     = 0;

        $desc = trim((string) ($rich['description'] ?? ''));
        if ($desc !== '') {
            $created += $this->upsertCandidate($businessId, $productId, 'description', mb_substr($desc, 0, 1200), [], 'icecat', 80, $runId, '');
        }

        $brand = trim((string) ($rich['brand'] ?? ''));
        $currentBrand = trim((string) optional($product->brand)->name);
        if ($brand !== '' && strcasecmp($brand, $currentBrand) !== 0) {
            $created += $this->upsertCandidate($businessId, $productId, 'brand', $brand, [], 'icecat', 80, $runId, '');
        }

        foreach ((array) ($rich['image_urls'] ?? []) as $url) {
            $url = trim((string) $url);
            if ($url === '') {
                continue;
            }
            $created += $this->upsertCandidate($businessId, $productId, 'image_url', $url, [], 'icecat', 80, $runId, '');
        }

        return $created;
    }

    /**
     * updateOrCreate a candidate on (product_id, field, value_hash) but ONLY
     * create when no row exists — an existing pending/approved/rejected/applied
     * row is left exactly as it is (rejected/applied never flip back to pending).
     *
     * @return int 1 if a new row was created, 0 otherwise
     */
    protected function upsertCandidate(int $businessId, int $productId, string $field, string $value, array $extra, string $source, int $confidence, string $runId, string $modelUsed): int
    {
        $hash = PcDataCandidate::makeHash($field, $value);

        $exists = PcDataCandidate::where('product_id', $productId)
            ->where('field', $field)
            ->where('value_hash', $hash)
            ->exists();
        if ($exists) {
            return 0;
        }

        if ($modelUsed !== '') {
            $extra['model_used'] = $modelUsed;
        }

        PcDataCandidate::create([
            'business_id' => $businessId,
            'product_id'  => $productId,
            'field'       => $field,
            'value'       => $value,
            'value_hash'  => $hash,
            'extra'       => $extra ?: null,
            'source'      => mb_substr($source, 0, 64),
            'confidence'  => max(0, min(100, $confidence)),
            'status'      => 'pending',
            'run_id'      => mb_substr($runId, 0, 40),
        ]);

        return 1;
    }

    /**
     * Store AI-suggested compatible-product refs as pending `compatible`
     * candidates (extra = the ref). They are resolved to real product ids later
     * by productcompat:match-compatibility, once all products + part numbers
     * exist. Deduped by the ref's match_by+value.
     */
    protected function writeCompatibleCandidates(Product $product, array $refs, string $runId, string $modelUsed): int
    {
        $n = 0;
        foreach ($refs as $ref) {
            $matchBy = (string) ($ref['match_by'] ?? '');
            $value   = (string) ($ref['value'] ?? '');
            if ($matchBy === '' || $value === '') {
                continue;
            }
            $extra = [
                'match_by' => $matchBy,
                'value'    => $value,
                'relation' => (string) ($ref['relation'] ?? 'compatible'),
                'note'     => (string) ($ref['note'] ?? ''),
                'linked'   => false,
            ];
            $n += $this->upsertCandidate(
                (int) $product->business_id, (int) $product->id,
                'compatible', $matchBy . ':' . $value, $extra, 'ai', 60, $runId, $modelUsed
            );
        }

        return $n;
    }

    /**
     * Resolve a correction's target pc_* row. Returns 'verified' when the target
     * has been human-verified (so the correction is suppressed), otherwise a
     * ['table'=>, 'id'=>] locator or an empty array when no target row is found.
     *
     * @return string|array
     */
    protected function resolveCorrectionTarget(int $productId, string $kind, string $current)
    {
        // Delegate to the shared resolver on ProductCompatService (same logic the
        // offline importer uses).
        return $this->service->resolveCorrectionTarget($productId, $kind, $current);
    }

    /**
     * "Materially different" name test: levenshtein distance > 5 OR length delta
     * > 10, case-insensitive. Prevents trivial re-wordings from being proposed.
     */
    protected function materiallyDifferent(string $candidate, string $current): bool
    {
        $a = mb_strtolower(trim($candidate));
        $b = mb_strtolower(trim($current));
        if ($a === $b) {
            return false;
        }
        if ($b === '') {
            return true;
        }
        if (abs(mb_strlen($a) - mb_strlen($b)) > 10) {
            return true;
        }

        // levenshtein is byte-based; guard against very long strings it rejects.
        $la = substr($a, 0, 255);
        $lb = substr($b, 0, 255);

        return levenshtein($la, $lb) > 5;
    }

    /** Existing-data snapshot recorded before the run. */
    protected function snapshot(Product $product): array
    {
        return [
            'name'          => (string) $product->name,
            'sku'           => (string) $product->sku,
            'brand'         => trim((string) optional($product->brand)->name),
            'description'   => trim(strip_tags((string) $product->product_description)),
            'part_numbers'  => PcPartNumber::where('product_id', $product->id)->pluck('part_number')->all(),
            'barcodes'      => PcProductBarcode::where('product_id', $product->id)->pluck('barcode')->all(),
            'device_models' => PcDeviceCompat::where('product_id', $product->id)
                ->join('pc_device_models', 'pc_device_compat.device_model_id', '=', 'pc_device_models.id')
                ->pluck('pc_device_models.model')->all(),
        ];
    }
}
