"""
Tier 0 faithfulness assessment — deterministic, no LLM, no network.

Checks each AI turn's factual number/ID/date claims against the injected
context (e.g. [OUTGOING], [AGENTDATA], [EXTENSIONS], agent_data) plus what
the caller has already said. A claim is "grounded" if its value appears in
that combined grounding set; otherwise it is flagged as unsupported.

This module is pure and side-effect-free: it does not touch the network,
the model, or any session state. It is called post-call and its result is
attached to the saved conversation log. English spoken/digit forms are
fully supported; Chinese and Malay currency/duration digit forms are
best-effort (digit sequences work in all languages).

Design notes:
  - Only "material" numbers are checked: currency (ringgit/sen/RM),
    durations (days/months), counts with a unit (payments/instalments),
    percentages, dates (day + month), and long digit IDs (accounts/phones/
    IC spelled as digit words or written as digits). Bare numbers with no
    unit (e.g. "one moment") are ignored to keep false positives low.
  - A wrong value that happens to also appear elsewhere in the context can
    be missed (PASS is "clean on mechanical checks", not "provably correct").
    A FAIL is high-confidence: a concrete value with a material unit that is
    nowhere in the context or prior caller speech.
"""

import re

# ── English number words ─────────────────────────────────────────────
_EN_ONES = {
    'zero': 0, 'oh': 0, 'nought': 0, 'one': 1, 'two': 2, 'three': 3,
    'four': 4, 'five': 5, 'six': 6, 'seven': 7, 'eight': 8, 'nine': 9,
    'ten': 10, 'eleven': 11, 'twelve': 12, 'thirteen': 13, 'fourteen': 14,
    'fifteen': 15, 'sixteen': 16, 'seventeen': 17, 'eighteen': 18,
    'nineteen': 19,
}
_EN_TENS = {
    'twenty': 20, 'thirty': 30, 'forty': 40, 'fifty': 50, 'sixty': 60,
    'seventy': 70, 'eighty': 80, 'ninety': 90,
}
_EN_SCALES = {'hundred': 100, 'thousand': 1000, 'million': 1000000}
_EN_ORDINAL = {
    'first': 1, 'second': 2, 'third': 3, 'fourth': 4, 'fifth': 5,
    'sixth': 6, 'seventh': 7, 'eighth': 8, 'ninth': 9, 'tenth': 10,
    'eleventh': 11, 'twelfth': 12, 'thirteenth': 13, 'fourteenth': 14,
    'fifteenth': 15, 'sixteenth': 16, 'seventeenth': 17, 'eighteenth': 18,
    'nineteenth': 19, 'twentieth': 20, 'thirtieth': 30, 'thirty-first': 31,
    'twenty-first': 21, 'twenty-second': 22, 'twenty-third': 23,
    'twenty-fourth': 24, 'twenty-fifth': 25, 'twenty-sixth': 26,
    'twenty-seventh': 27, 'twenty-eighth': 28, 'twenty-ninth': 29,
}

_MONTHS = {
    'january', 'february', 'march', 'april', 'may', 'june', 'july',
    'august', 'september', 'october', 'november', 'december',
    'januari', 'februari', 'mac', 'mei', 'jun', 'julai', 'ogos',
    'september', 'oktober', 'november', 'disember',
}

# unit word -> category (lowercase). Presence marks a number as "material".
_UNITS = {
    'ringgit': 'money', 'sen': 'money', 'rm': 'money', 'myr': 'money',
    'day': 'duration', 'days': 'duration', 'month': 'duration',
    'months': 'duration', 'year': 'duration', 'years': 'duration',
    'hari': 'duration', 'bulan': 'duration', 'tahun': 'duration',
    'percent': 'percent', 'peratus': 'percent',
    'payment': 'count', 'payments': 'count', 'instalment': 'count',
    'instalments': 'count', 'installment': 'count', 'installments': 'count',
    'ringgit.': 'money',
}

_ZH_UNITS = {'令吉': 'money', '仙': 'money', '天': 'duration', '日': 'duration',
             '个月': 'duration', '月': 'duration', '年': 'duration',
             '巴仙': 'percent'}


def _tok(text):
    """Lowercase word tokens, keeping hyphenated ordinals intact."""
    return re.findall(r"[a-z]+(?:-[a-z]+)?|\d+", text.lower())


