import re
import sys
import threading
import types
import unittest
from datetime import date, datetime
from types import SimpleNamespace
from unittest.mock import patch


class FakeMessage:
    def __init__(self, text=None, **kwargs):
        self.text = text


fake_models = types.ModuleType("zlapi.models")
fake_models.Message = FakeMessage
fake_models.ThreadType = object
fake_zlapi = types.ModuleType("zlapi")
fake_zlapi.models = fake_models
sys.modules.setdefault("zlapi", fake_zlapi)
sys.modules.setdefault("zlapi.models", fake_models)

from modules import banggia, capnhat, mau, menu, mua, stk
from sales_utils import (
    extract_command_args,
    format_package_line,
    get_active_promotions,
    load_catalog,
    package_price,
    package_status,
    parse_amount,
    prepare_transfer_note,
    resolve_command_name,
    resolve_product_selection,
)
from xkprj import (
    CommandHandler,
    SALES_BUSY,
    SALES_COOLDOWN,
    SALES_SCHEDULED,
)


class FakeClient:
    def __init__(self):
        self.command_handler = SimpleNamespace(current_prefix=".")
        self.replies = []
        self.images = []
        self.reactions = []

    def replyMessage(self, message, reply_target, thread_id, thread_type, ttl=0):
        self.replies.append((message.text, reply_target, ttl))

    def sendLocalImage(self, **kwargs):
        self.images.append(kwargs)

    def sendReaction(self, **kwargs):
        self.reactions.append(kwargs)


