<?php

namespace Modules\ProductCompat\Entities\Services;

/**
 * Parses the legacy unstructured product_custom_field* blobs into structured
 * candidates: part numbers, device models and barcodes. Each token is
 * classified with a confidence score; low-confidence tokens are flagged for
 * human review. Pure / side-effect free — persistence happens in the command.
 *
 * Known limitation: a few OEM part numbers embed a slash (e.g. "CGR-B/6H5").
 * Since "/" is also the blob separator, such values fragment and surface as
 * low-confidence tokens for the reviewer to merge. This is intentional for the
 * review-driven first pass.
 */
class CustomFieldParser
{
    /** @var string[] lower-cased brand/series vocabulary */
    protected array $brands;

    /** @var int 0-100; tokens below this are flagged needs_review */
    protected int $threshold;

    /** Words we treat as the manufacturer (first segment of a device model). */
    protected array $manufacturers = [
        'acer', 'asus', 'dell', 'hp', 'compaq', 'lenovo', 'toshiba', 'sony',
        'samsung', 'msi', 'fujitsu', 'lg', 'apple', 'gateway', 'medion', 'packard',
        'huawei', 'honor', 'microsoft', 'xiaomi',
    ];

    /** Words we treat as a product series (second segment). */
    protected array $seriesWords = [
        'aspire', 'travelmate', 'extensa', 'ferrari', 'predator', 'nitro', 'swift',
        'spin', 'chromebook', 'conceptd', 'enduro',
        'vivobook', 'zenbook', 'expertbook', 'rog', 'tuf', 'zephyrus', 'proart',
        'inspiron', 'latitude', 'vostro', 'xps', 'precision', 'alienware', 'optiplex',
        'pavilion', 'probook', 'elitebook', 'presario', 'omen', 'victus', 'envy',
        'spectre', 'stream', 'zbook',
        'thinkpad', 'ideapad', 'yoga', 'legion', 'thinkbook', 'flex', 'loq', 'miix',
        'satellite', 'tecra', 'portege', 'vaio', 'macbook', 'matebook', 'magicbook', 'surface',
    ];

    /**
     * Descriptive words that mark a multi-word token as a product description
     * (e.g. "compatible with …", "designed for …") rather than a device model.
     * When such a token has no recognised brand it is flagged for review as
     * "wrong" data instead of becoming a fake device model.
     */
    protected array $stopwords = [
        'for', 'with', 'without', 'compatible', 'designed', 'replacement', 'fits',
        'suitable', 'genuine', 'original', 'assembly', 'including', 'include', 'new',
    ];

    public function __construct(?array $brands = null, ?int $threshold = null)
    {
        $brands = $brands ?? config('productcompat.brand_dictionary', []);
        $this->brands = array_map('strtolower', $brands);
        $this->threshold = $threshold ?? (int) config('productcompat.review_threshold', 75);
    }