def _en_words_to_number(tokens):
    """Convert a run of English number words to an int, or None."""
    if not tokens:
        return None
    total = 0
    current = 0
    saw = False
    for t in tokens:
        if t in _EN_ONES:
            current += _EN_ONES[t]; saw = True
        elif t in _EN_TENS:
            current += _EN_TENS[t]; saw = True
        elif t == 'hundred':
            current = (current or 1) * 100; saw = True
        elif t in ('thousand', 'million'):
            total += (current or 1) * _EN_SCALES[t]; current = 0; saw = True
        elif t == 'and':
            continue
        else:
            break
    if not saw:
        return None
    return total + current


def _norm_digits(s):
    """Strip separators from a digit-ish string, return plain digits."""
    return re.sub(r'\D', '', s)


def _to_value(digit_str):
    """Digit string -> int value (no decimal handling)."""
    d = _norm_digits(digit_str)
    return int(d) if d else None


# ── Grounding extraction (context + caller turns) ────────────────────

def _extract_grounding_numbers(text):
    """Collect every numeric value + long digit string from grounding text.
    Returns (values: set of int, money_cents: set of int, digit_runs: list)."""
    values = set()
    money_cents = set()
    digit_runs = []

    # Written amounts like RM3,564.25 / 3,564.25 / 15842.70 / 585.00
    for m in re.finditer(r'\d[\d,]*(?:\.\d+)?', text):
        raw = m.group(0)
        digits = _norm_digits(raw.split('.')[0])
        if digits:
            values.add(int(digits))
            digit_runs.append(digits)
        # money in cents (handles .25, .70, .00)
        if '.' in raw:
            whole, frac = raw.split('.', 1)
            wd = _norm_digits(whole)
            frac = (frac + '00')[:2]
            if wd:
                money_cents.add(int(wd) * 100 + int(frac))
        else:
            if digits:
                money_cents.add(int(digits) * 100)

    # Digit groups joined by dashes/spaces (IC/account/phone formats),
    # e.g. "000412-10-4827" -> "000412104827", so a spelled-out ID in an
    # AI turn can match. Words between groups break the run, so structured
    # labels and "31 July 2026" are not falsely joined.
    for m in re.finditer(r'\d+(?:[-\s]\d+)+', text):
        joined = _norm_digits(m.group(0))
        if len(joined) >= 4:
            digit_runs.append(joined)

    # English spoken numbers embedded in grounding (rare, but safe)
    toks = _tok(text)
    i = 0
    while i < len(toks):
        if toks[i] in _EN_ONES or toks[i] in _EN_TENS:
            j = i
            run = []
            while j < len(toks) and (toks[j] in _EN_ONES or toks[j] in _EN_TENS
                                     or toks[j] in _EN_SCALES or toks[j] == 'and'):
                run.append(toks[j]); j += 1
            val = _en_words_to_number(run)
            if val is not None:
                values.add(val)
            i = j
        else:
            i += 1
    return values, money_cents, digit_runs


# ── Claim extraction from an AI turn ─────────────────────────────────