class SalesCommandTests(unittest.TestCase):
    def setUp(self):
        page_positions = getattr(banggia, "_page_positions", None)
        if page_positions is not None:
            page_positions.clear()

    def test_amount_parser_accepts_common_vietnamese_formats(self):
        expected_values = {
            "100000": 100_000,
            "100.000": 100_000,
            "100,000": 100_000,
            "100k": 100_000,
            "1.5tr": 1_500_000,
            "1,5 triệu": 1_500_000,
        }
        for raw_amount, expected in expected_values.items():
            with self.subTest(raw_amount=raw_amount):
                self.assertEqual(parse_amount(raw_amount), expected)

    def test_amount_parser_rejects_invalid_values(self):
        for raw_amount in ("", "abc", "-100", "1.2.3", "1.000k", "0"):
            with self.subTest(raw_amount=raw_amount):
                with self.assertRaises(ValueError):
                    parse_amount(raw_amount)

    def test_paused_status_and_legacy_content_are_normalized(self):
        self.assertEqual(package_status({"status": "Tạm ngưng"}), "paused")

        for marker in ("TẠM NGƯNG", "TAM NGUNG"):
            with self.subTest(marker=marker):
                rendered = format_package_line(
                    {"number": "1", "content": f"⛔️ ({marker}) ChatGPT Plus"}
                )
                self.assertEqual(rendered.count("TẠM NGƯNG"), 1)
                self.assertNotIn("TAM NGUNG", rendered)

    def test_package_price_rejects_fractional_values(self):
        self.assertEqual(package_price({"price": 100_000.0}), 100_000)
        self.assertIsNone(package_price({"price": 100_000.9}))

    def test_command_argument_parser_accepts_configured_alias(self):
        arguments = extract_command_args(
            ".thanhtoan 100k CHATGPT-2",
            "stk",
            aliases=["thanhtoan"],
        )
        self.assertEqual(arguments, "100k CHATGPT-2")

    def test_product_lookup_handles_spaces_hyphens_and_package_number(self):
        catalog = {
            "chatgpt": {"packages": []},
            "google ai": {"packages": []},
            "google one": {"packages": []},
            "elsa premium": {"packages": []},
        }
        cases = {
            "CHATGPT 2": ("chatgpt", "2"),
            "google-ai 1": ("google ai", "1"),
            "googleone": ("google one", None),
            "elsa_premium 2": ("elsa premium", "2"),
        }
        for query, expected in cases.items():
            with self.subTest(query=query):
                product_code, _, package_number = resolve_product_selection(query, catalog)
                self.assertEqual((product_code, package_number), expected)

    def test_router_requires_command_boundary_except_compact_mua(self):
        commands = {"mua": object(), "mau": object(), "stk": object(), "sticker": object()}
        aliases = {"stk": ["sticker"]}
        cases = {
            ".mua chatgpt": "mua",
            ".muachatgpt": "mua",
            ".MUAChatGPT": "mua",
            ".stk 100000": "stk",
            ".sticker": "sticker",
            ".stkabc": None,
            ".mauabc": None,
        }
        for message, expected in cases.items():
            with self.subTest(message=message):
                actual = resolve_command_name(
                    message,
                    ".",
                    commands,
                    aliases=aliases,
                    compact_commands={"mua"},
                )
                self.assertEqual(actual, expected)

    def test_mua_renders_numbered_packages(self):
        catalog = load_catalog()
        product_code, product, _ = resolve_product_selection("chatgpt", catalog)
        rendered = mua.render_product_packages(product_code, product, ".")
        self.assertIn("Gói 1", rendered)
        self.assertIn(".mua chatgpt <số gói>", rendered)

    def test_compact_mua_handler_returns_chatgpt_packages(self):
        client = FakeClient()
        message_object = SimpleNamespace()
        mua.handle_mua_command(
            ".muachatgpt",
            message_object,
            "thread",
            "group",
            "customer",
            client,
        )
        self.assertEqual(len(client.replies), 1)
        self.assertIn("Mã sản phẩm: chatgpt", client.replies[0][0])
        self.assertIn("Gói 5", client.replies[0][0])

    def test_bg_without_arguments_shows_only_first_page(self):
        catalog = {
            f"p{index:02d}": {
                "name": f"Product {index:02d}",
                "packages": [{"number": "1", "content": f"Package {index:02d}"}],
            }
            for index in range(1, 11)
        }
        client = FakeClient()
        with patch.object(banggia, "load_catalog", return_value=catalog), patch.object(
            banggia,
            "load_shop_config",
            return_value={"store_name": "TEST"},
        ):
            banggia.handle_bg_command(
                ".bg", SimpleNamespace(), "thread", "group", "customer", client
            )

        self.assertEqual(len(client.replies), 1)
        reply_text = client.replies[0][0]
        self.assertIn("(p01)", reply_text)
        self.assertIn("(p08)", reply_text)
        self.assertNotIn("(p09)", reply_text)
        self.assertIn("Trang 1/2", reply_text)
        self.assertIn(".bg next", reply_text)

    def test_bg_next_advances_from_the_current_page(self):
        catalog = {
            f"p{index:02d}": {
                "name": f"Product {index:02d}",
                "packages": [{"number": "1", "content": f"Package {index:02d}"}],
            }
            for index in range(1, 18)
        }
        client = FakeClient()
        with patch.object(banggia, "load_catalog", return_value=catalog), patch.object(
            banggia,
            "load_shop_config",
            return_value={"store_name": "TEST"},
        ):
            banggia.handle_bg_command(
                ".bg 2", SimpleNamespace(), "thread", "group", "customer", client
            )
            banggia.handle_bg_command(
                ".bg next", SimpleNamespace(), "thread", "group", "customer", client
            )

        reply_text = client.replies[-1][0]
        self.assertIn("Trang 3/3", reply_text)
        self.assertIn("(p17)", reply_text)
        self.assertNotIn("(p09)", reply_text)

    def test_bg_next_after_first_page_shows_second_page(self):
        catalog = {
            f"p{index:02d}": {"name": f"Product {index:02d}", "packages": []}
            for index in range(1, 11)
        }
        client = FakeClient()
        with patch.object(banggia, "load_catalog", return_value=catalog), patch.object(
            banggia,
            "load_shop_config",
            return_value={"store_name": "TEST"},
        ):
            banggia.handle_bg_command(
                ".bg", SimpleNamespace(), "thread", "group", "customer", client
            )
            banggia.handle_bg_command(
                ".bg next", SimpleNamespace(), "thread", "group", "customer", client
            )

        reply_text = client.replies[-1][0]
        self.assertIn("Trang 2/2", reply_text)
        self.assertIn("(p09)", reply_text)
        self.assertNotIn("(p08)", reply_text)

    def test_bg_next_position_is_isolated_per_user_and_thread(self):
        catalog = {
            f"p{index:02d}": {"name": f"Product {index:02d}", "packages": []}
            for index in range(1, 18)
        }
        client = FakeClient()
        with patch.object(banggia, "load_catalog", return_value=catalog), patch.object(
            banggia,
            "load_shop_config",
            return_value={"store_name": "TEST"},
        ):
            banggia.handle_bg_command(
                ".bg 2", SimpleNamespace(), "thread-a", "group", "customer-a", client
            )
            banggia.handle_bg_command(
                ".bg next", SimpleNamespace(), "thread-a", "group", "customer-b", client
            )
            banggia.handle_bg_command(
                ".bg next", SimpleNamespace(), "thread-b", "group", "customer-a", client
            )

        self.assertIn("Trang 2/3", client.replies[-2][0])
        self.assertIn("Trang 2/3", client.replies[-1][0])

    def test_bg_page_stays_within_safe_message_length_for_long_products(self):
        catalog = {
            f"p{index:02d}": {
                "name": f"Product {index:02d}",
                "packages": [{"number": "1", "content": "x" * 600}],
            }
            for index in range(1, 11)
        }
        client = FakeClient()
        with patch.object(banggia, "load_catalog", return_value=catalog), patch.object(
            banggia,
            "load_shop_config",
            return_value={"store_name": "TEST"},
        ):
            banggia.handle_bg_command(
                ".bg", SimpleNamespace(), "thread", "group", "customer", client
            )

        self.assertEqual(len(client.replies), 1)
        self.assertLessEqual(len(client.replies[0][0]), banggia.MAX_PAGE_TEXT_LENGTH)

    def test_concurrent_bg_next_requests_advance_to_distinct_pages(self):
        catalog = {
            f"p{index:02d}": {"name": f"Product {index:02d}", "packages": []}
            for index in range(1, 18)
        }
        target = SimpleNamespace()
        setup_client = FakeClient()
        with patch.object(banggia, "load_catalog", return_value=catalog), patch.object(
            banggia,
            "load_shop_config",
            return_value={"store_name": "TEST"},
        ):
            banggia.handle_bg_command(
                ".bg", target, "thread", "group", "customer", setup_client
            )

            barrier = threading.Barrier(2)

            class BlockingClient(FakeClient):
                def replyMessage(self, message, reply_target, thread_id, thread_type, ttl=0):
                    barrier.wait(timeout=2)
                    super().replyMessage(message, reply_target, thread_id, thread_type, ttl)

            client = BlockingClient()
            workers = [
                threading.Thread(
                    target=banggia.handle_bg_command,
                    args=(
                        ".bg next",
                        target,
                        "thread",
                        "group",
                        "customer",
                        client,
                    ),
                )
                for _ in range(2)
            ]
            for worker in workers:
                worker.start()
            for worker in workers:
                worker.join(timeout=3)

        page_headers = sorted(
            line
            for reply_text, _, _ in client.replies
            for line in reply_text.splitlines()
            if line.startswith("📄 Trang ") and "/" in line
        )
        self.assertEqual(page_headers, ["📄 Trang 2/3", "📄 Trang 3/3"])

    def test_bg_rejects_page_outside_the_available_range(self):
        catalog = {
            f"p{index:02d}": {"name": f"Product {index:02d}", "packages": []}
            for index in range(1, 11)
        }
        client = FakeClient()
        with patch.object(banggia, "load_catalog", return_value=catalog):
            banggia.handle_bg_command(
                ".bg 3", SimpleNamespace(), "thread", "group", "customer", client
            )

        self.assertEqual(len(client.replies), 1)
        self.assertIn("từ 1 đến 2", client.replies[0][0])

    def test_bg_product_lookup_still_bypasses_pagination(self):
        catalog = {
            "p01": {"name": "Product 01", "packages": []},
            "special-product": {"name": "Special Product", "packages": []},
            **{
                f"p{index:02d}": {"name": f"Product {index:02d}", "packages": []}
                for index in range(2, 11)
            },
        }
        client = FakeClient()
        with patch.object(banggia, "load_catalog", return_value=catalog), patch.object(
            banggia,
            "load_shop_config",
            return_value={"store_name": "TEST"},
        ):
            banggia.handle_bg_command(
                ".bg special_product",
                SimpleNamespace(),
                "thread",
                "group",
                "customer",
                client,
            )

        reply_text = client.replies[0][0]
        self.assertIn("(special-product)", reply_text)
        self.assertNotIn("(p01)", reply_text)
        self.assertNotIn("Trang 1/", reply_text)

    def test_bg_numeric_product_code_takes_priority_over_page_number(self):
        catalog = {
            "2": {"name": "Numeric Product", "packages": []},
            **{
                f"p{index:02d}": {"name": f"Product {index:02d}", "packages": []}
                for index in range(1, 10)
            },
        }
        client = FakeClient()
        with patch.object(banggia, "load_catalog", return_value=catalog), patch.object(
            banggia,
            "load_shop_config",
            return_value={"store_name": "TEST"},
        ):
            banggia.handle_bg_command(
                ".bg 2", SimpleNamespace(), "thread", "group", "customer", client
            )

        self.assertIn("(2)", client.replies[0][0])
        self.assertNotIn("Trang 2/", client.replies[0][0])

    def test_bg_page_keyword_selects_page_when_code_is_numeric(self):
        catalog = {
            "2": {"name": "Numeric Product", "packages": []},
            **{
                f"p{index:02d}": {"name": f"Product {index:02d}", "packages": []}
                for index in range(1, 10)
            },
        }
        client = FakeClient()
        with patch.object(banggia, "load_catalog", return_value=catalog), patch.object(
            banggia,
            "load_shop_config",
            return_value={"store_name": "TEST"},
        ):
            banggia.handle_bg_command(
                ".bg page 2", SimpleNamespace(), "thread", "group", "customer", client
            )

        self.assertIn("Trang 2/2", client.replies[0][0])

    def test_shop_menu_documents_bg_pagination(self):
        menu_text = menu.render_shop_menu(".")
        self.assertIn(".bg next", menu_text)
        self.assertIn(".bg <trang>", menu_text)

    def test_bg_message_uses_icons_for_actions_and_navigation(self):
        catalog = {
            "chatgpt": {
                "name": "ChatGPT Plus",
                "packages": [{"number": "1", "content": "Gói một tháng"}],
            }
        }
        with patch.object(
            banggia,
            "load_shop_config",
            return_value={"store_name": "TEST"},
        ):
            reply_text = banggia.render_price_list(
                catalog,
                ".",
                page_number=2,
                total_pages=3,
            )

        self.assertIn("📄 Trang 2/3", reply_text)
        self.assertIn("🛒 Xem/chọn gói", reply_text)
        self.assertIn("➡️ Trang tiếp", reply_text)
        self.assertIn("⬅️ Trang trước", reply_text)
        self.assertIn("🔢 Chọn trang", reply_text)
        self.assertIn("🔎 Tìm sản phẩm", reply_text)

    def test_sales_contact_falls_back_to_phone_without_user_id(self):
        client = FakeClient()
        with patch.object(
            mua,
            "load_shop_config",
            return_value={"contact": {"phone": "0900000000"}},
        ):
            mua.send_sales_contact(
                client,
                "thread",
                "group",
                SimpleNamespace(),
            )
        self.assertEqual(len(client.replies), 1)
        self.assertIn("0900000000", client.replies[0][0])

    def test_payment_template_builds_amount_and_qr_action(self):
        context, action = mau.build_template_context("thanhtoan", "100k CHATGPT-2", ".")
        self.assertEqual(context["amount_vnd"], "100.000đ")
        self.assertRegex(
            context["transfer_note"],
            r"^CHATGPT-2 MT\d{12}[A-F0-9]{6}$",
        )
        self.assertEqual(action, ("payment", 100_000, context["transfer_note"]))

    def test_payment_template_replies_to_customer_and_sends_qr(self):
        client = FakeClient()
        quoted_message = SimpleNamespace(
            ownerId="123",
            globalMsgId="456",
            cliMsgId="789",
            msgType="webchat",
            content="Mình mua gói 2",
            ts="111",
        )
        command_message = SimpleNamespace(quote=quoted_message)
        with patch.object(mau, "is_sales_user", return_value=True), patch.object(
            mau, "send_payment_qr"
        ) as send_qr:
            mau.handle_mau_command(
                ".mau tt 100k CHATGPT-2",
                command_message,
                "thread",
                "group",
                "sale",
                client,
            )
        self.assertEqual(len(client.replies), 1)
        self.assertIn("100.000đ", client.replies[0][0])
        self.assertEqual(client.replies[0][1].uidFrom, "123")
        send_qr.assert_called_once()
        sent_note = send_qr.call_args.args[-1]
        self.assertTrue(send_qr.call_args.kwargs["prepared_note"])
        self.assertRegex(sent_note, r"^CHATGPT-2 MT\d{12}[A-F0-9]{6}$")
        self.assertIn(sent_note, client.replies[0][0])

    def test_payment_template_does_not_instruct_customer_when_qr_fails(self):
        client = FakeClient()
        command_message = SimpleNamespace(quote=SimpleNamespace())
        with patch.object(mau, "is_sales_user", return_value=True), patch.object(
            mau,
            "send_payment_qr",
            side_effect=RuntimeError("vietqr unavailable"),
        ):
            mau.handle_mau_command(
                ".mau tt 100k CHATGPT-2",
                command_message,
                "thread",
                "group",
                "sale",
                client,
            )
        self.assertEqual(len(client.replies), 1)
        self.assertIs(client.replies[0][1], command_message)
        self.assertIn("chưa gửi hướng dẫn thanh toán", client.replies[0][0])

    def test_transfer_note_never_reuses_a_supplied_reference(self):
        payment_config = {
            "default_note": "THANH TOAN",
            "max_note_length": 50,
        }
        supplied_reference = "MT250101010101ABCDEF"
        prepared = prepare_transfer_note(
            f"CHATGPT-2 {supplied_reference}",
            payment_config,
        )
        self.assertNotIn(supplied_reference, prepared)
        self.assertRegex(prepared, r"^CHATGPT-2 MT\d{12}[A-F0-9]{6}$")

    def test_transfer_note_rejects_too_short_reference_limit(self):
        with self.assertRaises(ValueError):
            prepare_transfer_note(
                "CHATGPT-2",
                {"default_note": "THANH TOAN", "max_note_length": 19},
            )

    def test_stk_handler_passes_normalized_amount_to_qr_sender(self):
        client = FakeClient()
        with patch.object(stk, "send_payment_qr") as send_qr:
            stk.handle_stk_command(
                ".stk 100.000 CHATGPT-2",
                SimpleNamespace(),
                "thread",
                "group",
                "customer",
                client,
            )
        send_qr.assert_called_once_with(client, "thread", "group", 100_000, "CHATGPT-2")

    def test_stk_handler_logs_qr_failure_before_replying(self):
        client = FakeClient()
        with patch.object(
            stk,
            "send_payment_qr",
            side_effect=RuntimeError("upload failed"),
        ), patch.object(stk.logger, "exception") as log_exception:
            stk.handle_stk_command(
                ".stk 100000 CHATGPT-2",
                SimpleNamespace(),
                "thread",
                "group",
                "customer",
                client,
            )

        log_exception.assert_called_once()
        self.assertIn("Không thể tạo QR thanh toán", client.replies[0][0])

    def test_customer_reply_target_maps_quote_fields(self):
        command_message = SimpleNamespace(
            quote=SimpleNamespace(
                ownerId="123",
                globalMsgId="456",
                cliMsgId="789",
                msgType="webchat",
                content="Cần tư vấn ChatGPT",
                ts="111",
            )
        )
        reply_target = mau.get_customer_reply_target(command_message)
        self.assertIsNot(reply_target, command_message)
        self.assertEqual(reply_target.uidFrom, "123")
        self.assertEqual(reply_target.msgId, "456")

    def test_vietqr_payload_contains_amount_and_transfer_note(self):
        payment_config = {
            "bank_code": "970422",
            "account_number": "123456",
            "account_name": "NGUYEN VAN A",
            "qr_template": "compact2",
        }
        payload = stk.build_vietqr_payload(payment_config, 100_000, "CHATGPT-2")
        self.assertEqual(payload["acqId"], "970422")
        self.assertEqual(payload["accountNo"], "123456")
        self.assertEqual(payload["amount"], 100_000)
        self.assertEqual(payload["addInfo"], "CHATGPT-2")

    def test_product_promotion_requires_an_explicit_scope(self):
        promotions = [
            {
                "title": "Legacy global-looking promotion",
                "expiry_date": "31/12/2030",
            },
            {
                "title": "ChatGPT promotion",
                "expiry_date": "31/12/2030",
                "product_codes": ["chatgpt"],
            },
            {
                "title": "All products promotion",
                "expiry_date": "31/12/2030",
                "all_products": True,
            },
        ]
        with patch("sales_utils.load_json_file", return_value=promotions):
            active = get_active_promotions("chatgpt", date(2026, 9, 14))
        self.assertEqual(
            [promotion["title"] for promotion in active],
            ["ChatGPT promotion", "All products promotion"],
        )

    def test_promotion_filter_accepts_datetime(self):
        promotions = [
            {
                "title": "Still active",
                "expiry_date": "14/09/2026",
                "all_products": True,
            }
        ]
        with patch("sales_utils.load_json_file", return_value=promotions):
            active = get_active_promotions(
                current_date=datetime(2026, 9, 14, 23, 59),
            )
        self.assertEqual(len(active), 1)

    def test_promotion_with_invalid_start_date_is_ignored(self):
        promotions = [
            {
                "title": "Invalid start date",
                "start_date": "31/02/2026",
                "expiry_date": "31/12/2030",
                "all_products": True,
            }
        ]
        with patch("sales_utils.load_json_file", return_value=promotions):
            active = get_active_promotions(current_date=date(2026, 9, 14))
        self.assertEqual(active, [])

    def test_admin_promotion_defaults_to_all_products(self):
        client = FakeClient()
        with patch.object(capnhat, "ADMIN", ["admin"]), patch.object(
            capnhat,
            "get_current_date_vn",
            return_value=datetime(2026, 9, 14),
        ), patch.object(capnhat, "load_km_list", return_value=[]), patch.object(
            capnhat,
            "save_km_list",
            return_value=(True, None),
        ) as save_promotions:
            capnhat.handle_capnhat_command(
                ".capnhat 3 Flash sale: Giảm giá cuối tuần",
                SimpleNamespace(),
                "thread",
                "group",
                "admin",
                client,
            )

        saved_promotion = save_promotions.call_args.args[0][0]
        self.assertTrue(saved_promotion["all_products"])
        self.assertEqual(saved_promotion["expiry_date"], "17/09/2026")

    def test_sales_scheduler_reports_cooldown_and_busy(self):
        handler = object.__new__(CommandHandler)
        handler._sales_command_lock = threading.Lock()
        handler._sales_command_slots = threading.BoundedSemaphore(1)
        handler._sales_command_last_used = {}

        with patch("xkprj.time.monotonic", side_effect=[100.0, 100.2]), patch(
            "xkprj.threading.Thread"
        ) as thread_class:
            scheduled, _ = handler._schedule_sales_command(
                "mua", lambda *args: None, ".mua", object(), "thread", "group", "user"
            )
            cooldown, retry_after = handler._schedule_sales_command(
                "mua", lambda *args: None, ".mua", object(), "thread", "group", "user"
            )
        self.assertEqual(scheduled, SALES_SCHEDULED)
        self.assertEqual(cooldown, SALES_COOLDOWN)
        self.assertGreater(retry_after, 0)
        thread_class.return_value.start.assert_called_once()

        busy_handler = object.__new__(CommandHandler)
        busy_handler._sales_command_lock = threading.Lock()
        busy_handler._sales_command_slots = threading.BoundedSemaphore(0)
        busy_handler._sales_command_last_used = {}
        with patch("xkprj.time.monotonic", return_value=200.0):
            busy, retry_after = busy_handler._schedule_sales_command(
                "stk", lambda *args: None, ".stk 100k", object(), "thread", "group", "user"
            )
        self.assertEqual((busy, retry_after), (SALES_BUSY, None))

    def test_sales_limit_notice_is_short_and_rate_limited(self):
        client = FakeClient()
        handler = object.__new__(CommandHandler)
        handler.client = client
        handler._sales_command_lock = threading.Lock()
        handler._sales_limit_notice_last_used = {}
        message_object = SimpleNamespace()

        with patch("xkprj.time.monotonic", side_effect=[100.0, 101.0]):
            handler._notify_sales_limit(
                SALES_COOLDOWN,
                1.2,
                message_object,
                "thread",
                "group",
                "user",
            )
            handler._notify_sales_limit(
                SALES_BUSY,
                None,
                message_object,
                "thread",
                "group",
                "user",
            )
        self.assertEqual(len(client.replies), 1)
        self.assertIn("chờ 2 giây", client.replies[0][0])
        self.assertEqual(client.replies[0][2], 5000)

    def test_every_template_has_a_context_builder(self):
        """Mẫu nào có placeholder mà build_template_context không dựng nổi thì
        lệnh .mau <mã> luôn chết với '❌ <tên placeholder>'. Lỗi này từng xảy ra
        với mẫu 'chotdon'."""
        catalog = {
            "chatgpt": {
                "name": "ChatGPT Plus",
                "packages": [{"number": "1", "content": "Gói một tháng", "price": 100000}],
            }
        }
        sample_arguments = {
            "baogia": "chatgpt",
            "chotdon": "chatgpt 1",
            "thanhtoan": "100000",
            "xacnhan": "100000",
            "giahan": "chatgpt 1",
            "hethang": "chatgpt",
        }
        templates = mau.load_templates()
        self.assertTrue(templates, "không tải được mẫu tin")

        failures = []
        for code, template in templates.items():
            placeholders = set(re.findall(r"{{([a-z0-9_]+)}}", template["content"]))
            if not placeholders:
                continue
            with patch.object(mau, "load_catalog", return_value=catalog), patch.object(
                mau, "load_shop_config",
                return_value={"store_name": "TEST", "payment": {"max_note_length": 50}},
            ), patch.object(mau, "get_active_promotions", return_value=[
                {"title": "KM", "content": "noi dung", "expiry_date": "31/12/2099"}
            ]):
                try:
                    context, _action = mau.build_template_context(
                        code, sample_arguments.get(code, ""), "."
                    )
                    mau.replace_placeholders(template["content"], context)
                except ValueError as error:
                    failures.append(f"{code}: thiếu {error}")
        self.assertEqual(failures, [], f"mẫu không dựng được dữ liệu: {failures}")


if __name__ == "__main__":
    unittest.main()
