<?php

namespace Modules\ProductCompat\Entities\Services;

use App\Product;
use Illuminate\Support\Facades\Schema;
use Modules\ProductCompat\Entities\PcPartNumber;
use Modules\ProductCompat\Entities\Services\Settings;
use Modules\ProductCompat\Entities\Services\ProductContext;

/**
 * The AI extraction pass. Given a product, a set of numbered SOURCE blocks and
 * the product's existing structured data, it asks the shared AiCore engine to
 * pull corrected identifiers, barcodes, description, brand, specs, image URLs
 * and data corrections — then aggressively VALIDATES the model's answer against
 * the source texts before trusting anything.
 *
 * AiCore is consumed ONLY through class_exists-guarded references so ProductCompat
 * still loads and runs (AI pass simply "skipped") when AiCore is absent or its
 * table hasn't been migrated. No AI keys/models are duplicated here — the engine's
 * own failover chain and budget guard apply automatically.
 */
class AiExtractionService
{
    /** Whether the AI pass can run for a given business. Never throws. */
    public function available(int $businessId): bool
    {
        try {
            if (! class_exists(\Modules\AiCore\Entities\Services\AiClient::class)
                || ! class_exists(\Modules\AiCore\Entities\AiProvider::class)) {
                return false;
            }
            if (! Schema::hasTable('ai_providers')) {
                return false;
            }

            return \Modules\AiCore\Entities\AiProvider::forBusiness($businessId)->isConfigured();
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Live health check: is the AI actually responding (not just configured)?
     * Makes one tiny call through the failover chain — used as a drain
     * precondition so we don't churn the queue into "failed" while the provider
     * is down. Never throws.
     */
    public function ping(int $businessId): bool
    {
        try {
            if (! $this->available($businessId)) {
                return false;
            }
            $provider = \Modules\AiCore\Entities\AiProvider::forBusiness($businessId);
            $res = (new \Modules\AiCore\Entities\Services\AiClient())->chat(
                $provider,
                'You are a connectivity probe. Reply with exactly: OK',
                'OK',
                ['module' => 'ProductCompat', 'feature' => 'health']
            );

            return ! empty($res['ok']);
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Run one extraction call for a product.
     *
     * @param  array  $sources   numbered blocks from SearchTextCollector::collect()
     * @param  array  $existing  current structured data snapshot (part_numbers, device_models, barcodes, name, brand, description)
     * @return array{ok:bool, error?:string, data?:array, model_used?:?string}
     */
    public function extract(Product $product, array $sources, array $existing): array
    {
        if (! $this->available((int) $product->business_id)) {
            return ['ok' => false, 'error' => 'ai_unavailable'];
        }

        $provider = \Modules\AiCore\Entities\AiProvider::forBusiness((int) $product->business_id);

        // Knowledge mode: when on, the model may fill DESCRIPTIVE fields + device
        // compatibility from its own training knowledge (identifiers/URLs still
        // require verbatim sources). Lets well-known products enrich even with no
        // web-search source configured.
        $useKnowledge = Settings::bool('ai_use_model_knowledge', false);

        $system = $this->systemPrompt($useKnowledge);
        $user   = $this->userPrompt($product, $sources, $existing, $useKnowledge);

        // Give the AI pass a slightly longer runtime than the scraping providers.
        config(['aicore.timeout' => (int) config('productcompat.ai.timeout', 45)]);

        $client = new \Modules\AiCore\Entities\Services\AiClient();
        $res = $client->chat($provider, $system, $user, [
            'module'  => 'ProductCompat',
            'feature' => 'aggressive_enrich',
        ]);

        if (empty($res['ok'])) {
            // Propagate budget_exceeded verbatim so the queue can halt on it.
            return ['ok' => false, 'error' => (string) ($res['error'] ?? 'ai_error')];
        }

        $parsed = $this->parseJson((string) ($res['text'] ?? ''));
        if ($parsed === null) {
            return ['ok' => false, 'error' => 'unparseable'];
        }

        $data = $this->validate($parsed, $product, $sources, $existing, $useKnowledge);

        return [
            'ok'         => true,
            'data'       => $data,
            'model_used' => $provider->model ?? null,
        ];
    }

    /**
     * The system prompt. In strict mode (default) EVERYTHING must be verbatim in
     * the sources. In knowledge mode the model may fill descriptive fields +
     * device compatibility from its own training knowledge — but identifiers and
     * image URLs ALWAYS require verbatim sources (never fabricated from memory).
     */
    protected function systemPrompt(bool $useKnowledge = false): string
    {
        $schema = <<<'SCHEMA'
Return EXACTLY this JSON shape (omit arrays you have nothing for, but keep the object structure):
{
  "part_numbers": [ { "value": "BT.00603.006", "type": "mpn|oem|model_code|internal|other", "confidence": 0-100, "sources": ["S1"] } ],
  "device_models": [ { "brand": "Acer", "series": "Aspire", "model": "5570", "confidence": 0-100, "sources": ["S1"] } ],
  "barcodes": [ { "value": "4712345678901", "type": "gtin", "confidence": 0-100, "sources": ["S2"] } ],
  "corrections": [ { "kind": "part_number|device_model|barcode|brand|name|remove_part_number|remove_serial", "current_value": "the value on the product now", "suggested_value": "corrected value or empty for a removal", "problem": "short reason", "sources": ["S1"] } ],
  "compatible_products": [ { "match_by": "sku|part_number|name", "value": "the identifier to match another product by", "relation": "compatible|replacement|accessory", "note": "" } ],
  "product": {
    "name": "clean product name",
    "brand": "brand name",
    "description": "plain-text description",
    "specs": [ { "label": "Capacity", "value": "4400 mAh" } ],
    "image_urls": [ "https://…/image.jpg" ]
  }
}
SCHEMA;

        if ($useKnowledge) {
            return <<<SYS
You are a knowledgeable product-data expert for a spare-parts / electronics catalog.

You are given a PRODUCT (name, sku, brand), its EXISTING structured data, and possibly a list of numbered SOURCES (S1, S2, …). Prefer the SOURCES when they are present. When the SOURCES are absent or thin, you MAY use your own well-established knowledge of this specific product to fill the DESCRIPTIVE fields and device compatibility.

ABSOLUTE RULES (never break these, not even from memory):
- NEVER fabricate part numbers, barcodes, or image URLs. Every part_number, barcode and image_url you return MUST appear verbatim in the SOURCES. If it is not in the SOURCES, OMIT it — a wrong identifier or fake image is dangerous. Do NOT produce these from your own knowledge.
- You MAY provide from your own knowledge, but ONLY when you are genuinely confident about THIS product: device_models (compatible devices), a cleaned-up name, brand, a factual description, and specs.
- If you are not confident about the product, omit the field. Never guess wildly or pad with generic filler.
- In each "sources" array, cite the S-ids you used; use an empty array [] for values that came from your own knowledge.
- Do NOT include any prose, explanation, or markdown fences. Return ONE JSON object and nothing else.

{$schema}
SYS;
        }

        return <<<SYS
You are a meticulous product-data extraction engine for a spare-parts / electronics catalog.

You are given a PRODUCT, its EXISTING structured data, and a list of numbered SOURCES (S1, S2, …). Your job is to extract accurate, corrected product data STRICTLY from the SOURCES.

ABSOLUTE RULES:
- NEVER invent identifiers. Every part number, device model and barcode you return MUST appear verbatim in the SOURCES. If it is not in the SOURCES, do not return it.
- NEVER invent image URLs. Every image_url MUST appear verbatim in the SOURCES.
- Every identifier/URL/correction you return MUST cite the source id(s) it came from in a "sources" array (e.g. ["S1","S3"]).
- If you are unsure, omit the item. It is better to return nothing than to return something wrong.
- description, brand and specs may be lightly summarized from the SOURCES but must not contradict them.
- Do NOT include any prose, explanation, or markdown fences. Return ONE JSON object and nothing else.

{$schema}
SYS;
    }

    /** Build the user prompt: existing data + numbered sources. */
    protected function userPrompt(Product $product, array $sources, array $existing, bool $useKnowledge = false): string
    {
        $ctx = ProductContext::gather($product);

        $lines = [];
        $lines[] = 'PRODUCT:';
        $lines[] = 'name: ' . $ctx['name'];
        $lines[] = 'sku: ' . $ctx['sku'];
        $lines[] = 'brand: ' . ($ctx['brand'] !== '' ? $ctx['brand'] : '(none)');
        if ($ctx['category'] !== '' || $ctx['sub_category'] !== '') {
            $lines[] = 'category: ' . trim($ctx['category'] . ' / ' . $ctx['sub_category'], ' /');
        }
        if ($ctx['description'] !== '') {
            $lines[] = 'description: ' . $ctx['description'];
        }
        foreach ($ctx['custom_fields'] as $label => $val) {
            $lines[] = $label . ': ' . $val;
        }
        $lines[] = '';
        $lines[] = 'EXISTING PART NUMBERS / SERIALS ON THIS PRODUCT (judge each for relevance; flag any that do NOT belong via a remove_* correction):';
        $lines[] = $ctx['existing_identifiers'] ? implode(', ', $ctx['existing_identifiers']) : '(none)';
        $lines[] = '';
        $lines[] = 'EXISTING STRUCTURED DATA (JSON):';
        $lines[] = json_encode($existing, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $lines[] = '';
        $lines[] = 'SOURCES:';
        foreach ($sources as $s) {
            $id   = (string) ($s['id'] ?? '');
            $orig = (string) ($s['origin'] ?? '');
            $text = (string) ($s['text'] ?? '');
            $urls = (array) ($s['urls'] ?? []);
            $lines[] = "[{$id}] (from {$orig})";
            if ($text !== '') {
                $lines[] = $text;
            }
            if ($urls) {
                $lines[] = 'IMAGE URLS: ' . implode(' ', $urls);
            }
            $lines[] = '';
        }

        if (empty($sources)) {
            $lines[] = '(no external sources were found for this product)';
            $lines[] = '';
        }

        $cleanup = 'TASK: (1) CLEAN UP — for each value in EXISTING PART NUMBERS / SERIALS, decide if it truly belongs to THIS exact product; if it does NOT, return a correction with kind "remove_part_number" (or "remove_serial") and a short reason. Never flag a value that genuinely belongs. '
            . '(2) COMPLETE — add any part numbers/serials this product is missing. '
            . '(3) COMPATIBLE PRODUCTS — list other products this one is interchangeable with / replaces, each identified by sku, part_number or name so it can be matched to the catalogue.';

        if ($useKnowledge) {
            $lines[] = $cleanup . ' Use the SOURCES first; where thin, use your own confident knowledge of this exact product for description, brand, specs and compatible device_models. Do NOT output any part_number, barcode or image_url that is not present verbatim in the SOURCES or the product data above.';
        } else {
            $lines[] = $cleanup . ' Base identifiers only on the SOURCES or the product data above.';
        }

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

    /**
     * Robust JSON parse: trim, strip ``` fences, take the substring from the
     * first { to the last }, then json_decode. Returns null on failure.
     */
    protected function parseJson(string $text): ?array
    {
        $t = (string) $text;

        // Reasoning models (glm-4.5, deepseek-r1, qwq…) emit a chain-of-thought
        // block before the answer — strip it so its stray braces don't corrupt
        // the JSON scan.
        $t = preg_replace('#<think>.*?</think>#is', '', $t);
        $t = preg_replace('#<thinking>.*?</thinking>#is', '', $t);
        // Some models leave an unterminated <think> with no close tag.
        if (stripos($t, '</think>') !== false) {
            $t = preg_replace('#^.*</think>#is', '', $t);
        }

        // Prefer the contents of a fenced ```json … ``` block when present.
        if (preg_match('/```(?:json)?\s*(\{.*\})\s*```/is', $t, $m)) {
            $decoded = json_decode($m[1], true);
            if (is_array($decoded)) {
                return $decoded;
            }
        }

        $t = trim($t);

        // Straight decode (model returned clean JSON).
        $decoded = json_decode($t, true);
        if (is_array($decoded)) {
            return $decoded;
        }

        // Balanced-brace scan from the first "{" — respects string literals and
        // escapes, so trailing prose or reasoning that contains braces can't
        // throw off the extraction (naive first-{-to-last-} does).
        $json = $this->extractBalancedObject($t);
        if ($json !== null) {
            $decoded = json_decode($json, true);
            if (is_array($decoded)) {
                return $decoded;
            }
        }

        return null;
    }

    /**
     * Return the first complete, brace-balanced JSON object substring, or null.
     * String-aware so braces inside "..." values don't affect the depth count.
     */
    protected function extractBalancedObject(string $t): ?string
    {
        $start = strpos($t, '{');
        if ($start === false) {
            return null;
        }
        $depth = 0;
        $inStr = false;
        $esc   = false;
        $len   = strlen($t);
        for ($i = $start; $i < $len; $i++) {
            $ch = $t[$i];
            if ($inStr) {
                if ($esc) {
                    $esc = false;
                } elseif ($ch === '\\') {
                    $esc = true;
                } elseif ($ch === '"') {
                    $inStr = false;
                }
                continue;
            }
            if ($ch === '"') {
                $inStr = true;
            } elseif ($ch === '{') {
                $depth++;
            } elseif ($ch === '}') {
                $depth--;
                if ($depth === 0) {
                    return substr($t, $start, $i - $start + 1);
                }
            }
        }

        return null; // unbalanced — likely a truncated response
    }

    /**
     * Validate + sanitize the model's raw output against the sources. Everything
     * that can't be corroborated is dropped. The concatenated (normalized)
     * source text is the ground truth for identifiers/URLs.
     */
    protected function validate(array $raw, Product $product, array $sources, array $existing, bool $useKnowledge = false): array
    {
        // Build the corpus we validate identifiers against. The product's OWN
        // name / SKU / existing structured data are trusted ground truth: a part
        // number printed in the product's own name (e.g. "…AS10D3E AS10D41
        // [Battery]") is real, not a hallucination, so identifiers found there
        // pass the verbatim check. (Image URLs deliberately still require an
        // EXTERNAL source — see $sourceUrls / $externalText below.)
        $externalText = '';
        $sourceUrls   = [];
        foreach ($sources as $s) {
            $externalText .= ' ' . (string) ($s['text'] ?? '');
            foreach ((array) ($s['urls'] ?? []) as $u) {
                if (is_string($u)) {
                    $sourceUrls[] = $u;
                }
            }
        }

        $ownText = ' ' . (string) $product->name . ' ' . (string) $product->sku
            . ' ' . json_encode($existing, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        // Identifiers (part numbers / device models / barcodes) may match either
        // the external sources OR the product's own name/data.
        $sourceTextRaw = $externalText . ' ' . $ownText;
        $normText = $this->normalizeForMatch($sourceTextRaw);

        $cap = (int) config('productcompat.ai.ai_only_confidence_cap', 60);

        $out = [
            'part_numbers'        => [],
            'device_models'       => [],
            'barcodes'            => [],
            'corrections'         => [],
            'compatible_products' => [],
            'product'             => [],
        ];

        // ---- Part numbers: 3–48 chars w/ digit-or-hyphen, must appear normalized in sources. ----
        foreach ((array) ($raw['part_numbers'] ?? []) as $pn) {
            $value = trim((string) ($pn['value'] ?? ''));
            $len   = mb_strlen($value);
            if ($len < 3 || $len > 48) {
                continue;
            }
            if (! preg_match('/[0-9\-]/', $value)) {
                continue;
            }
            // Presence check must use the SAME normalization as $normText
            // (case-insensitive, alnum-only) — otherwise a part number with
            // letters never matches (e.g. "AS10D3E" vs lowercased source text).
            $norm = $this->normalizeForMatch($value);
            if ($norm === '' || strpos($normText, $norm) === false) {
                continue; // hallucination guard — not present in the sources
            }
            $out['part_numbers'][] = [
                'value'      => $value,
                'type'       => in_array($pn['type'] ?? 'other', PcPartNumber::TYPES, true) ? $pn['type'] : 'other',
                'confidence' => $this->clampConf($pn['confidence'] ?? 60, $cap),
            ];
        }

        // ---- Device models: must appear in sources (strict), OR come from the
        //      model's knowledge when knowledge mode is on. Identifiers above stay
        //      verbatim-only regardless — only compatibility relaxes here. ----
        foreach ((array) ($raw['device_models'] ?? []) as $dm) {
            $model = trim((string) ($dm['model'] ?? ''));
            if ($model === '' || mb_strlen($model) > 80) {
                continue;
            }
            $norm = $this->normalizeForMatch($model);
            if ($norm === '') {
                continue;
            }
            if (! $useKnowledge && strpos($normText, $norm) === false) {
                continue; // strict mode: must be corroborated by a source
            }
            $out['device_models'][] = [
                'brand'      => trim((string) ($dm['brand'] ?? '')) ?: null,
                'series'     => trim((string) ($dm['series'] ?? '')) ?: null,
                'model'      => $model,
                'confidence' => $this->clampConf($dm['confidence'] ?? 60, $cap),
            ];
        }

        // ---- Barcodes: digits-only, valid GTIN length AND valid check digit. ----
        foreach ((array) ($raw['barcodes'] ?? []) as $bc) {
            $digits = preg_replace('/\D/', '', (string) ($bc['value'] ?? ''));
            if (! in_array(strlen($digits), [8, 12, 13, 14], true)) {
                continue;
            }
            if (! $this->gtinChecksumOk($digits)) {
                continue;
            }
            // Barcodes are also verbatim in the sources (digits may be spaced there).
            if (strpos(preg_replace('/\D/', '', $sourceTextRaw), $digits) === false) {
                continue;
            }
            $out['barcodes'][] = [
                'value'      => $digits,
                'type'       => 'gtin',
                'confidence' => $this->clampConf($bc['confidence'] ?? 70, $cap),
            ];
        }

        // ---- Corrections: must carry kind + current_value + problem. ----
        foreach ((array) ($raw['corrections'] ?? []) as $c) {
            $kind    = trim((string) ($c['kind'] ?? ''));
            $current = trim((string) ($c['current_value'] ?? ''));
            $problem = trim((string) ($c['problem'] ?? ''));
            if ($kind === '' || $current === '' || $problem === '') {
                continue; // drop malformed corrections
            }
            $out['corrections'][] = [
                'kind'            => mb_substr($kind, 0, 24),
                'current_value'   => mb_substr($current, 0, 191),
                'suggested_value' => mb_substr(trim((string) ($c['suggested_value'] ?? '')), 0, 191),
                'problem'         => mb_substr($problem, 0, 500),
            ];
        }

        // ---- Compatible products: match_by + value + relation, capped. ----
        foreach ((array) ($raw['compatible_products'] ?? []) as $cp) {
            $matchBy = strtolower(trim((string) ($cp['match_by'] ?? '')));
            $value   = trim((string) ($cp['value'] ?? ''));
            $rel     = strtolower(trim((string) ($cp['relation'] ?? 'compatible')));
            if (! in_array($matchBy, ['sku', 'part_number', 'name'], true) || $value === '') {
                continue;
            }
            $out['compatible_products'][] = [
                'match_by' => $matchBy,
                'value'    => mb_substr($value, 0, 191),
                'relation' => in_array($rel, ['compatible', 'replacement', 'accessory'], true) ? $rel : 'compatible',
                'note'     => mb_substr(trim((string) ($cp['note'] ?? '')), 0, 191),
            ];
            if (count($out['compatible_products']) >= 20) {
                break;
            }
        }

        // ---- product.{name, brand, description, specs, image_urls} ----
        $p = (array) ($raw['product'] ?? []);
        $productOut = [];

        $name = trim((string) ($p['name'] ?? ''));
        if ($name !== '') {
            $productOut['name'] = mb_substr($name, 0, 191);
        }

        $pBrand = trim((string) ($p['brand'] ?? ''));
        if ($pBrand !== '') {
            $productOut['brand'] = mb_substr($pBrand, 0, 191);
        }

        $desc = trim(strip_tags((string) ($p['description'] ?? '')));
        if ($desc !== '') {
            $productOut['description'] = mb_substr($desc, 0, 1200);
        }

        // Specs: max 25 rows, label/value 120 chars each.
        $specs = [];
        foreach ((array) ($p['specs'] ?? []) as $row) {
            $label = trim((string) ($row['label'] ?? ''));
            $val   = trim((string) ($row['value'] ?? ''));
            if ($label === '' || $val === '') {
                continue;
            }
            $specs[] = ['label' => mb_substr($label, 0, 120), 'value' => mb_substr($val, 0, 120)];
            if (count($specs) >= 25) {
                break;
            }
        }
        if ($specs) {
            $productOut['specs'] = $specs;
        }

        // Image URLs: http(s), must appear verbatim in an EXTERNAL source text or
        // the collector's URL list, max 6. Deliberately NOT matched against the
        // product's own name/data — a real image must come from a real source.
        $images = [];
        foreach ((array) ($p['image_urls'] ?? []) as $u) {
            $u = trim((string) $u);
            if (! preg_match('#^https?://#i', $u)) {
                continue;
            }
            $inText = strpos($externalText, $u) !== false;
            $inUrls = in_array($u, $sourceUrls, true);
            if (! $inText && ! $inUrls) {
                continue; // hallucination guard
            }
            if (! in_array($u, $images, true)) {
                $images[] = $u;
            }
            if (count($images) >= 6) {
                break;
            }
        }
        if ($images) {
            $productOut['image_urls'] = $images;
        }

        $out['product'] = $productOut;

        return $out;
    }

    /** Clamp confidence to 0–100 and cap AI-only values at $cap. */
    protected function clampConf($value, int $cap): int
    {
        $v = (int) $value;
        $v = max(0, min(100, $v));

        return min($v, $cap);
    }

    /**
     * Normalize a part number the same way ProductCompat does elsewhere, so the
     * "appears in sources" check matches how the value would be stored. Reuses
     * PcPartNumber::normalize() when available.
     */
    protected function normalizePartNumber(string $value): string
    {
        if (class_exists(PcPartNumber::class) && method_exists(PcPartNumber::class, 'normalize')) {
            return PcPartNumber::normalize($value);
        }

        // Fallback: mirror PcPartNumber::normalize (uppercase, strip non-alnum).
        return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $value));
    }

    /** Lower-case, strip everything but alphanumerics — for verbatim matching. */
    protected function normalizeForMatch(string $value): string
    {
        return strtolower(preg_replace('/[^A-Za-z0-9]/', '', $value));
    }

    /**
     * Standard GTIN mod-10 checksum, valid for GTIN-8/12/13/14. Weights the
     * digits 3/1/3/1… from the right (excluding the check digit), sums, and the
     * check digit is what makes the total a multiple of 10.
     */
    protected function gtinChecksumOk(string $digits): bool
    {
        $len = strlen($digits);
        if (! in_array($len, [8, 12, 13, 14], true) || ! ctype_digit($digits)) {
            return false;
        }

        $check = (int) $digits[$len - 1];
        $sum   = 0;
        // Walk the payload (all but the last digit) right-to-left, weighting the
        // rightmost payload digit by 3, then alternating 1/3.
        for ($i = $len - 2, $pos = 0; $i >= 0; $i--, $pos++) {
            $weight = ($pos % 2 === 0) ? 3 : 1;
            $sum += ((int) $digits[$i]) * $weight;
        }
        $expected = (10 - ($sum % 10)) % 10;

        return $expected === $check;
    }
}