def _extract_claims(text):
    """Return a list of claim dicts from one AI turn.
    Each: {raw, value, cents, digits, unit_category}."""
    claims = []
    lower = text.lower()

    # 1. Spelled digit sequences (IDs/phones/IC): 4+ consecutive digit-words
    toks_all = re.findall(r"[a-z]+|\d+|[一-鿿]", lower)
    i = 0
    while i < len(toks_all):
        if toks_all[i] in _EN_ONES and _EN_ONES[toks_all[i]] < 10:
            run = []
            j = i
            while j < len(toks_all) and toks_all[j] in _EN_ONES and _EN_ONES[toks_all[j]] < 10:
                run.append(str(_EN_ONES[toks_all[j]])); j += 1
            if len(run) >= 4:  # looks like an ID/account/phone spelled out
                claims.append({'raw': ' '.join(toks_all[i:j]),
                               'value': None, 'cents': None,
                               'digits': ''.join(run), 'unit_category': 'id'})
                i = j
                continue
        i += 1

    # 2. Written digit numbers with a nearby unit, currency, %, or date
    #    Currency: "RM3,564.25", "3564 ringgit", "fifty ringgit"
    for m in re.finditer(r'(?:rm|myr)\s*(\d[\d,]*(?:\.\d+)?)', lower):
        _add_written(claims, m.group(1), 'money')
    #    "<digits> ringgit [and <digits> sen]" and unit-suffixed digit numbers
    for m in re.finditer(r'(\d[\d,]*(?:\.\d+)?)\s*([a-z%]+)', lower):
        num, unit = m.group(1), m.group(2)
        cat = _UNITS.get(unit) or ('percent' if unit == '%' else None)
        if cat:
            _add_written(claims, num, cat)
    #    standalone "%"
    for m in re.finditer(r'(\d[\d,]*(?:\.\d+)?)\s*%', lower):
        _add_written(claims, m.group(1), 'percent')
    #    long standalone digit runs (account endings, "6591", phone)
    for m in re.finditer(r'\b(\d{4,})\b', lower):
        digits = m.group(1)
        claims.append({'raw': digits, 'value': int(digits), 'cents': None,
                       'digits': digits, 'unit_category': 'id'})
    #    dates written as digits: "31 July", "15 August"
    for m in re.finditer(r'\b(\d{1,2})\s+([a-z]+)', lower):
        if m.group(2) in _MONTHS:
            _add_written(claims, m.group(1), 'date')

    # 3. English spoken numbers followed by a material unit or month
    toks = _tok(text)
    i = 0
    while i < len(toks):
        t = toks[i]
        if t in _EN_ONES or t in _EN_TENS or t in _EN_ORDINAL:
            j = i
            run = []
            while j < len(toks) and (toks[j] in _EN_ONES or toks[j] in _EN_TENS
                                     or toks[j] in _EN_SCALES or toks[j] == 'and'):
                run.append(toks[j]); j += 1
            # ordinal date, e.g. "thirty-first of july"
            if t in _EN_ORDINAL and (j >= len(toks) or not run):
                val = _EN_ORDINAL[t]
                nxt = toks[j:j + 2]
                if any(x in _MONTHS for x in nxt) or (nxt and nxt[0] == 'of'):
                    claims.append({'raw': t, 'value': val, 'cents': val * 100,
                                   'digits': str(val), 'unit_category': 'date'})
                i = j + 1 if j == i else j
                continue
            val = _en_words_to_number(run)
            if val is not None and j < len(toks):
                # currency compound: "... ringgit and ... sen"
                if toks[j] in ('ringgit', 'rm', 'myr'):
                    cents = val * 100
                    k = j + 1
                    if k < len(toks) and toks[k] == 'and':
                        k += 1
                    sub = []
                    while k < len(toks) and (toks[k] in _EN_ONES or toks[k] in _EN_TENS):
                        sub.append(toks[k]); k += 1
                    sen = _en_words_to_number(sub) if sub else 0
                    if k < len(toks) and toks[k] == 'sen':
                        cents += (sen or 0); k += 1
                    claims.append({'raw': ' '.join(toks[i:k]), 'value': val,
                                   'cents': cents, 'digits': str(val),
                                   'unit_category': 'money'})
                    i = k
                    continue
                cat = _UNITS.get(toks[j])
                if cat:
                    claims.append({'raw': ' '.join(toks[i:j + 1]), 'value': val,
                                   'cents': val * 100, 'digits': str(val),
                                   'unit_category': cat})
                    i = j + 1
                    continue
                if toks[j] in _MONTHS or toks[j] == 'of':
                    claims.append({'raw': ' '.join(toks[i:j]), 'value': val,
                                   'cents': val * 100, 'digits': str(val),
                                   'unit_category': 'date'})
            i = j if j > i else i + 1
        else:
            i += 1

    # de-dup identical claims
    seen = set()
    unique = []
    for c in claims:
        key = (c['unit_category'], c['digits'], c['cents'])
        if key not in seen:
            seen.add(key)
            unique.append(c)
    return unique


def _add_written(claims, raw, category):
    digits = _norm_digits(raw.split('.')[0])
    if not digits:
        return
    value = int(digits)
    if '.' in raw:
        whole, frac = raw.split('.', 1)
        frac = (frac + '00')[:2]
        cents = int(_norm_digits(whole)) * 100 + int(frac)
    else:
        cents = value * 100
    claims.append({'raw': raw, 'value': value, 'cents': cents,
                   'digits': digits, 'unit_category': category})