    /**
     * Parse a product's custom fields.
     *
     * @param  object  $product  row with ->id plus product_custom_field* columns
     * @param  string[]  $fields  column names to read
     * @return array structured result (see class docblock / keys below)
     */
    public function parseProduct($product, array $fields): array
    {
        $oldData = [];
        $partNumbers = [];
        $deviceModels = [];
        $barcodes = [];
        $removed = [];

        $seenPart = [];
        $seenDevice = [];

        foreach ($fields as $field) {
            $raw = $product->{$field} ?? null;
            if ($raw === null || trim((string) $raw) === '') {
                continue;
            }
            $oldData[$field] = $raw;

            foreach ($this->tokenize($raw) as $token) {
                $c = $this->classifyToken($token);
                if ($c === null) {
                    continue;
                }
                $c['source_field'] = $field;

                switch ($c['category']) {
                    case 'part_number':
                        $key = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $c['value']));
                        if ($key === '' || isset($seenPart[$key])) {
                            break;
                        }
                        $seenPart[$key] = true;
                        $partNumbers[] = $c;
                        break;

                    case 'device_model':
                        [$brand, $series, $model] = $this->parseDeviceModel($c['value']);
                        $label = trim(implode(' ', array_filter([$brand, $series, $model])));
                        $key = strtoupper($label);
                        if ($key === '' || isset($seenDevice[$key])) {
                            break;
                        }
                        $seenDevice[$key] = true;
                        $c['brand'] = $brand;
                        $c['series'] = $series;
                        $c['model'] = $model;
                        $c['label'] = $label;
                        $deviceModels[] = $c;
                        break;

                    case 'barcode':
                        $barcodes[] = $c;
                        break;

                    default: // unknown / garbage => "wrong / removed" data
                        $removed[] = $c;
                        break;
                }
            }
        }

        return [
            'fields'        => $oldData,
            'part_numbers'  => $partNumbers,
            'device_models' => $deviceModels,
            'barcodes'      => $barcodes,
            'removed'       => $removed,
            'missing'       => [
                'part_number'  => count($partNumbers) === 0,
                'device_model' => count($deviceModels) === 0,
            ],
        ];
    }

    /**
     * Split a blob into candidate tokens.
     */
    public function tokenize(string $raw): array
    {
        $parts = preg_split('/[\/,;\r\n]+/', $raw) ?: [];
        $parts = array_map(fn ($p) => trim(preg_replace('/\s+/', ' ', $p)), $parts);

        return array_values(array_filter($parts, fn ($p) => $p !== ''));
    }

    /**
     * Classify a single token.
     *
     * @return array|null { value, category, type, confidence, needs_review, reason }
     */
    public function classifyToken(string $token): ?array
    {
        $t = trim($token);
        if ($t === '') {
            return null;
        }

        // Oversized tokens are descriptive blobs (stuffed paragraphs / lists),
        // not clean identifiers or device models — flag as wrong/unknown.
        if (mb_strlen($t) > 80) {
            return $this->result(mb_substr($t, 0, 120), 'unknown', null, 15);
        }

        $hasBrand   = $this->containsBrand($t);
        $hasSpace   = str_contains($t, ' ');
        $numericOnly = (bool) preg_match('/^\d{4,14}$/', $t);
        $looksPart  = $this->looksLikePartNumber($t);

        if ($hasBrand && $hasSpace) {
            return $this->result($t, 'device_model', null, 92);
        }
        if ($numericOnly) {
            return $this->result($t, 'barcode', null, 80);
        }
        if (! $hasSpace && $looksPart) {
            return $this->result($t, 'part_number', $this->guessPartType($t), 85);
        }
        if ($hasBrand && ! $hasSpace) {
            // a lone brand word — too vague to be useful on its own
            return $this->result($t, 'device_model', null, 55);
        }
        if ($hasSpace) {
            // No recognised brand. If it reads like a description ("compatible
            // with…", "designed for…"), flag it as wrong/unknown rather than a
            // fake device model.
            $words = preg_split('/\s+/', strtolower($t)) ?: [];
            if (in_array($words[0] ?? '', $this->stopwords, true) || array_intersect($words, $this->stopwords)) {
                return $this->result($t, 'unknown', null, 25);
            }

            // multi-word, no recognised brand — probably a model description
            return $this->result($t, 'device_model', null, 50);
        }
        if ($looksPart) {
            return $this->result($t, 'part_number', $this->guessPartType($t), 60);
        }

        return $this->result($t, 'unknown', null, 25);
    }

    protected function result(string $value, string $category, ?string $type, int $confidence): array
    {
        return [
            'value'        => $value,
            'category'     => $category,
            'type'         => $type,
            'confidence'   => $confidence,
            'needs_review' => $confidence < $this->threshold,
            'reason'       => $category,
        ];
    }

    protected function containsBrand(string $t): bool
    {
        $lower = strtolower($t);
        foreach ($this->brands as $brand) {
            if ($brand !== '' && preg_match('/\b' . preg_quote($brand, '/') . '\b/', $lower)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Heuristic: an alphanumeric code with at least one digit, no spaces,
     * length >= 4, possibly with dots/dashes (BT.00603.006, AS36802682,
     * BATEFL50L, 3UR18650Y-2-QC261, LIP6220QUPC-SY6 ...).
     */
    public function looksLikePartNumber(string $t): bool
    {
        if (str_contains($t, ' ') || mb_strlen($t) < 4 || mb_strlen($t) > 40) {
            return false;
        }
        if (! preg_match('/^[A-Za-z0-9][A-Za-z0-9._\-]{2,}$/', $t)) {
            return false;
        }

        return (bool) preg_match('/\d/', $t); // must contain a digit
    }

    /**
     * Best-effort extraction of part-number-like tokens from free text/HTML
     * (used by the scraping enrichment providers). Noisy by nature, so the
     * caller stores results as low-confidence / needs_review.
     *
     * @return array<int, array{value:string, type:string}>
     */
    public function extractPartNumbersFromText(string $text, int $max = 40): array
    {
        $out = [];
        $seen = [];
        if (preg_match_all('/[A-Za-z0-9][A-Za-z0-9._\-]{2,}/', $text, $m)) {
            foreach ($m[0] as $tok) {
                $tok = trim($tok, ".-_");
                if (! $this->looksLikePartNumber($tok)) {
                    continue;
                }
                // A part number contains BOTH a letter and a digit.
                if (! preg_match('/[A-Za-z]/', $tok) || ! preg_match('/\d/', $tok)) {
                    continue;
                }
                // Drop measurement / spec / price noise (volts, amps, mm, Hz, prices, cell counts…).
                if ($this->looksLikeSpecNoise($tok)) {
                    continue;
                }
                // Drop URL slugs / descriptive junk (e.g. "hp-pavilion-dv6-…-lcd-screen").
                if ($this->looksLikeSlug($tok)) {
                    continue;
                }
                // Confidence gate: need a separator, OR >=2 digits, OR length >=8
                // (avoids "utf8"/"html5"-type noise while keeping codes like L18M3PF9).
                $digits = preg_match_all('/\d/', $tok);
                $hasSep = (bool) preg_match('/[.\-]/', $tok);
                if (! $hasSep && $digits < 2 && mb_strlen($tok) < 8) {
                    continue;
                }
                $key = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $tok));
                if ($key === '' || isset($seen[$key])) {
                    continue;
                }
                $seen[$key] = true;
                $out[] = ['value' => $tok, 'type' => $this->guessPartType($tok)];
                if (count($out) >= $max) {
                    break;
                }
            }
        }

        return $out;
    }

    /**
     * Corroboration-based extraction for search providers: given the text of
     * each search result separately, keep only part numbers that appear in at
     * least $minHits distinct results, ranked by how many results mention them,
     * capped at $max. Cuts one-off / wrong-model noise hard while keeping the
     * genuinely-recurring equivalent codes.
     *
     * @param  string[]  $texts  one entry per search result (title + snippet)
     * @return array<int, array{value:string, type:string, hits:int}>
     */
    public function extractCorroborated(array $texts, int $minHits = 2, int $max = 5): array
    {
        $counts = [];
        $repr = [];
        $type = [];

        foreach ($texts as $txt) {
            $seenInThis = [];
            foreach ($this->extractPartNumbersFromText((string) $txt, 50) as $p) {
                $key = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $p['value']));
                if ($key === '' || isset($seenInThis[$key])) {
                    continue;                       // count each token once per result
                }
                $seenInThis[$key] = true;
                $counts[$key] = ($counts[$key] ?? 0) + 1;
                $repr[$key] = $repr[$key] ?? $p['value'];
                $type[$key] = $type[$key] ?? $p['type'];
            }
        }

        $keys = array_filter(array_keys($counts), fn ($k) => $counts[$k] >= $minHits);
        usort($keys, fn ($a, $b) => $counts[$b] <=> $counts[$a]);

        $out = [];
        foreach (array_slice($keys, 0, $max) as $k) {
            $out[] = ['value' => $repr[$k], 'type' => $type[$k], 'hits' => $counts[$k]];
        }

        return $out;
    }

    /**
     * True when a token is a URL slug or descriptive phrase, not a part number
     * (e.g. "hp-pavilion-dv6-6000-laptop-lcd-screen", "Number232323232323").
     */
    protected function looksLikeSlug(string $t): bool
    {
        return mb_strlen($t) > 24                       // overlong → slug/junk
            || substr_count($t, '-') >= 3               // many hyphens → slug
            || (bool) preg_match('/(laptop|notebook|screen|display|replacement|battery|charger|adapter|original|genuine|keyboard|cooler|pavilion|inspiron|thinkpad|ideapad|aspire|satellite|number\d)/i', $t)
            || (bool) preg_match('/(.{1,3})\1{3,}/', $t); // repeated groups e.g. 232323232323
    }

    /**
     * True when a token is a measurement / spec / price rather than a part number.
     */
    protected function looksLikeSpecNoise(string $t): bool
    {
        return (bool) (
            // currency anywhere (EGP285.00, 285.00EGP, Rp80.000)
            preg_match('/(^|[^a-z])(egp|usd|eur|gbp|sar|aed|jpy|rp|idr)([^a-z]|$)/i', $t)
            // numeric body ending in a unit: 11.1V, 100W, 3.0mm, 1.8m, 160GB, 2.2Ghz, 18AWG, 100-240V
            || preg_match('/^\d[\d.,\-]*(v|a|w|ah|wh|mah|hz|ghz|mhz|khz|mm|cm|m|kg|g|gb|mb|tb|awg|ohm|ohms|inch|in)$/i', $t)
            // voltage/current/power combos & ranges: 19.5V4.62A, 19.5V-3.33A, 90W-150W
            || preg_match('/\d(\.\d+)?(v|a|w|hz)[-x]?\d/i', $t)
            // dimensions: 4.5mmx3.0mm, 4.5mm-to-7.4mm, 4.5x3.0
            || preg_match('/\d(\.\d+)?mm/i', $t)
            || preg_match('/^\d+(\.\d+)?x\d/i', $t)
            // counts/quantities: 6-cell, 1-meter, 12-digit, 3-prong
            || preg_match('/^\d+-(meter|metre|cell|cells|wire|digit|pin|core|prong|inch|ft|foot|feet|year|years|month|months)/i', $t)
        );
    }

    protected function guessPartType(string $t): string
    {
        // Dotted vendor codes like BT.00603.006 / LC.BTP01.006 are typically OEM.
        if (preg_match('/^[A-Za-z]{1,3}\.[A-Za-z0-9]+/', $t)) {
            return 'oem';
        }

        return 'mpn';
    }

    /**
     * Break a device-model string into [brand, series, model].
     */
    public function parseDeviceModel(string $value): array
    {
        $words = preg_split('/\s+/', trim($value)) ?: [];
        if (empty($words)) {
            return [null, null, $value];
        }

        $brand = null;
        $series = null;

        if (in_array(strtolower($words[0]), $this->manufacturers, true)) {
            $brand = array_shift($words);
        }
        if (! empty($words) && in_array(strtolower($words[0]), $this->seriesWords, true)) {
            $series = array_shift($words);
        }

        $model = trim(implode(' ', $words));
        if ($model === '') {
            // Only brand/series present; keep something in model for uniqueness.
            $model = $series ?? $brand ?? $value;
        }

        return [$brand, $series, $model];
    }
}
