# -*- coding: utf-8 -*-
"""BM25 thuần Python — không thư viện ngoài.

VÌ SAO tự viết thay vì dùng thư viện (rank_bm25, whoosh...): kho tri thức chỉ
~500 đoạn, đổi mới vài chục dòng mỗi lần cập nhật sản phẩm. Thêm một
dependency ngoài cho việc này là phí — chỉ mục ngược + vòng lặp Python đủ
nhanh ở quy mô này (xem kiểm chứng ở cuối tệp), mà không kéo thêm rủi ro
phiên bản/khoá cài đặt trên VPS.

VÌ SAO khớp CHỮ CHÍNH XÁC quan trọng ở đây: khách gõ mã sản phẩm
(GOLDVIP6B), tên hàm (WebRequest), thông báo lỗi nguyên văn (Invalid Master
Code). Đây là tài liệu kỹ thuật, không phải văn xuôi — BM25 dựa trên khớp
từ nguyên văn (không như embedding, vốn có thể "lơ" các mã hiếm gặp vì
chúng ít xuất hiện trong dữ liệu huấn luyện). Vì vậy BM25 là một chân bắt
buộc của tầng lai, không phải phương án dự phòng.
"""
from __future__ import annotations

import math
import re
from collections import Counter, defaultdict
from typing import Dict, List, Optional, Set, Tuple

# Ký tự được coi là "thuộc về từ": chữ (kể cả có dấu tiếng Việt), số, và
# gạch dưới/chấm — hai ký tự này PHẢI giữ lại vì chúng nối liền các token
# kỹ thuật (max_loss, v1.19, file.ex5). Tách rời chúng ra thì "max_loss"
# vỡ thành "max" và "loss" MẤT LUÔN bản gốc dùng để khớp chính xác — dò
# tìm nguyên văn "max_loss" trong index sẽ trượt.
_MAU_TU = re.compile(r"[^\W_]+(?:[._][^\W_]+)*", re.UNICODE)

# Token toàn số (vd "119", "6") không mang nghĩa khi đứng một mình — chỉ
# giữ lại khi dính liền chữ (v119, GOLDVIP6B) vì lúc đó nó định danh sản
# phẩm/phiên bản, còn số trơn thì lẫn khắp nơi (giá tiền, ngày tháng) gây
# nhiễu chỉ mục ngược mà không giúp phân biệt tài liệu.
_TOAN_SO = re.compile(r"^\d+$")

# Hư từ — bỏ khỏi CẢ tài liệu lẫn câu hỏi.
#
# VÌ SAO cần: đo trên chỉ mục thật, câu "Quang Trade Panel dùng để làm gì"
# xếp đoạn tradepanel ra khỏi top-5, trong khi "Quang Trade Panel" trơn thì
# xếp nó hạng 1. Bốn chữ "dùng để làm gì" có mặt khắp nơi trong tài liệu,
# nên chúng kéo điểm về những đoạn dài chẳng liên quan và dìm tín hiệu thật.
#
# Danh sách cố ý NGẮN và chỉ gồm từ nối, từ để hỏi, đại từ. Tuyệt đối
# không bỏ những từ mang nghĩa trong lĩnh vực này — "lỗi", "cài", "giá",
# "key", "chạy", "mở", "đóng", "lệnh" đều phải giữ, dù chúng cũng rất phổ
# biến: phổ biến không đồng nghĩa với vô nghĩa.
HU_TU = frozenset("""
của và với cho từ theo về trong ngoài trên dưới khi nếu thì mà là ở tại
được bị các những cái một này kia đó ấy nào gì sao đâu bao nhiêu mấy
tôi mình em anh chị bạn ta họ nó chúng
dùng để làm bị có không chưa rồi đang sẽ đã vẫn cũng nữa lại
ạ nhé nha vậy thế ai ơi dạ vâng xin hỏi giúp muốn cần
the a an of and or to for from with in on at by is are was were be been
i you we they it this that these those what which who how why when where
do does did can could should would will shall may might must
""".split())

# Phát hiện camelCase / PascalCase kiểu "MaxLoss", "WebRequest" — biên chữ
# thường-sang-chữ-hoa.
#
# KHÔNG được viết bằng dải mã kiểu r"[a-zà-ỹ0-9](?=[A-ZÀ-Ỹ])". Dải `À-Ỹ`
# là dải ĐIỂM MÃ (U+00C0–U+1EF9), mà trong Unicode chữ hoa và chữ thường
# tiếng Việt nằm XEN KẼ nhau: À U+00C0, à U+00E0, Ạ U+1EA0, ạ U+1EA1...
# Nên `[A-ZÀ-Ỹ]` nuốt luôn cả chữ thường có dấu. Hậu quả đo được: 'dùng'
# bị coi là có biên camel giữa 'd' và 'ù', đẻ ra token rác 'ùng'; 'làm'
# đẻ ra 'àm'. Mọi từ tiếng Việt có dấu đều sinh rác, làm bẩn cả chỉ mục.
#
# `str.isupper()` của Python thì đúng theo bảng chữ hoa Unicode thật.
def _la_bien_camel(truoc: str, sau: str) -> bool:
    return (truoc.isalnum() and not truoc.isupper()) and sau.isupper()