def _is_grounded(claim, values, money_cents, digit_runs):
    """True if the claim's value is supported by the grounding sets."""
    cat = claim['unit_category']
    digits = claim.get('digits') or ''
    val = claim.get('value')
    cents = claim.get('cents')

    # ID / account / phone: allow suffix or exact-substring match against
    # any long grounding digit run (e.g. "6591" ends account 320174826591).
    if cat == 'id':
        if val is not None and val in values:
            return True
        if len(digits) >= 3:
            for run in digit_runs:
                if len(run) >= len(digits) and (run.endswith(digits) or digits in run):
                    return True
        return False

    # money: match on exact cents, or on whole-ringgit value
    if cat == 'money':
        if cents is not None and cents in money_cents:
            return True
        if val is not None and val in values:
            return True
        return False

    # duration / count / percent / date: exact integer value must appear
    if val is not None and val in values:
        return True
    return False


# ── Tool-call checks (Tier 0 subset) ─────────────────────────────────

# Prerequisite tools that must have been called earlier in the same call.
SEQUENCE_RULES = {
    'dial_extension': ('search_extension',),
    'checkout': ('view_cart',),
}

# Markers indicating a tool result was an error / business failure.
_ERROR_MARKERS = ('"ok": false', '"ok":false', 'api error', 'api call failed')

# Acknowledgement markers expected in the next AI turn after a tool error
# (multilingual, deliberately generous to keep false positives low).
_ACK_MARKERS = (
    'sorry', 'apolog', 'unable', 'cannot', "can't", 'couldn', 'error',
    'unfortunately', 'issue', 'problem', 'try again', 'trouble', 'not able',
    'out of stock', 'not found', 'no ', 'only ',
    '抱歉', '无法', '失败', '问题', '找不到', '没有', '缺货',
    'maaf', 'tidak', 'masalah', 'gagal', 'tiada',
)


def _tool_defs_map(tool_defs):
    """Index tool definitions by function name."""
    out = {}
    for t in tool_defs or []:
        try:
            fn = t.get('function', {})
            name = fn.get('name')
            if name:
                out[name] = fn
        except AttributeError:
            continue
    return out


def _check_tool_schema(name, args, fn_def):
    """Required-field and enum validation. Returns list of flag dicts."""
    flags = []
    params = (fn_def or {}).get('parameters', {}) or {}
    props = params.get('properties', {}) or {}
    required = params.get('required', []) or []
    for field in required:
        if field not in args or args.get(field) in (None, ''):
            flags.append({'claim_type': 'tool_schema',
                          'spoken': f'{name}: missing required argument "{field}"',
                          'verdict': 'violation'})
    for key, val in (args or {}).items():
        spec = props.get(key)
        if spec and isinstance(spec, dict) and 'enum' in spec:
            if val not in spec['enum']:
                flags.append({'claim_type': 'tool_schema',
                              'spoken': f'{name}.{key}="{val}" not in allowed values',
                              'verdict': 'violation'})
    return flags


def _check_tool_args_grounded(name, args, values, money_cents, digit_runs):
    """Material numeric argument values (3+ digit runs) must be grounded in
    context, prior caller speech, or prior tool results. Small numbers
    (quantities etc.) are intent-arithmetic — deliberately not checked."""
    flags = []
    for key, val in (args or {}).items():
        for run in re.findall(r'\d+', str(val)):
            if len(run) < 3:
                continue
            claim = {'unit_category': 'id', 'digits': run,
                     'value': int(run), 'cents': None}
            if not _is_grounded(claim, values, money_cents, digit_runs):
                flags.append({'claim_type': 'tool_arg',
                              'spoken': f'{name}.{key}={val}',
                              'digits': run,
                              'verdict': 'unsupported'})
    return flags


def _result_is_error(result):
    if not result or not isinstance(result, str):
        return False
    low = result.lower()
    return any(m in low for m in _ERROR_MARKERS)


