"""Match a spoken description to an asset.

Mike's own example recording opens with the equipment name - "conveyor one
packaging, replaced 10 rows of modular belt..." - so the asset is usually
already in the speech. Guessing it saves the technician a search.

Deliberately NOT an AI call. This is deterministic token overlap against the
seeded asset list: it is instant, free, offline-capable, and explainable. The
plan's out-of-scope list still excludes matching a transcript against the ITEM
file to pull part numbers; that is a different and much harder problem.

The result is always a SUGGESTION. The UI pre-selects it and the technician can
change it, so a wrong guess costs one tap and never silently mis-files a ticket.
"""

from __future__ import annotations

import re

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models.tables import Asset

# Words that carry no identifying signal in this domain - they appear in half
# the asset names and most of the sentences.
_STOPWORDS = {
    "a",
    "an",
    "and",
    "at",
    "for",
    "from",
    "in",
    "is",
    "it",
    "of",
    "on",
    "the",
    "to",
    "with",
    "was",
    "has",
    "have",
    "there",
    "this",
    "that",
    "making",
    "running",
    "noise",
    "problem",
    "issue",
    "broken",
    "replaced",
    "replace",
    "needs",
    "need",
}

# Spoken numbers appear in equipment names as digits ("PACKAGING 1").
_NUMBER_WORDS = {
    "one": "1",
    "two": "2",
    "three": "3",
    "four": "4",
    "five": "5",
    "six": "6",
    "seven": "7",
    "eight": "8",
    "nine": "9",
    "ten": "10",
}

MIN_SCORE = 0.34


def _tokenise(text: str) -> list[str]:
    raw = re.findall(r"[a-z0-9]+", (text or "").lower())
    return [_NUMBER_WORDS.get(token, token) for token in raw]


def _significant(tokens: list[str]) -> set[str]:
    return {t for t in tokens if t not in _STOPWORDS and len(t) > 1}


def match_asset(db: Session, text: str) -> tuple[Asset, float, str] | None:
    """Best asset guess for a spoken description, or None if nothing is close.

    Score is the share of an asset's own significant words that appear in the
    speech, so a two-word asset name fully present beats a five-word name half
    present. A small bonus rewards matching the line as well as the name.
    """
    spoken = _significant(_tokenise(text))
    if not spoken:
        return None

    lowered = (text or "").lower()
    best: tuple[Asset, float, str] | None = None

    for asset in db.scalars(select(Asset)):
        # The equipment number spoken aloud is decisive, and is checked BEFORE
        # the name-overlap guard below - an asset can be named by its number
        # alone ("problem on 2KL-001-03.1"), with none of its description
        # words present.
        number_spoken = (
            len(asset.equipment_no) >= 4 and asset.equipment_no.lower() in lowered
        )

        name_tokens = _significant(_tokenise(asset.description))
        overlap = name_tokens & spoken if name_tokens else set()

        if not overlap and not number_spoken:
            continue

        score = len(overlap) / len(name_tokens) if name_tokens and overlap else 0.0
        matched_on = asset.description

        line_tokens = _significant(_tokenise(asset.line or ""))
        if line_tokens and line_tokens & spoken:
            score += 0.15
            matched_on = f"{asset.description} ({asset.line})"

        if number_spoken:
            score += 1.0
            matched_on = asset.equipment_no

        if best is None or score > best[1]:
            best = (asset, min(score, 1.0), matched_on)

    if best is None or best[1] < MIN_SCORE:
        return None
    return best