def _tach_camel(tu: str) -> List[str]:
    """Cắt 'MaxLoss' -> ['max', 'loss'].

    VÌ SAO cần bước này: người dùng gõ rời "max loss" (có dấu cách) trong
    khi tài liệu kỹ thuật viết liền "MaxLoss" (đúng tên biến trong code EA).
    Nếu không sinh thêm token con, hai cách gõ này không bao giờ khớp nhau
    dù cùng một khái niệm — mà đây chính là kiểu gõ phổ biến nhất của khách
    không rành lập trình.
    """
    if not any(_la_bien_camel(tu[i], tu[i + 1]) for i in range(len(tu) - 1)):
        return []
    # Chèn khoảng trắng tại biên rồi tách — đơn giản hơn regex phức tạp,
    # và đủ dùng vì tên biến trong tài liệu không có UPPER-liên-tiếp phức
    # tạp kiểu "HTTPServer" (nếu có thì vẫn tách được, chỉ không tối ưu).
    danh_dau = []
    for i, ky_tu in enumerate(tu):
        danh_dau.append(ky_tu)
        if i < len(tu) - 1 and _la_bien_camel(ky_tu, tu[i + 1]):
            danh_dau.append(" ")
    manh = "".join(danh_dau).lower().split()
    # Bỏ mảnh trùng chính từ gốc (đã có sẵn) và mảnh 1 ký tự (nhiễu).
    return [m for m in manh if m != tu.lower() and len(m) > 1]


def tach_tu(van_ban: str) -> List[str]:
    """Tách văn bản thành danh sách token cho BM25.

    Nguyên tắc: chuyển thường nhưng GIỮ NGUYÊN dấu tiếng Việt — "má" và
    "mà" là hai từ khác nghĩa hoàn toàn, bỏ dấu để "chuẩn hoá" sẽ xoá mất
    thông tin cần để phân biệt, ngược với mục đích chuẩn hoá.
    """
    if not van_ban:
        return []
    thap = van_ban.lower()
    tho = _MAU_TU.findall(thap)

    ket_qua: List[str] = []
    for tu in tho:
        if _TOAN_SO.match(tu):
            continue  # số trơn — bỏ, xem lý do ở _TOAN_SO
        if tu in HU_TU:
            continue  # hư từ — xem lý do ở HU_TU
        ket_qua.append(tu)

    # Sinh token phụ từ bản GỐC (chưa hạ chữ thường) vì camelCase cần biên
    # hoa/thường để phát hiện — làm trên `van_ban` gốc, không phải `thap`.
    for tu_goc in _MAU_TU.findall(van_ban):
        ket_qua.extend(_tach_camel(tu_goc))

    return ket_qua