def assess_conversation(messages, context_text, tool_events=None, tool_defs=None):
    """Assess an entire conversation.

    Args:
        messages: list of {'speaker': 'ai'|'user'|'system', 'text': str}
        context_text: the resolved injected context (OUTGOING/AGENTDATA/etc.)
        tool_events: optional list of
            {'msg_index': int, 'name': str, 'arguments': dict, 'result': str|None}
            in call order. msg_index is the conversation_log position at the
            time the tool ran (used to interleave with speech turns).
        tool_defs: optional list of tool definitions ({'function': {...}}
            format) used for required-field/enum validation.

    Returns a result dict (no timestamp — caller stamps it):
        {checked_turns, tool_calls_checked, flagged_count, status, flags}
    """
    base_values, base_cents, base_runs = _extract_grounding_numbers(context_text or '')

    # running grounding accumulates caller-provided numbers as the call unfolds
    values = set(base_values)
    money_cents = set(base_cents)
    digit_runs = list(base_runs)

    defs_map = _tool_defs_map(tool_defs)
    events = sorted(tool_events or [], key=lambda e: e.get('msg_index', 0))
    ev_ptr = 0
    seen_tools = set()
    pending_error = None  # (event, ) awaiting acknowledgement in next AI turn

    flags = []
    checked = 0

    def _ingest_event(ev, at_idx):
        """Sequence + schema + arg checks, then fold args/result into grounding."""
        nonlocal pending_error
        name = ev.get('name') or ''
        args = ev.get('arguments') or {}
        if not isinstance(args, dict):
            try:
                import json as _json
                args = _json.loads(args)
            except Exception:
                args = {}
        result = ev.get('result')

        for prereq in SEQUENCE_RULES.get(name, ()):
            if prereq not in seen_tools:
                flags.append({'turn': at_idx, 'tool': name,
                              'claim_type': 'tool_sequence',
                              'spoken': f'{name} called without prior {prereq}',
                              'verdict': 'violation'})
        if name in defs_map:
            for f in _check_tool_schema(name, args, defs_map[name]):
                f['turn'] = at_idx
                f['tool'] = name
                flags.append(f)
        for f in _check_tool_args_grounded(name, args, values, money_cents, digit_runs):
            f['turn'] = at_idx
            f['tool'] = name
            flags.append(f)
        seen_tools.add(name)

        # Fold arguments + result into grounding for later speech/tools
        ground_text = ' '.join(str(v) for v in args.values())
        if isinstance(result, str):
            ground_text += ' ' + result
        gv, gc, gr = _extract_grounding_numbers(ground_text)
        values.update(gv)
        money_cents.update(gc)
        digit_runs.extend(gr)

        if _result_is_error(result):
            pending_error = {'event': ev, 'at_idx': at_idx, 'name': name}

    for idx, msg in enumerate(messages or []):
        while ev_ptr < len(events) and events[ev_ptr].get('msg_index', 0) <= idx:
            _ingest_event(events[ev_ptr], idx)
            ev_ptr += 1

        speaker = (msg.get('speaker') or '').lower()
        text = msg.get('text') or ''
        if speaker == 'user':
            uv, uc, ur = _extract_grounding_numbers(text)
            values |= uv
            money_cents |= uc
            digit_runs.extend(ur)
            continue
        if speaker != 'ai':
            continue

        # Tool-error acknowledgement: the first AI turn after an error result
        # should acknowledge it rather than carry on as if it succeeded.
        if pending_error is not None:
            low = text.lower()
            if not any(m in low for m in _ACK_MARKERS):
                flags.append({'turn': idx, 'tool': pending_error['name'],
                              'claim_type': 'tool_error_ignored',
                              'spoken': (f'{pending_error["name"]} returned an '
                                         f'error; the reply did not acknowledge it'),
                              'verdict': 'violation'})
            pending_error = None

        checked += 1
        for claim in _extract_claims(text):
            if not _is_grounded(claim, values, money_cents, digit_runs):
                flags.append({
                    'turn': idx,
                    'claim_type': claim['unit_category'],
                    'spoken': claim['raw'],
                    'value': claim.get('value'),
                    'digits': claim.get('digits'),
                    'verdict': 'unsupported',
                })

    # Any events after the last message
    while ev_ptr < len(events):
        _ingest_event(events[ev_ptr], len(messages or []))
        ev_ptr += 1

    return {
        'checked_turns': checked,
        'tool_calls_checked': len(events),
        'flagged_count': len(flags),
        'status': 'FAIL' if flags else 'PASS',
        'flags': flags,
    }