class BM25:
    """Chỉ mục BM25 dựng một lần, truy vấn nhiều lần.

    k1=1.5, b=0.75: đây là cặp mặc định kinh điển từ Robertson/Sparck-Jones,
    dùng rộng rãi cho văn bản độ dài vừa phải (bài báo, đoạn tài liệu) và
    không có lý do lệch khỏi nó ở đây — kho tri thức là các đoạn tài liệu
    ngắn, tương đối đồng đều về độ dài (không phải kho gồm cả tweet lẫn
    sách dày, nơi mới cần tinh chỉnh b). Chỉnh tay hai số này mà không có
    tập nhãn để đo hiệu quả là đoán mò, nên giữ nguyên giá trị chuẩn.
    """

    def __init__(self, tai_lieu: List[List[str]], k1: float = 1.5, b: float = 0.75):
        self.k1 = k1
        self.b = b
        self.tai_lieu = tai_lieu
        self.so_tai_lieu = len(tai_lieu)

        self.do_dai: List[int] = [len(d) for d in tai_lieu]
        self.do_dai_tb = (sum(self.do_dai) / self.so_tai_lieu) if self.so_tai_lieu else 0.0

        # Chỉ mục ngược: từ -> {chỉ số tài liệu: số lần xuất hiện}. Dùng
        # dict lồng thay vì quét tuần tự toàn bộ tài liệu mỗi truy vấn —
        # với vài trăm tài liệu thì quét thẳng cũng chưa chậm, nhưng chỉ
        # mục ngược giữ chi phí truy vấn tỉ lệ với SỐ TỪ TRONG CÂU HỎI
        # (thường vài từ) thay vì SỐ TÀI LIỆU, nên vẫn ổn khi kho phình
        # lên vài nghìn đoạn như đề bài tính trước.
        self._chi_muc_nguoc: Dict[str, Dict[int, int]] = defaultdict(dict)
        so_tai_lieu_chua_tu: Counter = Counter()

        for idx, tu_list in enumerate(tai_lieu):
            dem = Counter(tu_list)
            for tu, sl in dem.items():
                self._chi_muc_nguoc[tu][idx] = sl
            for tu in dem:
                so_tai_lieu_chua_tu[tu] += 1

        # idf chuẩn BM25 (Robertson-Walker), tính sẵn một lần lúc dựng chỉ
        # mục — truy vấn chỉ tra dict, không tính lại log mỗi lần.
        self._idf: Dict[str, float] = {}
        for tu, n_t in so_tai_lieu_chua_tu.items():
            self._idf[tu] = math.log(
                1 + (self.so_tai_lieu - n_t + 0.5) / (n_t + 0.5)
            )

    def diem_mot_doc(self, cac_tu: List[str], idx: int) -> float:
        """Điểm BM25 của một tài liệu với một danh sách từ truy vấn."""
        diem = 0.0
        dd = self.do_dai[idx]
        for tu in cac_tu:
            so_lan = self._chi_muc_nguoc.get(tu, {}).get(idx)
            if not so_lan:
                continue
            idf = self._idf.get(tu, 0.0)
            mau = so_lan + self.k1 * (1 - self.b + self.b * dd / (self.do_dai_tb or 1))
            diem += idf * (so_lan * (self.k1 + 1)) / mau
        return diem

    def truy_van(self, cac_tu: List[str], top: int,
                 cho_phep: Optional[Set[int]] = None) -> List[Tuple[int, float]]:
        """Trả [(chỉ số tài liệu, điểm)], sắp giảm dần theo điểm.

        Chỉ duyệt các tài liệu THỰC SỰ chứa ít nhất một từ truy vấn (qua
        chỉ mục ngược) thay vì duyệt toàn bộ self.tai_lieu — đây là điểm
        mấu chốt giữ tốc độ khi kho lớn dần.

        `cho_phep`: tập chỉ số được phép xét, dùng khi tầng gọi (lai.py)
        cần LỌC san_pham/loai TRƯỚC khi chấm điểm — lọc ở đây (trước khi
        tính BM25) thay vì lọc kết quả sau cùng, để một đoạn sai sản phẩm
        không chiếm mất chỗ trong top-N rồi mới bị vứt đi.
        """
        if not cac_tu:
            return []
        ung_vien: set = set()
        for tu in cac_tu:
            ung_vien.update(self._chi_muc_nguoc.get(tu, {}).keys())
        if cho_phep is not None:
            ung_vien &= cho_phep

        diem_theo_idx = [(idx, self.diem_mot_doc(cac_tu, idx)) for idx in ung_vien]
        diem_theo_idx.sort(key=lambda cap: cap[1], reverse=True)
        return diem_theo_idx[:top]


if __name__ == '__main__':
    # ---- Kiểm BM25 bằng bộ tài liệu bịa nhỏ ----
    tai_lieu_tho = [
        "MaxLoss là tham số giới hạn lỗ tối đa của EA, đặt trong file cấu hình.",
        "Hướng dẫn cài đặt MT5 cho người mới bắt đầu, tải MetaTrader 5 từ trang chủ.",
        "Lỗi Invalid Master Code xảy ra khi nhập sai mã kích hoạt GOLDVIP6B.",
        "Giá gói tháng và gói vĩnh viễn của các sản phẩm bot giao dịch.",
    ]
    tai_lieu_tach = [tach_tu(t) for t in tai_lieu_tho]
    bm = BM25(tai_lieu_tach)

    print("=== Kiểm tách từ ===")
    print("tach_tu('MaxLoss là gì') =", tach_tu("MaxLoss là gì"))
    print("tach_tu('max_loss GOLDVIP6B v1.19 .ex5 MT5') =",
          tach_tu("max_loss GOLDVIP6B v1.19 .ex5 MT5"))

    print("\n=== Kiểm truy vấn xếp hạng ===")
    truong_hop = [
        ("MaxLoss", 0),
        ("cài MT5", 1),
        ("Invalid Master Code", 2),
        ("giá bao nhiêu", 3),
    ]
    tat_ca_dat = True
    for cau, idx_mong_doi in truong_hop:
        tu = tach_tu(cau)
        kq = bm.truy_van(tu, top=4)
        idx_top1 = kq[0][0] if kq else None
        dat = idx_top1 == idx_mong_doi
        tat_ca_dat &= dat
        print(f"truy vấn={cau!r:35} top1_idx={idx_top1} mong_doi={idx_mong_doi} "
              f"{'OK' if dat else 'SAI'}  chi_tiet={kq}")

    print("\nKẾT LUẬN:", "TẤT CẢ ĐÚNG" if tat_ca_dat else "CÓ CA SAI")
