from __future__ import annotations

import html
import logging
import re
import secrets
import time as _time
from datetime import datetime, timedelta
from typing import Any

from .clients import ApiError, DeepSeekClient, LaravelApiClient, TelegramClient, TesseractOcrClient
from .config import BotConfig
from .gpos_client import GPOSApiClient
from .manufacturer_dict import ManufacturerDict
from .state_store import PendingActionStore, RateLimitStore

# Jumlah pesan yang disimpan per chat untuk konteks conversational AI
_MAX_CHAT_HISTORY = 10
_LOOKUP_PAGE_SIZE = 5
_LOOKUP_FETCH_LIMIT = 50

# Rate limiter per chat_id
_RATE_LIMIT_WINDOW_SECONDS = 10
_RATE_LIMIT_MAX_REQUESTS = 30
_RATE_LIMIT_CLEANUP_EVERY = 100


class BotService:
    def __init__(self, config: BotConfig) -> None:
        self.config = config
        self.logger = logging.getLogger(__name__)
        self.telegram = TelegramClient(config)
        self.deepseek = DeepSeekClient(config)
        self.ocr = TesseractOcrClient(config)
        self.laravel = LaravelApiClient(config)
        self.gpos = GPOSApiClient(config)
        self.pending_actions = PendingActionStore(config.bot_state_db)
        self.manufacturer_dict = ManufacturerDict()
        self.pending_actions.cleanup()
        # Riwayat chat per chat_id: {chat_id: [{"role": ..., "content": ...}]}
        # Catatan: state ini hilang jika proses WSGI direstart
        self.chat_history: dict[int, list[dict[str, str]]] = {}
        # Rate limiter persistent via SQLite (shared antar Passenger workers)
        self._rate_limiter = RateLimitStore(
            db_path=config.bot_state_db,
            window_seconds=_RATE_LIMIT_WINDOW_SECONDS,
            max_requests=_RATE_LIMIT_MAX_REQUESTS,
        )

    def is_webhook_secret_valid(self, received_secret: str) -> bool:
        expected = self.config.telegram_webhook_secret

        if not expected:
            self.logger.warning("TELEGRAM_WEBHOOK_SECRET tidak dikonfigurasi — webhook menerima request dari sumber manapun.")
            return True

        return secrets.compare_digest(received_secret, expected)

    def handle_update(self, update: dict[str, Any]) -> None:
        try:
            if "callback_query" in update:
                self._handle_callback_query(update["callback_query"])
                return

            if "message" in update:
                self._handle_message(update["message"])
                return

            self.logger.info("Update diabaikan karena format tidak dikenali: %s", update.keys())
        except Exception:
            self.logger.exception("Gagal memproses update Telegram")

    def _handle_message(self, message: dict[str, Any]) -> None:
        chat_id = message["chat"]["id"]

        if self._is_rate_limited(chat_id):
            return

        if not self._is_chat_allowed(chat_id):
            self.telegram.send_message(chat_id, "Chat ini belum diizinkan memakai bot.")
            return

        if not self._ensure_registered_message(message):
            return

        if "photo" in message:
            self._handle_photo_message(message)
            return

        text = (message.get("text") or "").strip()

        if not text:
            self.telegram.send_message(chat_id, "Kirim command, draft SP, screenshot bukti transfer, atau foto faktur dengan caption /faktur.")
            return

        if self._consume_pending_text_flow(message, text):
            return

        if self._consume_lookup_navigation_text(message, text):
            return

        if text.startswith("/start"):
            self.telegram.send_message(chat_id, self._start_message())
            return

        if text.startswith("/help"):
            self.telegram.send_message(chat_id, self._help_message())
            return

        if text.startswith("/reset"):
            self._clear_chat_history(chat_id)
            self.telegram.send_message(chat_id, "Riwayat percakapan direset.")
            return

        lookup_request = self._extract_lookup_request(text)
        if lookup_request:
            if lookup_request["kind"] == "gpos":
                self._handle_gpos_item_lookup(message, lookup_request["query"], mode=str(lookup_request["mode"]))
                return

            self._handle_product_search(message, lookup_request["query"], label=str(lookup_request["label"]))
            return

        if text.startswith("/defecta"):
            self._handle_defecta(message, text)
            return

        if self._looks_like_defecta_text(text):
            self._handle_defecta(message, text)
            return

        if text.lower().startswith("po ") or text.lower().startswith("po\n"):
            self._handle_po_request(message, text)
            return

        if text.startswith("/opname"):
            self._handle_stock_opname(message, text)
            return

        if text.startswith("/screening") or text.lower().startswith("screening "):
            self._handle_screening(message, text)
            return

        if text.startswith("/supplier") or text.lower().startswith("supplier "):
            self._handle_supplier_search(message, text)
            return

        if self._looks_like_purchase_order(text):
            self._handle_purchase_order_message(message, text)
            return

        history_request = self._extract_history_request(text)
        if history_request:
            self._handle_history_request(message, history_request)
            return

        knowledge_request = self._extract_knowledge_request(text)
        if knowledge_request:
            self._handle_knowledge_request(message, knowledge_request)
            return

        mismatch = self.pending_actions.get(self._product_mismatch_token(chat_id))
        if mismatch:
            lookup_selection = self._parse_lookup_multi_selection(text)
            if lookup_selection:
                self._start_defecta_from_lookup(message, lookup_selection)
                return

            defecta_selection = self._parse_defecta_selection(text)
            if defecta_selection:
                self._start_defecta_from_lookup_selection(message, defecta_selection)
                return

            if "belum terdaftar" in text.lower():
                self._explain_product_mismatch(chat_id, mismatch)
                return

        # Cek navigasi halaman: "lagi", "halaman 2", "semua"
        if self._consume_lookup_navigation_text(message, text):
            return

        # Cek seleksi nomor dari lookup sebelumnya: user reply "3" untuk pilih item #3
        if self._consume_lookup_selection_text(message, text):
            return

        # Natural language: nama produk atau PLU → cari & tampilkan info stok+harga
        if self._looks_like_product_query(text):
            self._handle_gpos_item_lookup(message, text, mode="stok")
            return

        # Teks bebas → teruskan ke DeepSeek chat
        if self.config.bot_enable_chat:
            self._handle_free_chat(message, text)
            return

        self.telegram.send_message(chat_id, self._help_message())

    def _handle_callback_query(self, callback_query: dict[str, Any]) -> None:
        callback_id = callback_query["id"]
        data = callback_query.get("data", "")
        message = callback_query.get("message", {})
        chat_id = message.get("chat", {}).get("id")
        actor = callback_query.get("from", {})

        if not chat_id:
            self.telegram.answer_callback_query(callback_id, "Callback tidak valid.")
            return

        if not self._ensure_registered_actor(actor, chat_id, callback_id):
            return

        if data.startswith("confirm_invoice:"):
            action_token = data.split(":", 1)[1]
            self._confirm_invoice(callback_id, chat_id, action_token)
            return

        if data.startswith("cancel_invoice:"):
            action_token = data.split(":", 1)[1]
            self.pending_actions.delete(action_token)
            self.telegram.answer_callback_query(callback_id, "Dibatalkan.")
            self.telegram.send_message(chat_id, "Draft faktur dibatalkan.")
            return

        if data.startswith("confirm_evidence:"):
            action_token = data.split(":", 1)[1]
            self._confirm_payment_evidence(callback_id, chat_id, action_token, actor)
            return

        if data.startswith("cancel_evidence:"):
            action_token = data.split(":", 1)[1]
            self.pending_actions.delete(action_token)
            self.telegram.answer_callback_query(callback_id, "Dibatalkan.")
            self.telegram.send_message(chat_id, "Draft bukti pembayaran dibatalkan.")
            return

        if data.startswith("evidence_bank:"):
            _, evidence_id, supplier_id = data.split(":", 2)
            self._save_supplier_bank_from_evidence(callback_id, chat_id, int(evidence_id), int(supplier_id), actor)
            return

        if data.startswith("po_finalize:"):
            _, session_id, action = data.split(":", 2)
            self._finalize_purchase_order(callback_id, chat_id, int(session_id), action, actor)
            return

        if data.startswith("po_cancel:"):
            _, session_id = data.split(":", 1)
            self._cancel_purchase_order(callback_id, chat_id, int(session_id), actor)
            return

        if data.startswith("po_approve:"):
            _, purchase_order_id = data.split(":", 1)
            self._decide_purchase_order(callback_id, chat_id, int(purchase_order_id), "approve", actor)
            return

        if data.startswith("po_reject:"):
            _, purchase_order_id = data.split(":", 1)
            self._decide_purchase_order(callback_id, chat_id, int(purchase_order_id), "reject", actor)
            return

        if data.startswith("defecta_del:"):
            _, defecta_id = data.split(":", 1)
            self._handle_defecta_delete_callback(callback_id, chat_id, int(defecta_id), actor)
            return

        if data.startswith("defecta_pick:"):
            _, action_token, product_id = data.split(":", 2)
            self._handle_defecta_pick_callback(callback_id, chat_id, action_token, int(product_id), actor)
            return

        if data.startswith("po_ambiguous:"):
            _, action_token = data.split(":", 1)
            self._handle_po_ambiguous_callback(callback_id, chat_id, action_token, actor)
            return

        if data.startswith("gpos_sync:"):
            _, action_token = data.split(":", 1)
            self._handle_gpos_sync_callback(callback_id, chat_id, action_token, actor)
            return

        if data.startswith("history_pick:"):
            _, action_token, product_id = data.split(":", 2)
            self._handle_history_pick_callback(callback_id, chat_id, action_token, int(product_id), actor)
            return

        if data.startswith("composition_variant:"):
            _, chat_id_str, product_id_str, slug = data.split(":", 3)
            self._handle_composition_variant(callback_id, int(chat_id_str), int(product_id_str), slug, actor)
            return

        if data.startswith("composition_yes:"):
            _, chat_id_str, product_id_str = data.split(":", 2)
            self._handle_composition_yes(callback_id, int(chat_id_str), int(product_id_str))
            return

        if data.startswith("composition_no:"):
            _, chat_id_str = data.split(":", 1)
            self._handle_composition_no(callback_id, int(chat_id_str))
            return

        if data.startswith("composition_save:"):
            _, chat_id_str, product_id_str = data.split(":", 2)
            self._handle_composition_save(callback_id, int(chat_id_str), int(product_id_str), actor)
            return

        if data.startswith("composition_skip:"):
            _, chat_id_str = data.split(":", 1)
            self._handle_composition_skip(callback_id, int(chat_id_str))
            return

        if data.startswith("comp_add:"):
            _, chat_id_str, product_id_str = data.split(":", 2)
            self._handle_comp_add(callback_id, int(chat_id_str), int(product_id_str))
            return

        if data.startswith("comp_edit:"):
            _, chat_id_str, product_id_str = data.split(":", 2)
            self._handle_comp_edit_pick(callback_id, int(chat_id_str), int(product_id_str))
            return

        if data.startswith("comp_edit_row:"):
            _, chat_id_str, product_id_str, row_str = data.split(":", 3)
            self._handle_comp_edit_row(callback_id, int(chat_id_str), int(product_id_str), int(row_str))
            return

        if data.startswith("comp_delete:"):
            _, chat_id_str, product_id_str = data.split(":", 2)
            self._handle_comp_delete_pick(callback_id, int(chat_id_str), int(product_id_str))
            return

        if data.startswith("comp_delete_row:"):
            _, chat_id_str, product_id_str, row_str = data.split(":", 3)
            self._handle_comp_delete_row(callback_id, int(chat_id_str), int(product_id_str), int(row_str))
            return

        if data.startswith("comp_save:"):
            _, chat_id_str, product_id_str = data.split(":", 2)
            self._handle_comp_save_dewa(callback_id, int(chat_id_str), int(product_id_str), actor)
            return

        self.telegram.answer_callback_query(callback_id, "Aksi tidak dikenali.")

    def _handle_photo_message(self, message: dict[str, Any]) -> None:
        chat_id = message["chat"]["id"]

        if not self.config.bot_enable_invoice_analysis:
            self.telegram.send_message(chat_id, "Analisis faktur sedang dimatikan.")
            return

        photos = message.get("photo", [])
        if not photos:
            self.telegram.send_message(chat_id, "Foto tidak ditemukan.")
            return

        largest_photo = photos[-1]
        file_id = largest_photo["file_id"]
        caption = message.get("caption")
        wants_invoice = str(caption or "").strip().lower().startswith("/faktur")

        self.telegram.send_message(
            chat_id,
            "Foto diterima. Sedang membaca teks dengan OCR..."
        )

        try:
            image_bytes = self.telegram.get_file_bytes(file_id)
        except Exception as exc:
            self.logger.exception("Gagal mengunduh foto dari Telegram")
            self.telegram.send_message(chat_id, f"Gagal mengunduh foto: {exc}")
            return

        # Langkah 1: OCR dengan Tesseract
        try:
            ocr_text = self.ocr.extract_text(image_bytes)
            self.logger.info("OCR selesai: %d karakter", len(ocr_text))
        except RuntimeError as exc:
            self.logger.error("OCR gagal: %s", exc)
            self.telegram.send_message(
                chat_id,
                f"Gagal membaca teks dari foto: {exc}\n\n"
                "Pastikan foto cukup terang dan teks terlihat jelas.",
            )
            return

        if wants_invoice:
            self.telegram.send_message(chat_id, "Teks terbaca. Sedang menganalisis data faktur...")
            try:
                analysis = self.deepseek.analyze_invoice_text(ocr_text, caption=caption)
            except Exception as exc:
                self.logger.exception("Gagal analisis faktur via DeepSeek")
                self.telegram.send_message(chat_id, f"Gagal menganalisis faktur: {exc}")
                return

            action_token = secrets.token_urlsafe(12)
            self.pending_actions.put(action_token, {
                "type": "purchase_invoice",
                "payload": analysis,
                "ocr_text": ocr_text,
            })

            preview = self._format_invoice_preview(analysis)
            keyboard = {
                "inline_keyboard": [[
                    {"text": "Konfirmasi Simpan", "callback_data": f"confirm_invoice:{action_token}"},
                    {"text": "Batal", "callback_data": f"cancel_invoice:{action_token}"},
                ]]
            }
            self.telegram.send_message(chat_id, preview, reply_markup=keyboard)
            return

        self.telegram.send_message(chat_id, "Teks terbaca. Sedang menganalisis bukti pembayaran...")
        try:
            analysis = self.deepseek.analyze_payment_evidence_text(ocr_text, caption=caption)
        except Exception as exc:
            self.logger.exception("Gagal analisis bukti pembayaran via DeepSeek")
            self.telegram.send_message(chat_id, f"Gagal menganalisis bukti pembayaran: {exc}")
            return

        if analysis.get("document_kind") == "purchase_invoice":
            self.telegram.send_message(
                chat_id,
                "Dokumen ini lebih mirip faktur pembelian. Kirim ulang dengan caption `/faktur` agar masuk flow faktur.",
            )
            return

        action_token = secrets.token_urlsafe(12)
        self.pending_actions.put(action_token, {
            "type": "telegram_evidence",
            "payload": analysis,
            "ocr_text": ocr_text,
            "image_hex": image_bytes.hex(),
            "file_id": file_id,
        })
        preview = self._format_payment_evidence_preview(analysis)
        keyboard = {
            "inline_keyboard": [[
                {"text": "Konfirmasi Simpan", "callback_data": f"confirm_evidence:{action_token}"},
                {"text": "Batal", "callback_data": f"cancel_evidence:{action_token}"},
            ]]
        }
        self.telegram.send_message(chat_id, preview, reply_markup=keyboard)

    def _confirm_invoice(self, callback_id: str, chat_id: int, action_token: str) -> None:
        action = self.pending_actions.pop(action_token)

        if not action:
            self.telegram.answer_callback_query(callback_id, "Draft sudah hilang atau expired.")
            return

        if not self.config.bot_enable_laravel_write:
            self.telegram.answer_callback_query(callback_id, "Mode simpan ke Laravel belum aktif.")
            self.telegram.send_message(
                chat_id,
                "Draft sudah dianalisis, tapi penulisan ke Laravel belum diaktifkan. "
                "Ubah BOT_ENABLE_LARAVEL_WRITE=true setelah endpoint Laravel siap.",
            )
            return

        try:
            result = self.laravel.create_purchase_invoice(action["payload"])
        except RuntimeError as exc:
            self.telegram.answer_callback_query(callback_id, "Flow faktur belum aktif.")
            self.telegram.send_message(
                chat_id,
                "Preview faktur berhasil dibuat, tetapi simpan otomatis ke DEWA untuk flow `/faktur` "
                f"belum aktif.\n\nDetail: {exc}",
            )
            return

        self.telegram.answer_callback_query(callback_id, "Berhasil disimpan.")
        self.telegram.send_message(chat_id, f"Faktur berhasil dikirim ke Laravel.\n\n{result}")

    def _confirm_payment_evidence(
        self,
        callback_id: str,
        chat_id: int,
        action_token: str,
        actor: dict[str, Any],
    ) -> None:
        action = self.pending_actions.pop(action_token)

        if not action:
            self.telegram.answer_callback_query(callback_id, "Draft sudah hilang atau expired.")
            return

        if not self.config.bot_enable_laravel_write:
            self.telegram.answer_callback_query(callback_id, "Mode simpan ke Laravel belum aktif.")
            self.telegram.send_message(chat_id, "Simpan bukti pembayaran ke Laravel belum aktif.")
            return

        image_bytes = bytes.fromhex(action.get("image_hex", ""))
        payload = self._with_actor_from_user(actor, {
            **action["payload"],
            "raw_text": action.get("ocr_text"),
            "telegram_file_id": action.get("file_id"),
        })

        try:
            result = self.laravel.create_telegram_evidence(payload, image_bytes)
        except Exception as exc:
            self.logger.exception("Gagal menyimpan bukti pembayaran")
            self.telegram.answer_callback_query(callback_id, "Gagal menyimpan.")
            self.telegram.send_message(chat_id, f"Gagal menyimpan bukti pembayaran: {exc}")
            return

        self.telegram.answer_callback_query(callback_id, "Bukti tersimpan.")
        evidence = result.get("data") or {}
        lines = [
            "Bukti pembayaran berhasil disimpan.",
            f"Jenis: {evidence.get('classification') or '-'}",
            f"Nominal: {self._format_rupiah(evidence.get('amount'))}",
            f"Status match: {evidence.get('match_status_label') or evidence.get('match_status') or '-'}",
        ]
        if evidence.get("matched_supplier_name"):
            lines.append(f"Supplier match: {evidence['matched_supplier_name']}")

        keyboard_rows: list[list[dict[str, Any]]] = []
        for suggestion in (result.get("supplier_suggestions") or [])[:3]:
            keyboard_rows.append([{
                "text": f"Simpan rekening ke {suggestion.get('name')}",
                "callback_data": f"evidence_bank:{evidence.get('id')}:{suggestion.get('id')}",
            }])
        reply_markup = {"inline_keyboard": keyboard_rows} if keyboard_rows else None
        if keyboard_rows:
            lines.append("")
            lines.append("Rekening belum ada di master. Anda bisa simpan ke supplier yang benar.")
        self.telegram.send_message(chat_id, "\n".join(lines), reply_markup=reply_markup)

    def _handle_product_search(self, message: dict[str, Any], query: str, label: str) -> None:
        chat_id = message["chat"]["id"]
        query = query.strip()

        if not query:
            self.telegram.send_message(chat_id, f"Format: /{label} nama produk")
            return

        try:
            result = self.laravel.search_products(
                self._with_actor(message, {"query": query, "limit": _LOOKUP_FETCH_LIMIT})
            )
        except Exception as exc:
            self.logger.exception("Gagal mencari produk")
            self.telegram.send_message(chat_id, f"Pencarian gagal: {exc}")
            return

        items = result.get("data") or []
        if not items:
            # --- GPOS master fallback: coba GPOS jika DEWA kosong ---
            gpos_items: list[dict[str, Any]] = []
            if self.gpos.is_configured():
                try:
                    gpos_page = self.gpos.search_items_page(query, limit=_LOOKUP_PAGE_SIZE, start=0)
                    gpos_items = gpos_page.get("items") or []
                except Exception:
                    pass
            if gpos_items:
                lines = [f"📦 *Hasil pencarian dari GPOS* (belum di DEWA):"]
                for idx, item in enumerate(gpos_items[:_LOOKUP_PAGE_SIZE], start=1):
                    plu = item.get("plu") or "-"
                    name = item.get("name") or "-"
                    stock = item.get("total_stock") or item.get("stock") or "0"
                    price = item.get("sales_price") or item.get("price") or 0
                    price_str = f"Rp {int(price):,}" if price else "-"
                    lines.append(f"{idx}. {name} — PLU: `{plu}`, Stok: {stock}, Harga: {price_str}")
                lines.append("")
                lines.append("ℹ️ _Gunakan PLU di atas untuk cek stok lengkap: /stok PLU_")
                self.telegram.send_message(chat_id, "\n".join(lines), parse_mode="Markdown")
                return
            self.telegram.send_message(chat_id, f"Tidak ada produk yang cocok untuk: {query}")
            return

        self._store_last_lookup(
            chat_id,
            "product",
            query,
            items[:_LOOKUP_PAGE_SIZE],
            all_items=items,
            total_count=len(items),
            page=1,
            page_size=_LOOKUP_PAGE_SIZE,
            mode=label,
        )
        self._send_lookup_page(chat_id)

    def _handle_gpos_item_lookup(self, message: dict[str, Any], query: str, mode: str) -> None:
        chat_id = message["chat"]["id"]
        query = query.strip()

        if not query:
            self.telegram.send_message(chat_id, f"Format: /{mode} nama produk atau PLU")
            return

        if not self.gpos.is_configured():
            self.telegram.send_message(chat_id, "GPOS belum dikonfigurasi. Isi username/password GPOS dulu.")
            return

        # Pencarian dua fase: query ASLI dulu, expand pabrik hanya jika 0 hasil
        gpos_query = self.manufacturer_dict.expand(query)

        items: list[dict[str, Any]] = []

        # Fase 1: cari dengan query ASLI user
        try:
            result = self.gpos.search_items_page(query, limit=_LOOKUP_FETCH_LIMIT, start=0)
            items = result.get("items") or []
        except Exception as exc:
            self.logger.exception("Gagal mencari item di GPOS")
            self.telegram.send_message(chat_id, f"Pencarian GPOS gagal: {exc}")
            return

        # Fase 2: jika query asli 0 hasil DAN ekspansi berbeda, coba expanded
        if not items and gpos_query != query:
            try:
                result = self.gpos.search_items_page(gpos_query, limit=_LOOKUP_FETCH_LIMIT, start=0)
                items = result.get("items") or []
            except Exception:
                pass

        # --- Fallback ke DEWA jika GPOS 0 hasil ---
        if not items:
            try:
                dewa_result = self.laravel.search_products(
                    self._with_actor(message, {"query": query, "limit": _LOOKUP_FETCH_LIMIT})
                )
                dewa_items = dewa_result.get("data") or []
            except Exception as exc:
                self.logger.exception("Gagal mencari produk di DEWA")
                dewa_items = []

            if dewa_items:
                # Tampilkan hasil DEWA dengan saran cek stok via PLU
                self._store_last_lookup(
                    chat_id,
                    "product",
                    query,
                    dewa_items[:_LOOKUP_PAGE_SIZE],
                    all_items=dewa_items,
                    total_count=len(dewa_items),
                    page=1,
                    page_size=_LOOKUP_PAGE_SIZE,
                    mode=mode,
                )
                self._send_lookup_page(chat_id)
                self.telegram.send_message(
                    chat_id,
                    "ℹ️ Produk di atas dari database DEWA. Gunakan PLU untuk cek stok di GPOS, misal: /stok <code>PLU</code>",
                    parse_mode="HTML",
                )
                return

            self.telegram.send_message(chat_id, f"Tidak ada item yang cocok untuk: {query}")
            return

        # --- Step 4: Tampilkan hasil GPOS ---
        total_count = len(items)
        self._store_last_lookup(
            chat_id,
            "gpos",
            query,
            items[:_LOOKUP_PAGE_SIZE],
            all_items=items,
            total_count=total_count,
            page=1,
            page_size=_LOOKUP_PAGE_SIZE,
            mode=mode,
        )
        self._send_lookup_page(chat_id)

    def _handle_purchase_order_message(self, message: dict[str, Any], text: str) -> None:
        chat_id = message["chat"]["id"]
        parsed = self._parse_purchase_order_text(text)

        if not parsed:
            self.telegram.send_message(chat_id, self._purchase_order_format_help())
            return

        supplier_result = self.laravel.search_suppliers(
            parsed["supplier_query"],
            self._actor_id(message),
        )
        suppliers = supplier_result.get("data") or []

        if not suppliers:
            self.telegram.send_message(chat_id, f"Supplier `{parsed['supplier_query']}` belum saya temukan di DEWA.")
            return

        supplier = suppliers[0]

        # Validasi alamat untuk SP OOT/Prekursor
        alamat_warning = ""
        if parsed["jenis_surat"] in ("OOT", "PREKURSOR"):
            alamat = (supplier.get("address") or "").strip()
            if not alamat or len(alamat) < 15 or not any(c.isalpha() for c in alamat):
                alamat_warning = (
                    f"\n\n⚠️ *Peringatan:* Supplier *{supplier.get('name', '-')}* tidak memiliki alamat lengkap "
                    f"(tercatat: \"{alamat or '(kosong)'}\").\n"
                    f"Untuk SP _{parsed['jenis_surat']}_, alamat jalan supplier *wajib* dicantumkan.\n"
                    f"Silakan lengkapi alamat supplier di admin panel sebelum SP difinalisasi."
                )

        session_payload = {
            "supplier_id": supplier["id"],
            "supplier_query": parsed["supplier_query"],
            "jenis_surat": parsed["jenis_surat"],
        }
        # Sertakan tanggal_surat jika dideteksi dari format informal
        if parsed.get("tanggal_surat"):
            session_payload["tanggal_surat"] = parsed["tanggal_surat"]
        session_result = self.laravel.start_purchase_order_session(
            self._with_actor(message, session_payload)
        )
        session = session_result.get("data") or {}
        session_id = session.get("id")

        if not session_id:
            self.telegram.send_message(chat_id, "Gagal menyiapkan draft SP. Coba lagi.")
            return

        added_items: list[dict[str, Any]] = []
        ambiguous_items: list[dict[str, Any]] = []
        pending_candidates: list[dict[str, Any]] = []
        rejected_items: list[str] = []

        for line in parsed["items"]:
            line_data = self._parse_item_line(line)
            if not line_data:
                rejected_items.append(f"- {line} (format item belum terbaca)")
                continue

            # Ekspansi singkatan pabrik (hj→hexpharm jaya, dexa→dexa medica, dll)
            expanded_query = self.manufacturer_dict.expand(line_data["name"])
            search_result = self.laravel.search_products(
                self._with_actor(
                    message,
                    {
                        "query": expanded_query,
                        "original_query": line_data["name"],
                        "supplier_id": supplier["id"],
                        "expected_classification": parsed["jenis_surat"],
                        "requested_unit": line_data.get("unit"),
                        "limit": 5,
                    },
                )
            )
            matches = search_result.get("data") or []

            if not matches:
                candidate = self.laravel.create_product_candidate(
                    self._with_actor(
                        message,
                        {
                            "supplier_id": supplier["id"],
                            "supplier_query": parsed["supplier_query"],
                            "requested_name": line_data["name"],
                            "requested_unit": line_data["unit"],
                            "requested_quantity": line_data["quantity"],
                            "requested_classification": parsed["jenis_surat"],
                            "source_input": line,
                        },
                    )
                )
                pending_candidates.append(
                    {
                        "name": line_data["name"],
                        "candidate_id": candidate.get("data", {}).get("id"),
                    }
                )
                continue

            disallowed_matches = [
                match for match in matches
                if parsed["jenis_surat"] == "BIASA"
                and match.get("classification") in {"PREKURSOR", "OOT", "NARKOTIKA", "PSIKOTROPIKA", "MANUAL_REVIEW"}
            ]
            if disallowed_matches and not [
                match for match in matches
                if match.get("classification") == parsed["jenis_surat"]
            ]:
                labels = ", ".join(sorted({str(match.get("classification") or "-") for match in disallowed_matches}))
                rejected_items.append(
                    f"- {line_data['name']} (terdeteksi sebagai {labels}, tidak bisa masuk sesi SP {parsed['jenis_surat']})"
                )
                continue

            exact_alias = next(
                (
                    match for match in matches
                    if self._normalize_reference(match.get("matched_by")) in {"plu", "barcode", "aliasexact"}
                ),
                None,
            )
            exact_name = next(
                (
                    match for match in matches
                    if self._normalize_reference(match.get("name")) == self._normalize_reference(line_data["name"])
                ),
                None,
            )
            best_match = exact_alias or exact_name
            if best_match is None and len(matches) > 1:
                # Disambiguasi berbasis unit: filter varian tidak kompatibel
                unit_filtered = self._filter_matches_by_unit(matches, line_data.get("unit"))
                if len(unit_filtered) == 1:
                    best_match = unit_filtered[0]
                else:
                    ambiguous_items.append({
                        "source_name": line_data["name"],
                        "requested_quantity": line_data["quantity"],
                        "requested_unit": line_data["unit"],
                        "options": unit_filtered[:5],
                    })
                    continue

            best_match = best_match or matches[0]
            try:
                add_result = self.laravel.add_purchase_order_session_item(
                    session_id,
                    self._with_actor(
                        message,
                        {
                            "product_id": best_match["id"],
                            "requested_quantity": line_data["quantity"],
                            "requested_unit": line_data["unit"],
                            "base_quantity": self._safe_base_quantity(line_data["quantity"]),
                            "base_unit": best_match.get("unit") or line_data["unit"] or "PCS",
                            "conversion_qty": 1,
                            "original_text": line_data["name"],
                        },
                    ),
                )
                added_items.append(add_result.get("item") or {})
            except Exception as exc:
                rejected_items.append(f"- {line_data['name']} ({exc})")

        if ambiguous_items:
            self.pending_actions.put(
                self._purchase_order_review_token(chat_id),
                {
                    "type": "po_review",
                    "session_id": session_id,
                    "supplier_id": supplier["id"],
                    "supplier_name": supplier["name"],
                    "jenis_surat": parsed["jenis_surat"],
                    "added_items": added_items,
                    "pending_candidates": pending_candidates,
                    "rejected_items": rejected_items,
                    "ambiguous_items": ambiguous_items,
                },
            )
            # Build inline keyboard untuk tiap ambiguous item
            keyboard_rows: list[list[dict[str, Any]]] = []
            for item in ambiguous_items:
                source = item["source_name"]
                qty = item.get("requested_quantity", 1)
                unit = item.get("requested_unit", "PCS")
                for idx, opt in enumerate(item.get("options", [])[:5], start=1):
                    name = opt.get("display_name") or opt.get("name") or "-"
                    # Simpan token untuk callback
                    token = secrets.token_urlsafe(10)
                    self.pending_actions.put(token, {
                        "type": "po_ambiguous_pick",
                        "session_id": session_id,
                        "source_name": source,
                        "product_id": opt["id"],
                        "quantity": qty,
                        "unit": unit,
                        "supplier_id": supplier["id"],
                    })
                    keyboard_rows.append([{
                        "text": f"{source} → {name[:35]}",
                        "callback_data": f"po_ambiguous:{token}",
                    }])
            self.telegram.send_message(
                chat_id,
                self._format_purchase_order_review_prompt(
                    supplier["name"],
                    parsed["jenis_surat"],
                    added_items,
                    pending_candidates,
                    rejected_items,
                    ambiguous_items,
                ),
                reply_markup={"inline_keyboard": keyboard_rows},
            )
            return

        if not added_items:
            self.telegram.send_message(
                chat_id,
                self._format_sp_result(
                    supplier["name"],
                    parsed["jenis_surat"],
                    [],
                    pending_candidates,
                    rejected_items,
                    allow_finalize=False,
                ) + alamat_warning,
            )
            return

        preview = self._format_sp_result(
            supplier["name"],
            parsed["jenis_surat"],
            added_items,
            pending_candidates,
            rejected_items,
            allow_finalize=True,
        ) + alamat_warning
        keyboard = {
            "inline_keyboard": [
                [
                    {"text": "Simpan Draft", "callback_data": f"po_finalize:{session_id}:draft"},
                    {"text": "Kirim APJ", "callback_data": f"po_finalize:{session_id}:submit"},
                ],
                [
                    {"text": "Batal", "callback_data": f"po_cancel:{session_id}"},
                ],
            ]
        }
        self.telegram.send_message(chat_id, preview, reply_markup=keyboard)

    def _finalize_purchase_order(
        self,
        callback_id: str,
        chat_id: int,
        session_id: int,
        action: str,
        actor: dict[str, Any],
    ) -> None:
        submit_to_apj = action == "submit"

        try:
            result = self.laravel.finalize_purchase_order_session(
                session_id,
                self._with_actor_from_user(
                    actor,
                    {
                        "submit_to_apj": submit_to_apj,
                    },
                ),
            )
        except Exception as exc:
            self.telegram.answer_callback_query(callback_id, "Gagal memproses draft.")
            self.telegram.send_message(chat_id, f"Gagal finalize draft SP: {exc}")
            return

        data = result.get("data") or {}
        nomor = data.get("nomor_surat", "-")
        status = data.get("status", "-")
        jenis = data.get("jenis_surat", "-")
        supplier = data.get("supplier_name", "-")

        self.telegram.answer_callback_query(callback_id, "Draft berhasil diproses.")
        self.telegram.send_message(
            chat_id,
            f"SP {jenis} berhasil diproses.\nNomor: {nomor}\nSupplier: {supplier}\nStatus: {status}",
        )

        for approver in data.get("approval_targets", []):
            approver_chat_id = approver.get("telegram_user_id")
            if not approver_chat_id:
                continue

            keyboard = {
                "inline_keyboard": [
                    [
                        {"text": "Setujui", "callback_data": f"po_approve:{data.get('id')}"},
                        {"text": "Tolak", "callback_data": f"po_reject:{data.get('id')}"},
                    ]
                ]
            }
            self.telegram.send_message(
                int(approver_chat_id),
                "Perlu approval SP dari bot Telegram.\n"
                f"Nomor: {nomor}\n"
                f"Jenis: {jenis}\n"
                f"Supplier: {supplier}",
                reply_markup=keyboard,
            )

    def _cancel_purchase_order(self, callback_id: str, chat_id: int, session_id: int, actor: dict[str, Any]) -> None:
        try:
            self.laravel.cancel_purchase_order_session(
                session_id,
                self._with_actor_from_user(actor, {}),
            )
        except Exception as exc:
            self.telegram.answer_callback_query(callback_id, "Gagal membatalkan.")
            self.telegram.send_message(chat_id, f"Gagal membatalkan draft SP: {exc}")
            return

        self.telegram.answer_callback_query(callback_id, "Draft dibatalkan.")
        self.telegram.send_message(chat_id, "Draft SP dibatalkan.")

    def _decide_purchase_order(
        self,
        callback_id: str,
        chat_id: int,
        purchase_order_id: int,
        action: str,
        actor: dict[str, Any],
    ) -> None:
        try:
            result = self.laravel.decide_purchase_order(
                purchase_order_id,
                self._with_actor_from_user(actor, {"action": action}),
            )
        except Exception as exc:
            self.telegram.answer_callback_query(callback_id, "Gagal memproses approval.")
            self.telegram.send_message(chat_id, f"Gagal memproses approval SP: {exc}")
            return

        self.telegram.answer_callback_query(callback_id, "Aksi approval berhasil.")
        self.telegram.send_message(chat_id, result.get("message", "Aksi approval selesai."))

    # ─── CONVERSATIONAL AI ────────────────────────────────────────────────────

    # ── Chat history helpers (persisten via pending_actions TTL) ─────────────

    def _chat_history_token(self, chat_id: int) -> str:
        return f"chat_history:{chat_id}"

    def _load_chat_history(self, chat_id: int) -> list[dict[str, str]]:
        """
        Ambil riwayat dari pending_actions (persisten, TTL 8 jam).
        Fallback ke in-memory cache bila store tidak tersedia.
        """
        stored = self.pending_actions.get(self._chat_history_token(chat_id))
        if stored and isinstance(stored.get("history"), list):
            return stored["history"]
        # Sinkronkan ke in-memory bila ada di sana (sesi yang sedang berjalan)
        return self.chat_history.get(chat_id, [])

    def _save_chat_history(self, chat_id: int, history: list[dict[str, str]]) -> None:
        """Simpan riwayat ke pending_actions dengan TTL 8 jam."""
        self.chat_history[chat_id] = history  # update in-memory juga
        self.pending_actions.put(
            self._chat_history_token(chat_id),
            {"type": "chat_history", "history": history},
            ttl_minutes=480,
        )

    # ── Guardrail operasional ─────────────────────────────────────────────────

    _OPERATIONAL_KEYWORDS = frozenset([
        "obat", "produk", "stok", "defecta", "sp", "surat pesanan",
        "faktur", "invoice", "gpos", "dewa", "apotek", "farmasi",
        "beli", "jual", "harga", "supplier", "distributor",
        "batch", "payment", "pelunasan", "klasifikasi",
        "narkotika", "psikotropika", "prekursor", "oot",
        "vitamin", "suplemen", "alkes", "bmhp", "herbal",
        "dosis", "aturan minum", "zat aktif", "kandungan",
    ])

    def _is_operasional_context(self, text: str) -> bool:
        """
        Apakah pesan ini masih dalam konteks operasional DEWA/apotek?
        Cukup satu kata kunci farmasi/operasional untuk lolos.
        """
        lowered = text.lower()
        return any(kw in lowered for kw in self._OPERATIONAL_KEYWORDS)

    def _handle_free_chat(self, message: dict[str, Any], text: str) -> None:
        """
        Teruskan pesan bebas ke DeepSeek chat.
        Guardrail: bila tidak ada konteks operasional, tolak dengan sopan.
        Riwayat disimpan ke pending_actions (persisten lintas restart, TTL 8 jam).
        """
        chat_id = message["chat"]["id"]
        max_h   = self.config.chat_max_history

        recent_defecta = self.pending_actions.get(f"defecta_recent:{chat_id}") or {}
        recent_age = int(_time.time()) - int(recent_defecta.get("created_at") or 0) if recent_defecta else 0
        if text.strip().isdigit() and recent_defecta and recent_age <= 600:
            product_name = recent_defecta.get("product_name") or "produk sebelumnya"
            self.telegram.send_message(
                chat_id,
                f"Defecta untuk {product_name} sudah selesai dicatat.\n"
                "Kalau mau tambah lagi, kirim nama produk baru beserta jumlahnya.",
            )
            return
        if recent_defecta and recent_age > 600:
            self.pending_actions.delete(f"defecta_recent:{chat_id}")

        # Guardrail: tolak pertanyaan di luar konteks operasional DEWA/apotek
        if not self._is_operasional_context(text):
            self.telegram.send_message(
                chat_id,
                "Saya adalah asisten operasional apotek DEWA. "
                "Saya hanya bisa membantu hal-hal seputar obat, produk, stok, defecta, "
                "surat pesanan, faktur, dan operasional apotek lainnya.\n\n"
                "Silakan ajukan pertanyaan yang berkaitan dengan sistem DEWA atau apotek.",
            )
            return

        history = self._load_chat_history(chat_id)

        try:
            result = self.laravel.free_chat(
                self._with_actor(message, {
                    "query":   text,
                    "history": history,
                })
            )
            reply = (result.get("data") or {}).get("answer") or "Saya tidak mendapat jawaban dari asisten."
        except Exception as exc:
            self.logger.exception("Gagal free chat via Laravel")
            self.telegram.send_message(chat_id, f"Maaf, saya tidak bisa merespons saat ini: {exc}")
            return

        history.append({"role": "user", "content": text})
        history.append({"role": "assistant", "content": reply})

        # Batasi panjang riwayat (simpan pasangan user+assistant)
        if len(history) > max_h * 2:
            history = history[-(max_h * 2):]

        self._save_chat_history(chat_id, history)
        self.telegram.send_message(chat_id, reply)

    def _clear_chat_history(self, chat_id: int) -> None:
        """Reset riwayat chat untuk chat_id tertentu (in-memory dan store)."""
        self.chat_history.pop(chat_id, None)
        self.pending_actions.delete(self._chat_history_token(chat_id))

    # ─── DEFECTA ──────────────────────────────────────────────────────────────

    def _handle_defecta(self, message: dict[str, Any], text: str) -> None:
        chat_id = message["chat"]["id"]
        normalized = text.strip()
        if normalized.startswith("/"):
            normalized = normalized[1:]

        command_body = normalized[len("defecta"):].strip() if normalized.lower().startswith("defecta") else normalized
        lowered = command_body.lower()

        # Jika terdeteksi dari keyword natural (bukan prefix "defecta"), strip keyword
        if not normalized.lower().startswith("defecta"):
            for kw in self._DEFECTA_NATURAL_KEYWORDS:
                kw_lower = kw.lower()
                if lowered.endswith(f" {kw_lower}"):
                    command_body = command_body[:-(len(kw) + 1)].strip()
                    break
                elif lowered.startswith(f"{kw_lower} "):
                    command_body = command_body[len(kw) + 1:].strip()
                    break
                # Keyword di tengah: "alco kosong 2 botol" → "alco 2 botol"
                elif f" {kw_lower} " in lowered:
                    command_body = lowered.replace(f" {kw_lower} ", " ").strip()
                    break

        if not command_body or lowered == "list":
            self._defecta_list_open(message)
            return

        if lowered in ("today", "hari ini"):
            self._defecta_list_open(message, date="today")
            return

        if lowered == "ordered":
            self._defecta_list_ordered(message)
            return

        if lowered in ("dipesan", "status", "status pesanan", "dipesan belum sampai"):
            self._defecta_list_ordered_status(message)
            return

        # --- Date-based filters ---
        if lowered == "kemarin":
            yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
            self._defecta_list_open(message, date=yesterday)
            return

        if lowered == "minggu lalu":
            week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
            self._defecta_list_open(message, date=week_ago)
            return

        if lowered == "bulan ini":
            first_of_month = datetime.now().strftime("%Y-%m-01")
            self._defecta_list_open(message, date=first_of_month)
            return

        if lowered.startswith("tanggal "):
            date_str = command_body[len("tanggal "):].strip()
            parsed = self._parse_indonesian_date(date_str)
            if parsed:
                self._defecta_list_open(message, date=parsed)
                return
            # Jika gagal parse, fallback ke pesan bantuan
            self.telegram.send_message(
                chat_id, "Format tanggal tidak dikenali. Contoh: defecta tanggal 5 april 2026"
            )
            return

        if lowered.startswith("hapus ") or lowered.startswith("delete "):
            body = command_body[command_body.lower().find(" ") + 1:].strip()
            self._defecta_delete(message, body)
            return

        if lowered.startswith("batalkan "):
            body = command_body[command_body.lower().find(" ") + 1:].strip()
            self._defecta_delete(message, body)
            return

        if lowered == "add":
            self.telegram.send_message(
                chat_id,
                "Format: /defecta add <nama produk> [jumlah]\n"
                "Contoh: /defecta add Paracetamol 100\n"
                "Atau cukup: defecta Cendo Xitrol",
            )
            return

        if lowered.startswith("add "):
            self._defecta_add(message, command_body[4:].strip())
            return

        # Multi-line defecta: setiap baris diproses sebagai produk terpisah
        if "\n" in command_body:
            lines = [l.strip() for l in command_body.strip().split("\n") if l.strip()]
            if lines:
                self.telegram.send_message(chat_id, f"⏳ Memproses {len(lines)} produk defecta...")
                for line in lines:
                    self._defecta_add(message, line)
            else:
                self.telegram.send_message(chat_id, "Format tidak dikenali. Ketik defecta untuk bantuan.")
            return

        self._defecta_add(message, command_body)

    def _defecta_list_open(self, message: dict[str, Any], date: str = "") -> None:
        chat_id = message["chat"]["id"]
        try:
            result = self.laravel.get_open_defecta(self._actor_id(message), date=date)
        except Exception as exc:
            self.logger.exception("Gagal mengambil daftar defecta")
            self.telegram.send_message(chat_id, f"Gagal mengambil daftar defecta: {exc}")
            return

        items = result.get("data") or []
        if not items:
            label = "hari ini " if date == "today" else ""
            self.telegram.send_message(chat_id, f"Tidak ada defecta {label}yang sedang menunggu.")
            return

        # Simpan daftar defecta terakhir untuk referensi hapus-by-nomor
        self.pending_actions.put(
            f"defecta_list:{chat_id}",
            {"type": "defecta_list", "items": items, "date": date},
            ttl_minutes=15,
        )

        label = "Hari Ini " if date == "today" else ""
        lines = [f"Defecta {label}Menunggu ({len(items)} item):"]
        keyboard_rows: list[list[dict[str, Any]]] = []
        for idx, item in enumerate(items[:20], start=1):
            product_name = (
                item.get("display_name")
                or item.get("product_name")
                or (item.get("product") or {}).get("name")
                or "-"
            )
            jumlah = item.get("jumlah_diminta", "?")
            satuan_diminta = item.get("satuan_diminta") or ""
            product_unit = item.get("product_unit") or ""
            sisa_stok = item.get("sisa_stok")
            # Ambil stok live dari GPOS jika tersedia (cache 60 detik per PLU)
            gpos_stock = None
            plu = item.get("product_plu") or ""
            if plu and self.gpos.is_configured():
                cache_key = f"gpos_stock:{plu}"
                cached = self.pending_actions.get(cache_key)
                now_ts = _time.time()
                if cached and (cached.get("ts") or 0) > now_ts - 60:
                    gpos_stock = cached.get("stock")
                else:
                    try:
                        results = self.gpos.search_items(plu, limit=1)
                        if results:
                            gpos_stock = (
                                results[0].get("total_stock")
                                or results[0].get("stock")
                            )
                        self.pending_actions.put(
                            cache_key,
                            {"stock": gpos_stock, "ts": now_ts},
                            ttl_minutes=2,
                        )
                    except Exception:
                        pass  # GPOS gagal, fallback ke sisa_stok tersimpan
            prioritas = item.get("prioritas") or "NORMAL"
            customer = item.get("customer_name") or ""
            line = f"{idx}. {product_name} | Diminta: {jumlah}"
            # Tampilkan satuan + info kemasan hanya jika berbeda dari satuan default produk
            if satuan_diminta and satuan_diminta.lower() != product_unit.lower():
                line += f" {satuan_diminta}"
                # Tambah info isi kemasan jika tersedia
                packaging = self._describe_packaging(item, satuan_diminta, product_unit)
                if packaging:
                    line += packaging
            # Tampilkan stok: GPOS live jika ada, fallback ke sisa_stok tersimpan
            if gpos_stock is not None:
                line += f" | Sisa: {gpos_stock} {product_unit or 'PCS'}"
            elif sisa_stok is not None:
                line += f" | Sisa: {sisa_stok}"
            line += f" | {prioritas}"
            if customer:
                cust_phone = item.get("customer_phone")
                if cust_phone:
                    line += f" | 🧑 {customer} ({cust_phone})"
                else:
                    line += f" | 🧑 {customer}"
            lines.append(line)
            # Tombol hapus per item
            keyboard_rows.append([{
                "text": f"🗑 {idx}. {product_name[:25]}",
                "callback_data": f"defecta_del:{item['id']}",
            }])

        if len(items) > 20:
            lines.append(f"... dan {len(items) - 20} lainnya")

        self.telegram.send_message(
            chat_id,
            "\n".join(lines),
            reply_markup={"inline_keyboard": keyboard_rows},
        )

    def _defecta_list_ordered(self, message: dict[str, Any]) -> None:
        chat_id = message["chat"]["id"]
        try:
            result = self.laravel.get_ordered_defecta(self._actor_id(message))
        except Exception as exc:
            self.logger.exception("Gagal mengambil daftar defecta ordered")
            self.telegram.send_message(chat_id, f"Gagal mengambil daftar defecta: {exc}")
            return

        items = result.get("data") or []
        if not items:
            self.telegram.send_message(chat_id, "Tidak ada defecta yang sudah dipesan.")
            return

        lines = [f"Defecta Sudah Dipesan ({len(items)} item):"]
        for item in items[:20]:
            product_name = (
                item.get("display_name")
                or item.get("product_name")
                or (item.get("product") or {}).get("name")
                or "-"
            )
            jumlah = item.get("jumlah_diminta", "?")
            satuan_diminta = item.get("satuan_diminta") or ""
            product_unit = item.get("product_unit") or ""
            sisa_stok = item.get("sisa_stok")
            # Ambil stok live dari GPOS jika tersedia (cache 60 detik per PLU)
            gpos_stock = None
            plu = item.get("product_plu") or ""
            if plu and self.gpos.is_configured():
                cache_key = f"gpos_stock:{plu}"
                cached = self.pending_actions.get(cache_key)
                now_ts = _time.time()
                if cached and (cached.get("ts") or 0) > now_ts - 60:
                    gpos_stock = cached.get("stock")
                else:
                    try:
                        results = self.gpos.search_items(plu, limit=1)
                        if results:
                            gpos_stock = (
                                results[0].get("total_stock")
                                or results[0].get("stock")
                            )
                        self.pending_actions.put(
                            cache_key,
                            {"stock": gpos_stock, "ts": now_ts},
                            ttl_minutes=2,
                        )
                    except Exception:
                        pass
            customer = item.get("customer_name") or ""
            supplier = item.get("ordered_to_supplier") or ""
            line = f"- {product_name} | Diminta: {jumlah}"
            # Tampilkan satuan + info kemasan hanya jika berbeda dari satuan default produk
            if satuan_diminta and satuan_diminta.lower() != product_unit.lower():
                line += f" {satuan_diminta}"
                packaging = self._describe_packaging(item, satuan_diminta, product_unit)
                if packaging:
                    line += packaging
            # Tampilkan stok: GPOS live jika ada, fallback ke sisa_stok tersimpan
            if gpos_stock is not None:
                line += f" | Sisa: {gpos_stock} {product_unit or 'PCS'}"
            elif sisa_stok is not None:
                line += f" | Sisa: {sisa_stok}"
            if supplier:
                line += f" | Ke: {supplier}"
            if customer:
                cust_phone = item.get("customer_phone")
                if cust_phone:
                    line += f" | 🧑 {customer} ({cust_phone})"
                else:
                    line += f" | 🧑 {customer}"
            lines.append(line)

        if len(items) > 20:
            lines.append(f"... dan {len(items) - 20} lainnya")

        self.telegram.send_message(chat_id, "\n".join(lines))

    def _defecta_list_ordered_status(self, message: dict[str, Any]) -> None:
        """Tampilkan defecta ORDERED dengan status pengiriman dari GPOS."""
        chat_id = message["chat"]["id"]
        try:
            result = self.laravel.get_ordered_defecta(self._actor_id(message))
        except Exception as exc:
            self.logger.exception("Gagal mengambil daftar defecta ordered")
            self.telegram.send_message(chat_id, f"Gagal mengambil daftar defecta: {exc}")
            return

        items = result.get("data") or []
        if not items:
            self.telegram.send_message(chat_id, "Tidak ada defecta yang sudah dipesan.")
            return

        gpos = getattr(self, "gpos", None)
        if not gpos:
            # Tanpa GPOS, tampilkan list biasa
            lines = [f"📦 *Status Pesanan Defecta* ({len(items)} item):", ""]
            lines.append("_GPOS tidak terhubung — tidak bisa cek status faktur._")
            lines.append("")
            for item in items[:50]:
                product_name = (
                    item.get("display_name")
                    or item.get("product_name")
                    or (item.get("product") or {}).get("name")
                    or "-"
                )
                jumlah = item.get("jumlah_diminta", "?")
                lines.append(f"• *{product_name}* | {jumlah}")
            if len(items) > 50:
                lines.append(f"... dan {len(items) - 50} lainnya")
            self.telegram.send_message(chat_id, "\n".join(lines), parse_mode="Markdown")
            return

        self.telegram.send_message(chat_id, f"🔍 Memeriksa status faktur {len(items)} item di GPOS...")

        lines = [f"📦 *Status Pesanan Defecta* ({len(items)} item):", ""]
        sampai_count = 0
        belum_count = 0
        error_count = 0

        for item in items[:50]:
            product_name = (
                item.get("display_name")
                or item.get("product_name")
                or (item.get("product") or {}).get("name")
                or "-"
            )
            plu = (item.get("product") or {}).get("plu") or ""
            jumlah = item.get("jumlah_diminta", "?")

            status_icon = "⏳"
            status_text = "Belum ada faktur"

            try:
                invoices = gpos.get_purchase_history_by_item(
                    item_id=plu,
                    item_name=product_name,
                    limit=1,
                )
                if invoices:
                    latest = invoices[0]
                    inv_date = latest.get("transaction_date") or "-"
                    inv_no = latest.get("purchase_invoice_no") or latest.get("vendor_invoice_no") or "-"
                    status_icon = "✅"
                    status_text = f"Faktur {inv_date} (#{inv_no})"
                    sampai_count += 1
                else:
                    belum_count += 1
            except Exception:
                status_icon = "⚠️"
                status_text = "GPOS error"
                error_count += 1

            plu_info = f" (PLU: `{plu}`)" if plu else ""
            lines.append(f"{status_icon} *{product_name}*{plu_info} | {jumlah}")
            lines.append(f"   _{status_text}_")
            lines.append("")

        if len(items) > 15:
            lines.append(f"... dan {len(items) - 15} lainnya")

        # Ringkasan
        summary_parts = []
        if sampai_count:
            summary_parts.append(f"✅ {sampai_count} sudah ada faktur")
        if belum_count:
            summary_parts.append(f"⏳ {belum_count} belum ada faktur")
        if error_count:
            summary_parts.append(f"⚠️ {error_count} gagal dicek")
        if summary_parts:
            lines.append("—" * 20)
            lines.append(" | ".join(summary_parts))

        self.telegram.send_message(chat_id, "\n".join(lines), parse_mode="Markdown")

    # ── PO Command (Fase 1.2) ──────────────────────────────────────────

    def _handle_po_request(self, message: dict[str, Any], text: str) -> None:
        """Tangani command PO (pre-order pelanggan multi-item).

        Format:
        po <nama> <no_hp> <keterangan?>
        <produk1> <jumlah?> <satuan?>
        <produk2> <jumlah?> <satuan?>
        """
        chat_id = message["chat"]["id"]
        lines = [ln.strip() for ln in text.split("\n") if ln.strip()]

        if len(lines) < 2:
            self.telegram.send_message(
                chat_id,
                "📋 *Format PO (Pre-Order Pelanggan)*\n\n"
                "```\n"
                "po <nama> <no_hp> <keterangan?>\n"
                "<produk1> <jumlah?> <satuan?>\n"
                "<produk2> <jumlah?> <satuan?>\n"
                "```\n\n"
                "Contoh:\n"
                "```\n"
                "po Linda 081398603118\n"
                "albendazole 400 tab\n"
                "permethrine shampoo\n"
                "```\n\n"
                "Bot akan mencari produk di DEWA → GPOS.\n"
                "Produk tidak dikenal dicatat sebagai produk mentah.",
                parse_mode="Markdown",
            )
            return

        # Parse baris pertama — customer info
        first_line = re.sub(r"^po\s+", "", lines[0], flags=re.IGNORECASE)
        customer_name, customer_phone, po_notes = self._parse_po_first_line(first_line)

        if not customer_name:
            self.telegram.send_message(chat_id, "❌ Nama pelanggan wajib diisi.\nContoh: `po Budi 08123456789`")
            return

        if not customer_phone:
            self.telegram.send_message(chat_id, "❌ No HP pelanggan wajib diisi (format 08xx).\nContoh: `po Budi 08123456789`")
            return

        # Parse baris produk
        items: list[dict[str, Any]] = []
        for line in lines[1:]:
            query, qty, satuan = self._parse_product_and_optional_quantity(line)
            if not query:
                continue
            items.append({"query": query, "qty": qty or 1, "satuan": satuan})

        if not items:
            self.telegram.send_message(chat_id, "❌ Minimal satu produk harus dicantumkan setelah baris pelanggan.")
            return

        # Proses setiap item: cascade DEWA → GPOS → raw
        self.telegram.send_message(
            chat_id,
            f"🔍 Mencari {len(items)} produk untuk PO {customer_name}...",
        )

        created: list[dict[str, Any]] = []
        raw_items: list[dict[str, Any]] = []

        for item in items:
            result = self._resolve_and_create_po_item(
                message, item["query"], item["qty"], item["satuan"],
                customer_name, customer_phone, po_notes,
            )
            if result.get("status") == "raw":
                raw_items.append(result)
            created.append(result)

        # Kirim ringkasan
        self._send_po_summary(chat_id, customer_name, customer_phone, created)

    @staticmethod
    def _parse_po_first_line(text: str) -> tuple[str, str, str]:
        """Parse baris pertama PO: <nama> <no_hp> <keterangan?>
        Returns (customer_name, customer_phone, notes).
        """
        phone_match = re.search(r"(08\d{8,11})", text)
        if not phone_match:
            return text.strip(), "", ""

        phone = phone_match.group(1)
        phone_pos = phone_match.start()

        name = text[:phone_pos].strip()
        notes = text[phone_pos + len(phone):].strip()

        return name, phone, notes

    def _resolve_and_create_po_item(
        self,
        message: dict[str, Any],
        query: str,
        qty: int,
        satuan: str | None,
        customer_name: str,
        customer_phone: str,
        po_notes: str | None,
    ) -> dict[str, Any]:
        """Cascade pencarian produk untuk PO: DEWA → GPOS → raw.

        Returns {"status": "found_dewa"|"synced_gpos"|"raw", "name": ..., "qty": ..., "satuan": ...}
        """
        # Step 1: Cari di DEWA
        try:
            result = self.laravel.search_products(
                self._with_actor(message, {"query": query, "limit": 3}),
            )
            matches = result.get("data") or []
        except Exception as exc:
            self.logger.warning("PO: Gagal search_products untuk %s: %s", query, exc)
            matches = []

        if matches:
            product = matches[0]
            pid = product["id"]
            pname = product.get("display_name") or product.get("name") or query
            try:
                payload = {
                    "product_id": pid,
                    "jumlah_diminta": qty,
                    "customer_name": customer_name,
                    "customer_phone": customer_phone,
                    "notes": po_notes or "",
                }
                if satuan:
                    payload["satuan_diminta"] = satuan
                self.laravel.create_defecta(self._with_actor(message, payload))
                return {"status": "found_dewa", "name": pname, "qty": qty, "satuan": satuan}
            except Exception as exc:
                self.logger.exception("PO: Gagal create_defecta DEWA untuk %s", query)
                return {"status": "raw", "name": query, "qty": qty, "satuan": satuan, "error": str(exc)}

        # Step 2: Cari di GPOS → sync → create
        if self.gpos.is_configured():
            try:
                gpos_items = self.gpos.search_items(query, limit=3)
            except Exception as exc:
                self.logger.warning("PO: Gagal search_items GPOS untuk %s: %s", query, exc)
                gpos_items = []

            if gpos_items:
                gpos_item = gpos_items[0]
                plu = str(gpos_item.get("plu") or "")
                gpos_name = gpos_item.get("name") or query

                try:
                    sync_result = self.laravel.sync_product_from_gpos(plu)
                    product = (
                        (sync_result.get("data") or {}).get("product")
                        or sync_result.get("product")
                        or {}
                    )
                except Exception as exc:
                    self.logger.exception("PO: Gagal sync_product_from_gpos untuk %s", plu)
                    product = {}

                if product.get("id"):
                    pid = product["id"]
                    pname = product.get("name") or gpos_name
                    try:
                        payload = {
                            "product_id": pid,
                            "jumlah_diminta": qty,
                            "customer_name": customer_name,
                            "customer_phone": customer_phone,
                            "notes": po_notes or "",
                        }
                        if satuan:
                            payload["satuan_diminta"] = satuan
                        self.laravel.create_defecta(self._with_actor(message, payload))
                        return {"status": "synced_gpos", "name": pname, "qty": qty, "satuan": satuan}
                    except Exception as exc:
                        self.logger.exception("PO: Gagal create_defecta GPOS untuk %s", query)
                        return {"status": "raw", "name": query, "qty": qty, "satuan": satuan, "error": str(exc)}

                # Sync gagal — fallback ke raw
                self.logger.warning("PO: Sync GPOS gagal untuk %s — catat mentah", query)

        # Step 3: Catat sebagai produk mentah (product_id=NULL, product_name_raw)
        try:
            payload: dict[str, Any] = {
                "product_name_raw": query,
                "jumlah_diminta": qty,
                "customer_name": customer_name,
                "customer_phone": customer_phone,
                "notes": (po_notes or "") + " | PO: produk tidak dikenal",
            }
            if satuan:
                payload["satuan_diminta"] = satuan
            self.laravel.create_defecta(self._with_actor(message, payload))
            return {"status": "raw", "name": query, "qty": qty, "satuan": satuan}
        except Exception as exc:
            self.logger.exception("PO: Gagal create_defecta raw untuk %s", query)
            return {"status": "raw", "name": query, "qty": qty, "satuan": satuan, "error": str(exc)}

    def _send_po_summary(
        self,
        chat_id: int,
        customer_name: str,
        customer_phone: str,
        items: list[dict[str, Any]],
    ) -> None:
        """Kirim ringkasan hasil PO."""
        found = [i for i in items if i.get("status") in ("found_dewa", "synced_gpos")]
        raw = [i for i in items if i.get("status") == "raw"]
        errors = [i for i in items if i.get("error")]

        lines = [
            f"✅ *PO {customer_name}* ({customer_phone}) tercatat:",
            "",
        ]

        idx = 1
        for item in found:
            satuan_str = f" {item['satuan']}" if item.get("satuan") else ""
            lines.append(f"{idx}. {item['name']} — {item['qty']}{satuan_str}")
            idx += 1

        if raw:
            lines.append("")
            lines.append(f"⚠️ {len(raw)} produk tidak dikenal — dicatat mentah:")
            for item in raw:
                satuan_str = f" {item['satuan']}" if item.get("satuan") else ""
                lines.append(f"  • {item['name']} — {item['qty']}{satuan_str}")

        if errors:
            lines.append("")
            lines.append(f"❌ {len(errors)} produk gagal dicatat:")
            for item in errors:
                lines.append(f"  • {item['name']}: {item.get('error', 'unknown')}")

        lines.append("")
        lines.append(f"Total {len(items)} item. /defecta untuk melihat daftar.")

        self.telegram.send_message(chat_id, "\n".join(lines), parse_mode="Markdown")

    def _defecta_delete(self, message: dict[str, Any], raw: str) -> None:
        """Hapus defecta by nomor urut dari daftar terakhir."""
        chat_id = message["chat"]["id"]
        try:
            nomor = int(raw.strip())
        except (ValueError, TypeError):
            self.telegram.send_message(chat_id, "Nomor defecta tidak valid. Contoh: /defecta hapus 3")
            return

        pending = self.pending_actions.get(f"defecta_list:{chat_id}")
        items = (pending or {}).get("items") or []
        if not items:
            self.telegram.send_message(chat_id, "Tidak ada daftar defecta terakhir. Ketik /defecta dulu untuk melihat daftar.")
            return

        if nomor < 1 or nomor > len(items):
            self.telegram.send_message(chat_id, f"Nomor harus antara 1 dan {len(items)}.")
            return

        target = items[nomor - 1]
        product_name = target.get("product_name") or "-"
        defecta_id = target.get("id")

        if not defecta_id:
            self.telegram.send_message(chat_id, "Data defecta tidak lengkap, tidak bisa dihapus.")
            return

        try:
            result = self.laravel.delete_defecta(defecta_id, self._actor_id(message))
        except Exception as exc:
            self.telegram.send_message(chat_id, f"Gagal menghapus defecta: {exc}")
            return

        self.telegram.send_message(chat_id, f"✅ Defecta #{nomor} *{product_name}* berhasil dihapus.")

    def _defecta_add(self, message: dict[str, Any], raw: str) -> None:
        chat_id = message["chat"]["id"]
        lookup_selection = self._parse_lookup_multi_selection(raw)
        if lookup_selection:
            self._start_defecta_from_lookup(message, lookup_selection)
            return

        defecta_selection = self._parse_defecta_selection(raw)
        if defecta_selection:
            self._start_defecta_from_lookup_selection(message, defecta_selection)
            return

        product_query, jumlah, satuan = self._parse_product_and_optional_quantity(raw)
        if not product_query:
            self.telegram.send_message(
                chat_id,
                "Format: /defecta add <nama produk> [jumlah] [satuan]\n"
                "Contoh: /defecta add Paracetamol 100\n"
                "/defecta add Insto 3 botol\n"
                "        /defecta add 1 box E000012575",
            )
            return

        # Pencarian dua fase: query ASLI dulu, expand pabrik hanya jika 0 hasil.
        # Ini mencegah token pabrik (HEXPHARM, JAYA, MEDICA) mencemari hasil pencarian.
        expanded_query = self.manufacturer_dict.expand(product_query)

        # Fase 1: cari dengan query ASLI user
        result = None
        try:
            result = self.laravel.search_products(
                self._with_actor(message, {
                    "query": product_query,
                    "limit": 5,
                    "requested_unit": satuan,
                })
            )
        except Exception as exc:
            self.logger.exception("Gagal mencari produk untuk defecta")
            self.telegram.send_message(chat_id, f"Gagal mencari produk: {exc}")
            return

        matches = result.get("data") or [] if result else []

        # Fase 2: jika query asli 0 hasil DAN ekspansi berbeda, coba dengan query expanded
        if (not matches) and expanded_query != product_query:
            try:
                result = self.laravel.search_products(
                    self._with_actor(message, {"query": expanded_query, "limit": 5})
                )
                matches = result.get("data") or [] if result else []
            except Exception:
                pass

        if not matches:
            # Fallback: coba ekstrak PLU dari query jika pencarian gagal
            # "box E000012575" → ekstrak "E000012575" dan cari ulang
            plu_fallback = re.search(r"\b([A-Z]\d{5,})\b", product_query)
            if plu_fallback:
                plu_token = plu_fallback.group(1)
                if plu_token.lower() != product_query.lower():
                    try:
                        result = self.laravel.search_products(
                            self._with_actor(message, {"query": plu_token, "limit": 5})
                        )
                        matches = result.get("data") or [] if result else []
                    except Exception:
                        pass

        if not matches:
            self._handle_missing_defecta_product(message, chat_id, product_query, jumlah, satuan)
            return

        # Pisahkan confirmed (skor ≥ 900: PLU/BARCODE/ALIAS_EXACT) vs ambiguous (skor < 900)
        confirmed = [item for item in matches if (item.get("score") or 0) >= 900]
        ambiguous = [item for item in matches if (item.get("score") or 0) < 900]

        # Auto-pick jika tepat 1 confirmed (abaikan ambiguous)
        if len(confirmed) == 1:
            self._prepare_defecta_creation(message, confirmed[0], jumlah, satuan)
            return

        # Auto-pick jika semua hasil adalah 1 ambiguous
        if not confirmed and len(ambiguous) == 1:
            self._prepare_defecta_creation(message, ambiguous[0], jumlah, satuan)
            return

        action_token = secrets.token_urlsafe(10)
        self.pending_actions.put(action_token, {
            "type": "defecta_pick",
            "query": product_query,
            "jumlah": jumlah,
            "satuan": satuan,
        })
        all_items = (confirmed[:5] + ambiguous[:5])[:5]
        self.pending_actions.put(self._defecta_pick_token(chat_id), {
            "type": "defecta_pick",
            "query": product_query,
            "jumlah": jumlah,
            "satuan": satuan,
            "items": [
                {
                        "id": item["id"],
                        "name": item.get("display_name") or item.get("name") or "-",
                        "plu": item.get("plu") or "-",
                        "unit": item.get("unit") or "-",
                        "preferred_defecta_unit": item.get("preferred_defecta_unit"),
                        "valid_units": item.get("valid_units") or [],
                        "isi_kemasan": item.get("isi_kemasan"),
                    }
                for item in all_items
            ],
        })

        lines: list[str] = []
        keyboard_rows: list[list[dict[str, Any]]] = []
        counter = 0

        if confirmed:
            lines.append("--- COCOK ---")
            lines.append("")
            for item in confirmed[:5]:
                counter += 1
                name = item.get("display_name") or item.get("name") or "-"
                lines.extend([
                    f"{counter}. {name}",
                    self._format_product_choice_meta(item),
                    "",
                ])
                keyboard_rows.append([{
                    "text": f"Cocok: {counter}. {name[:27]}",
                    "callback_data": f"defecta_pick:{action_token}:{item['id']}",
                }])

        if ambiguous:
            if confirmed:
                lines.append("--- MUNGKIN ---")
                lines.append("")
            else:
                lines.append(f"Hasil untuk \"{product_query}\":")
                lines.append("")
            for item in ambiguous[:5]:
                counter += 1
                if counter > 5:
                    break
                name = item.get("display_name") or item.get("name") or "-"
                lines.extend([
                    f"{counter}. {name}",
                    self._format_product_choice_meta(item),
                    "",
                ])
                keyboard_rows.append([{
                    "text": f"Mungkin: {counter}. {name[:24]}",
                    "callback_data": f"defecta_pick:{action_token}:{item['id']}",
                }])

        self.telegram.send_message(
            chat_id,
            "\n".join(lines).strip(),
            reply_markup={"inline_keyboard": keyboard_rows},
        )

    def _create_defecta_for_product(
        self,
        message: dict[str, Any],
        product: dict[str, Any],
        jumlah: int,
        satuan: str | None = None,
        original_query: str | None = None,
    ) -> None:
        chat_id = message["chat"]["id"]
        product_name = product.get("display_name") or product.get("name") or "-"
        valid_units = self._product_valid_units(product)
        preferred_unit = self._product_preferred_unit(product)

        if not satuan and self._product_requires_unit_confirmation(product):
            self.logger.info(
                "Defecta unit confirmation required before create chat_id=%s product_id=%s valid_units=%s",
                chat_id,
                product.get("id"),
                valid_units,
            )
            self._prompt_defecta_unit_confirmation(chat_id, product, jumlah, valid_units)
            return

        # Cek apakah sudah ada defecta hari ini
        try:
            check = self.laravel.check_defecta(product["id"], self._actor_id(message))
            existing = (check.get("data") or {})
            if existing.get("exists"):
                self.telegram.send_message(
                    chat_id,
                    f"Defecta untuk {product_name} sudah ada hari ini.\n"
                    f"Jumlah diminta: {existing.get('jumlah_diminta', '-')}\n"
                    f"Status: {existing.get('status', '-')}",
                )
                return
        except Exception:
            pass  # Jika check gagal, tetap coba buat

        final_satuan = satuan or preferred_unit
        if not final_satuan:
            self.logger.info(
                "Defecta unit unresolved chat_id=%s product_id=%s valid_units=%s",
                chat_id,
                product.get("id"),
                valid_units,
            )
            self._prompt_defecta_unit_confirmation(chat_id, product, jumlah, valid_units)
            return

        try:
            self.laravel.create_defecta(
                self._with_actor(message, {
                    "product_id": product["id"],
                    "jumlah_diminta": jumlah,
                    "satuan_diminta": final_satuan,
                })
            )
            self.logger.info(
                "Defecta created chat_id=%s product_id=%s qty=%s unit=%s preferred=%s",
                chat_id,
                product.get("id"),
                jumlah,
                final_satuan,
                preferred_unit,
            )
            # Bangun pesan sukses dengan info konversi satuan jika relevan
            success_msg = (
                f"Defecta berhasil dicatat.\n"
                f"Produk: {product_name}\n"
                f"Jumlah: {jumlah} {final_satuan}"
            )
            # Tambahkan info konversi jika satuan berbeda dari satuan dasar produk
            product_unit = product.get("unit") or ""
            unit_conv = product.get("unit_conversion")
            if unit_conv and str(unit_conv).replace(".", "").isdigit():
                unit_conv_num = float(unit_conv)
                if unit_conv_num > 1 and satuan and product_unit:
                    normalized_user = str(final_satuan).strip().lower()
                    normalized_product = str(product_unit).strip().lower()
                    if normalized_user and normalized_product and normalized_user != normalized_product:
                        isi = product.get("isi_kemasan") or ""
                        total_base = int(jumlah * unit_conv_num)
                        if isi:
                            success_msg += f"\n📦 1 {final_satuan} = {isi} ({total_base} {product_unit})"
                        else:
                            success_msg += f"\n📦 1 {final_satuan} ≈ {int(unit_conv_num)} {product_unit} (total: {total_base} {product_unit})"
            self.telegram.send_message(chat_id, success_msg)
        except ApiError as exc:
            payload = exc.payload or {}
            code = payload.get("code")
            data = payload.get("data") or {}
            self.logger.warning(
                "Defecta create rejected chat_id=%s product_id=%s code=%s unit=%s payload=%s",
                chat_id,
                product.get("id"),
                code,
                final_satuan,
                payload,
            )
            if code == "UNIT_CONFIRMATION_REQUIRED":
                self._prompt_defecta_unit_confirmation(
                    chat_id,
                    product,
                    jumlah,
                    data.get("valid_units") or valid_units,
                    reason="Satuan perlu dikonfirmasi",
                )
                return
            if code == "UNIT_INVALID":
                self._prompt_defecta_unit_confirmation(
                    chat_id,
                    product,
                    jumlah,
                    data.get("valid_units") or valid_units,
                    reason=str(exc),
                )
                return
            self.logger.exception("Gagal mencatat defecta via API error")
            self.telegram.send_message(chat_id, f"Gagal mencatat defecta: {exc}")
            return
        except Exception as exc:
            self.logger.exception("Gagal mencatat defecta")
            self.telegram.send_message(chat_id, f"Gagal mencatat defecta: {exc}")
            return

        # Self-learning alias: simpan kata kunci asli user sebagai alias produk
        if original_query and original_query.strip():
            alias_candidate = original_query.strip()
            if len(alias_candidate) >= 3:
                # Hanya simpan jika berbeda signifikan dengan nama produk
                normalized_alias = self._normalize_reference(alias_candidate)
                normalized_name = self._normalize_reference(product_name)
                if normalized_alias != normalized_name:
                    self._maybe_save_product_alias(
                        message,
                        product["id"],
                        alias_candidate,
                    )

        self.pending_actions.put(f"defecta_recent:{chat_id}", {
            "type": "defecta_recent",
            "product_name": product_name,
            "created_at": int(_time.time()),
        })

    def _handle_defecta_delete_callback(
        self,
        callback_id: str,
        chat_id: int,
        defecta_id: int,
        actor: dict[str, Any],
    ) -> None:
        try:
            telegram_user_id = str((actor or {}).get("id", ""))
            self.laravel.delete_defecta(defecta_id, telegram_user_id)
            self.telegram.answer_callback_query(callback_id, "Defecta dihapus.")
            # Refresh daftar defecta setelah hapus via callback
            try:
                self._defecta_list_open({"chat": {"id": chat_id}, "from": actor})
            except Exception:
                pass  # Silent fail — refresh is best-effort
        except Exception as exc:
            self.logger.exception("Gagal menghapus defecta")
            self.telegram.answer_callback_query(callback_id, f"Gagal: {exc}")

    def _handle_defecta_pick_callback(
        self,
        callback_id: str,
        chat_id: int,
        action_token: str,
        product_id: int,
        actor: dict[str, Any],
    ) -> None:
        try:
            product_result = self.laravel.get_product(product_id, self._with_actor_from_user(actor, {}))
            product = product_result.get("data") or {}
        except Exception as exc:
            self.logger.exception("Gagal membuka produk defecta")
            self.telegram.answer_callback_query(callback_id, "Produk tidak bisa dibuka.")
            self.telegram.send_message(chat_id, f"Gagal membuka produk defecta: {exc}")
            return

        pending = self.pending_actions.pop(action_token) or {}
        jumlah = pending.get("jumlah")
        satuan = pending.get("satuan")
        self.pending_actions.delete(self._defecta_pick_token(chat_id))

        self.telegram.answer_callback_query(callback_id, "Produk dipilih.")

        satuan = pending.get("satuan")
        if jumlah is None:
            self.pending_actions.put(
                self._defecta_qty_token(chat_id),
                {
                    "type": "defecta_qty",
                    "product": product,
                    "satuan": satuan,
                },
            )
            self.telegram.send_message(
                chat_id,
                f"Berapa jumlah defecta untuk {product.get('display_name') or product.get('name') or '-'}?",
            )
            return

        self._create_defecta_for_user(actor, chat_id, product, int(jumlah), satuan=satuan)

    def _handle_po_ambiguous_callback(
        self,
        callback_id: str,
        chat_id: int,
        action_token: str,
        actor: dict[str, Any],
    ) -> None:
        """Tangani pilihan varian produk dari inline keyboard PO."""
        pending = self.pending_actions.pop(action_token) or {}
        session_id = pending.get("session_id")
        product_id = pending.get("product_id")
        quantity = pending.get("quantity", 1)
        unit = pending.get("unit", "PCS")
        source_name = pending.get("source_name", "")

        if not session_id or not product_id:
            self.telegram.answer_callback_query(callback_id, "Data tidak lengkap.")
            return

        try:
            product_result = self.laravel.get_product(product_id, self._with_actor_from_user(actor, {}))
            product = product_result.get("data") or {}
        except Exception:
            self.telegram.answer_callback_query(callback_id, "Gagal mengambil produk.")
            return

        try:
            add_result = self.laravel.add_purchase_order_session_item(
                session_id,
                self._with_actor_from_user(actor, {
                    "product_id": product_id,
                    "requested_quantity": quantity,
                    "requested_unit": unit,
                    "base_quantity": self._safe_base_quantity(float(quantity)),
                    "base_unit": product.get("unit") or unit,
                    "conversion_qty": 1,
                    "original_text": source_name,
                }),
            )
        except Exception as exc:
            self.telegram.answer_callback_query(callback_id, "Gagal menambah item.")
            self.telegram.send_message(chat_id, f"Gagal menambah {source_name} ke SP: {exc}")
            return

        self.telegram.answer_callback_query(
            callback_id,
            f"{product.get('name', source_name)[:40]} ditambahkan.",
        )

        # Periksa apakah semua ambiguous item sudah di-resolve
        review_pending = self.pending_actions.pop(self._purchase_order_review_token(chat_id))
        if not review_pending:
            self.telegram.send_message(chat_id, "Semua item berhasil diproses.")
            return

        # Hapus item yang sudah dipilih dari daftar ambiguous
        remaining = [
            item for item in (review_pending.get("ambiguous_items") or [])
            if item["source_name"] != source_name
        ]
        review_pending["ambiguous_items"] = remaining

        if not remaining:
            # Semua resolved — tampilkan final review
            added_items = review_pending.get("added_items") or []
            # Tambahkan item yang baru saja dipilih
            item_info = add_result.get("item") or {}
            if item_info:
                added_items.append(item_info)
            preview = self._format_sp_result(
                review_pending["supplier_name"],
                review_pending["jenis_surat"],
                added_items,
                review_pending.get("pending_candidates") or [],
                review_pending.get("rejected_items") or [],
                allow_finalize=True,
            )
            keyboard = {
                "inline_keyboard": [
                    [
                        {"text": "Simpan Draft", "callback_data": f"po_finalize:{session_id}:draft"},
                        {"text": "Kirim APJ", "callback_data": f"po_finalize:{session_id}:submit"},
                    ],
                    [
                        {"text": "Batal", "callback_data": f"po_cancel:{session_id}"},
                    ],
                ]
            }
            self.telegram.send_message(chat_id, preview, reply_markup=keyboard)
        else:
            # Masih ada ambiguous item — simpan kembali & tampilkan sisa
            self.pending_actions.put(
                self._purchase_order_review_token(chat_id), review_pending
            )
            # Rebuild inline keyboard untuk item yang tersisa
            keyboard_rows: list[list[dict[str, Any]]] = []
            for item in remaining:
                for idx, opt in enumerate(item.get("options", [])[:5], start=1):
                    name = opt.get("display_name") or opt.get("name") or "-"
                    token = secrets.token_urlsafe(10)
                    self.pending_actions.put(token, {
                        "type": "po_ambiguous_pick",
                        "session_id": session_id,
                        "source_name": item["source_name"],
                        "product_id": opt["id"],
                        "quantity": item.get("requested_quantity", 1),
                        "unit": item.get("requested_unit", "PCS"),
                        "supplier_id": review_pending.get("supplier_id"),
                    })
                    keyboard_rows.append([{
                        "text": f"{item['source_name']} → {name[:35]}",
                        "callback_data": f"po_ambiguous:{token}",
                    }])
            self.telegram.send_message(
                chat_id,
                self._format_purchase_order_review_prompt(
                    review_pending["supplier_name"],
                    review_pending["jenis_surat"],
                    review_pending.get("added_items") or [],
                    review_pending.get("pending_candidates") or [],
                    review_pending.get("rejected_items") or [],
                    remaining,
                ),
                reply_markup={"inline_keyboard": keyboard_rows},
            )

    def _handle_gpos_sync_callback(
        self,
        callback_id: str,
        chat_id: int,
        action_token: str,
        actor: dict[str, Any],
    ) -> None:
        pending = self.pending_actions.pop(action_token) or {}
        plu = pending.get("plu", "")
        jumlah = pending.get("jumlah")
        satuan = pending.get("satuan")
        product_name = pending.get("product_name", plu)

        if not plu:
            self.telegram.answer_callback_query(callback_id, "Data sync tidak lengkap.")
            return

        self.telegram.answer_callback_query(callback_id, f"Menyinkronkan {product_name}...")

        try:
            result = self.laravel.sync_product_from_gpos(plu)
        except Exception as exc:
            self.logger.exception("Gagal sync produk dari GPOS")
            self.telegram.send_message(chat_id, f"Gagal sinkronkan {product_name} dari GPOS: {exc}")
            return

        product = (result.get("data") or {}).get("product") or result.get("product") or {}
        if not product or not product.get("id"):
            self.telegram.send_message(chat_id, f"Produk {product_name} gagal disinkronkan — coba sync manual via Admin.")
            return

        self.telegram.send_message(
            chat_id,
            f"✅ {product.get('name') or product_name} berhasil disinkronkan dari GPOS ke DEWA.",
        )

        if jumlah is None:
            self.pending_actions.put(
                self._defecta_qty_token(chat_id),
                {
                    "type": "defecta_qty",
                    "product": product,
                    "satuan": satuan,
                },
            )
            self.telegram.send_message(
                chat_id,
                f"Berapa jumlah defecta untuk {product.get('display_name') or product.get('name') or '-'}?",
            )
            return

        self._create_defecta_for_user(actor, chat_id, product, int(jumlah), satuan=satuan)

    def _prepare_defecta_creation(
        self,
        message: dict[str, Any],
        product: dict[str, Any],
        jumlah: int | None,
        satuan: str | None = None,
    ) -> None:
        chat_id = message["chat"]["id"]
        product_name = product.get("display_name") or product.get("name") or "-"

        if jumlah is None:
            self.pending_actions.put(
                self._defecta_qty_token(chat_id),
                {
                    "type": "defecta_qty",
                    "product": product,
                    "satuan": satuan,
                },
            )
            self.telegram.send_message(
                chat_id,
                f"Berapa jumlah defecta untuk {product_name}?",
            )
            return

        valid_units = self._product_valid_units(product)
        preferred_unit = self._product_preferred_unit(product)

        if satuan:
            self.logger.info(
                "Defecta user supplied unit chat_id=%s product_id=%s unit=%s",
                chat_id,
                product.get("id"),
                satuan,
            )
        elif self._product_requires_unit_confirmation(product):
            reason = "Satuan perlu dikonfirmasi"
            if preferred_unit:
                reason += f". Saran sistem: {preferred_unit}"
            self._prompt_defecta_unit_confirmation(chat_id, product, jumlah, valid_units, reason=reason)
            return
        elif preferred_unit:
            satuan = preferred_unit

        self._create_defecta_for_product(message, product, jumlah, satuan=satuan)

    def _create_defecta_for_user(
        self,
        actor: dict[str, Any],
        chat_id: int,
        product: dict[str, Any],
        jumlah: int,
        satuan: str | None = None,
    ) -> None:
        message = {"chat": {"id": chat_id}, "from": actor}
        self._create_defecta_for_product(message, product, jumlah, satuan=satuan)

    def _handle_missing_defecta_product(
        self, message: dict[str, Any], chat_id: int, product_query: str, jumlah: int | None = None, satuan: str | None = None
    ) -> None:
        if self.gpos.is_configured():
            # Pencarian dua fase: query ASLI dulu, expand pabrik hanya jika 0 hasil
            items: list[dict[str, Any]] = []
            try:
                items = self.gpos.search_items(product_query, limit=3)
            except Exception:
                pass
            if not items:
                gpos_query = self.manufacturer_dict.expand(product_query)
                if gpos_query != product_query:
                    try:
                        items = self.gpos.search_items(gpos_query, limit=3)
                    except Exception:
                        pass

            if items:
                lines = [
                    f"❌ Produk '{product_query}' belum terdaftar di DEWA.",
                    "",
                    "👇 Klik tombol di bawah untuk otomatis:",
                    "1. Tarik data produk dari GPOS ke DEWA",
                    "2. Langsung catat sebagai defecta",
                    "",
                ]
                keyboard_rows: list[list[dict[str, Any]]] = []
                for idx, item in enumerate(items, start=1):
                    name = item.get('name') or '-'
                    plu = str(item.get('plu') or '')
                    lines.extend([
                        f"{idx}. {name}",
                        f"PLU: `{plu}`",
                        f"Stok: {item.get('stock') or item.get('total_stock') or '-'}",
                        "",
                    ])
                    # Simpan pending data per item
                    token = secrets.token_urlsafe(10)
                    self.pending_actions.put(token, {
                        "type": "gpos_sync_defecta",
                        "plu": plu,
                        "jumlah": jumlah,
                        "satuan": satuan,
                        "product_name": name,
                    })
                    label = f"🔁 Sync & Catat Defecta — {name[:25]}"
                    if jumlah:
                        label += f" ×{jumlah}"
                    keyboard_rows.append([{
                        "text": label,
                        "callback_data": f"gpos_sync:{token}",
                    }])

                self._store_last_lookup(chat_id, "gpos", product_query, items)
                self._store_product_mismatch(chat_id, items, source="gpos_search")
                self.telegram.send_message(
                    chat_id,
                    "\n".join(lines).strip(),
                    reply_markup={"inline_keyboard": keyboard_rows},
                )
                return

        self.telegram.send_message(chat_id, f"Produk '{product_query}' tidak ditemukan di DEWA.")

    # ─────────────────────────────────────────────────────────────────────────

    def _handle_screening(self, message: dict[str, Any], text: str) -> None:
        """Screening produk dengan harga abnormal (< 50 rupiah) dan stok > 0."""
        chat_id = message["chat"]["id"]
        parts = text.strip().split()
        limit = 20
        if len(parts) > 1 and parts[-1].isdigit():
            limit = min(int(parts[-1]), 100)

        self.telegram.send_message(chat_id, "Mencari produk dengan harga abnormal...")
        try:
            result = self.laravel.screening_products({"limit": limit})
        except Exception as exc:
            self.telegram.send_message(chat_id, f"Gagal screening: {exc}")
            return

        data = result.get("data") or []
        count = result.get("count") or len(data)
        if not data:
            self.telegram.send_message(chat_id, "✅ Tidak ada produk dengan harga abnormal (stok > 0 & harga < Rp50).")
            return

        lines = [f"Produk perlu pengecekan harga ({count} item):", ""]
        for idx, p in enumerate(data, start=1):
            price = p.get("sales_price")
            price_str = self._format_rupiah(price) if price else "BELUM DIISI"
            stock = p.get("total_stock") or 0
            plu = p.get("plu") or "-"
            name = p.get("name") or "-"
            lines.append(
                f"{idx}. <code>{plu}</code> {name}\n"
                f"   Harga: {price_str} | Stok: {stock}"
            )
        lines.append("")
        lines.append("Gunakan PLU untuk setting harga di GPOS atau DEWA.")

        self.telegram.send_message(chat_id, "\n".join(lines), parse_mode="HTML")

    def _handle_supplier_search(self, message: dict[str, Any], text: str) -> None:
        """Cari supplier/PBF via bot."""
        chat_id = message["chat"]["id"]
        # Parse: /supplier <query> atau supplier <query>
        parts = text.strip().split(None, 1)
        query = parts[1].strip() if len(parts) > 1 else ""

        if not query:
            self.telegram.send_message(
                chat_id,
                "Format: /supplier nama PBF\nContoh: /supplier parit padang",
            )
            return

        try:
            result = self.laravel.search_suppliers(query, self._actor_id(message))
        except Exception as exc:
            self.telegram.send_message(chat_id, f"Gagal mencari supplier: {exc}")
            return

        suppliers = result.get("data") or []
        if not suppliers:
            self.telegram.send_message(chat_id, f"Tidak ada PBF dengan kata kunci: {query}")
            return

        lines = [f"Hasil pencarian PBF untuk: {query}", ""]
        for idx, s in enumerate(suppliers[:10], start=1):
            code = s.get("code") or "-"
            name = s.get("name") or "-"
            address = (s.get("address") or "").strip()
            phone = (s.get("phone") or s.get("contact_phone") or "").strip()

            lines.append(f"{idx}. *{name}*")
            lines.append(f"   Kode: `{code}`")
            if address:
                lines.append(f"   Alamat: {address}")
            if phone:
                lines.append(f"   Telp: {phone}")

            # Tandai jika data belum lengkap
            if not address:
                lines.append(f"   ⚠️ _Alamat belum diisi — lengkapi di panel DEWA_")
            if not phone:
                lines.append(f"   ⚠️ _Telepon belum diisi — lengkapi di panel DEWA_")
            lines.append("")

        self.telegram.send_message(chat_id, "\n".join(lines))

    def _handle_stock_opname(self, message: dict[str, Any], text: str) -> None:
        chat_id = message["chat"]["id"]
        parts = text.split(None, 2)
        subcommand = (parts[1] if len(parts) > 1 else "").lower()
        rest = parts[2] if len(parts) > 2 else ""

        try:
            if not subcommand or subcommand == "aktif":
                result = self.laravel.get_active_stock_opname_sessions(self._actor_id(message))
                sessions = result.get("data") or []
                if not sessions:
                    self.telegram.send_message(chat_id, "Belum ada sesi stock opname aktif.")
                    return
                lines = ["Sesi stock opname aktif:"]
                for session in sessions[:5]:
                    lines.append(
                        f"- #{session.get('id')} {session.get('name')} | "
                        f"dibuka oleh {session.get('opened_by_name') or '-'} | "
                        f"berakhir {session.get('ends_at') or '-'}"
                    )
                self.telegram.send_message(chat_id, "\n".join(lines))
                return

            if subcommand == "buka":
                payload = self._with_actor(message, {"name": rest or None})
                result = self.laravel.create_stock_opname_session(payload)
                session = result.get("data") or {}
                self.telegram.send_message(
                    chat_id,
                    f"Sesi stock opname dibuka.\nID: {session.get('id')}\nNama: {session.get('name')}",
                )
                return

            if subcommand == "pending":
                session_id = int(rest.strip())
                result = self.laravel.get_pending_stock_opname_items(session_id, self._actor_id(message), limit=10)
                items = result.get("data") or []
                if not items:
                    self.telegram.send_message(chat_id, "Tidak ada item pending atau sesi tidak ditemukan.")
                    return
                lines = [f"Item pending sesi #{session_id}:"]
                for item in items[:10]:
                    lines.append(f"- {item.get('name')} | PLU: {item.get('plu') or '-'} | {item.get('unit') or '-'}")
                self.telegram.send_message(chat_id, "\n".join(lines))
                return

            if subcommand == "hitung":
                args = [part.strip() for part in rest.split("|")]
                if len(args) < 3:
                    self.telegram.send_message(
                        chat_id,
                        "Format: /opname hitung <session_id> | <nama/PLU> | <stok fisik> | [catatan]",
                    )
                    return
                session_id = int(args[0])
                query = args[1]
                physical_stock = float(args[2].replace(",", "."))
                notes = args[3] if len(args) > 3 else None
                self._count_stock_opname_item(message, session_id, query, physical_stock, notes)
                return
        except Exception as exc:
            self.logger.exception("Gagal memproses stock opname")
            self.telegram.send_message(chat_id, f"Gagal memproses stock opname: {exc}")
            return

        self.telegram.send_message(
            chat_id,
            "Command stock opname:\n"
            "/opname aktif\n"
            "/opname buka Nama Sesi\n"
            "/opname pending <session_id>\n"
            "/opname hitung <session_id> | <nama/PLU> | <stok fisik> | [catatan]",
        )

    def _count_stock_opname_item(
        self,
        message: dict[str, Any],
        session_id: int,
        query: str,
        physical_stock: float,
        notes: str | None,
    ) -> None:
        chat_id = message["chat"]["id"]
        result = self.laravel.search_products(self._with_actor(message, {"query": query, "limit": 5}))
        items = result.get("data") or []
        if not items:
            self.telegram.send_message(chat_id, f"Produk tidak ditemukan di DEWA: {query}")
            return

        product = items[0]
        gpos_items = self.gpos.search_items(product.get("plu") or query, limit=5) if self.gpos.is_configured() else []
        system_stock_raw = next(
            (item.get("total_stock") for item in gpos_items if str(item.get("plu")) == str(product.get("plu"))),
            gpos_items[0].get("total_stock") if gpos_items else "0",
        )
        system_stock = self._extract_stock_number(system_stock_raw)

        self.laravel.lock_stock_opname_item(
            session_id,
            self._with_actor(message, {"product_id": product["id"], "system_stock": system_stock}),
        )
        count_result = self.laravel.count_stock_opname_item(
            session_id,
            self._with_actor(message, {
                "product_id": product["id"],
                "system_stock": system_stock,
                "physical_stock": physical_stock,
                "notes": notes,
            }),
        )
        data = count_result.get("data") or {}
        self.telegram.send_message(
            chat_id,
            "Hasil stock opname tersimpan.\n"
            f"Produk: {data.get('product_name')}\n"
            f"PLU: `{data.get('product_plu')}`\n"
            f"Stok sistem: {data.get('system_stock')}\n"
            f"Stok fisik: {data.get('physical_stock')}\n"
            f"Selisih: {data.get('difference')}",
        )

    def _save_supplier_bank_from_evidence(
        self,
        callback_id: str,
        chat_id: int,
        evidence_id: int,
        supplier_id: int,
        actor: dict[str, Any],
    ) -> None:
        try:
            result = self.laravel.suggest_supplier_bank_account(
                evidence_id,
                self._with_actor_from_user(actor, {"supplier_id": supplier_id}),
            )
        except Exception as exc:
            self.logger.exception("Gagal menyimpan rekening supplier dari bukti")
            self.telegram.answer_callback_query(callback_id, "Gagal menyimpan rekening.")
            self.telegram.send_message(chat_id, f"Gagal menyimpan rekening supplier: {exc}")
            return

        self.telegram.answer_callback_query(callback_id, "Rekening supplier disimpan.")
        self.telegram.send_message(chat_id, result.get("message", "Rekening supplier berhasil ditambahkan."))

    # Unit words yang umum di WhatsApp SP
    _SP_UNIT_WORDS = (
        "box", "btl", "botol", "strip", "dos", "pcs", "tablet", "tab",
        "kapsul", "kaplet", "tube", "sachet", "fls", "vial", "ampul",
        "botol", "buah", "lembar", "pasang", "set", "pak", "ball",
    )

    # Map unit → keywords in product name yang TIDAK kompatibel
    # Jika produk mengandung keyword ini, TIDAK cocok dengan unit yang diminta
    _UNIT_INCOMPATIBLE_KEYWORDS: dict[str, frozenset[str]] = {
        # User minta "box" → exclude produk liquid (syr, drop, susp), topical (gel, krim, salep)
        "box": frozenset({"syrup", "syr", "drop", "susp", "suspensi", "gel", "krim", "salep", "cream", "ointment", "lotion"}),
        "dos": frozenset({"syrup", "syr", "drop", "susp", "suspensi", "gel", "krim", "salep", "cream", "ointment", "lotion"}),
        "strip": frozenset({"syrup", "syr", "drop", "susp", "suspensi", "gel", "krim", "salep", "cream", "ointment", "lotion"}),
        # User minta "botol" → exclude solid (tab, kap, cap, box), topical (gel, krim, salep)
        "botol": frozenset({"tab", "kaplet", "kapsul", "gel", "krim", "salep", "cream", "ointment"}),
        "btl": frozenset({"tab", "kaplet", "kapsul", "gel", "krim", "salep", "cream", "ointment"}),
        "fls": frozenset({"tab", "kaplet", "kapsul", "gel", "krim", "salep", "cream", "ointment"}),
        # User minta "tube" → exclude solid (tab, kap, cap, box), liquid (syr, drop)
        "tube": frozenset({"tab", "kaplet", "kapsul", "syrup", "syr", "drop", "susp", "suspensi", "botol"}),
        "sachet": frozenset({"tab", "kaplet", "kapsul", "syrup", "syr", "drop", "susp", "suspensi", "botol"}),
    }

    def _filter_matches_by_unit(self, matches: list[dict[str, Any]], requested_unit: str | None) -> list[dict[str, Any]]:
        """Filter hasil pencarian produk berdasarkan kompatibilitas unit.

        Contoh: user minta "box" → exclude produk GEL, SYR, DROP, dll.
        Jika setelah filter tersisa 1, return 1 match itu.
        Jika tidak ada yang kompatibel, return original matches.
        """
        if not requested_unit or not matches or len(matches) <= 1:
            return matches
        unit_lower = requested_unit.lower().strip()
        incompatible = self._UNIT_INCOMPATIBLE_KEYWORDS.get(unit_lower)
        if not incompatible:
            return matches
        # Filter: produk kompatibel jika tidak mengandung keyword inkompatibel di namanya
        compatible = []
        for match in matches:
            name = (match.get("display_name") or match.get("name") or "").lower()
            # Cek apakah nama produk mengandung keyword inkompatibel
            if not any(kw in name for kw in incompatible):
                compatible.append(match)
        return compatible if compatible else matches

    @staticmethod
    def _normalize_date(raw: str) -> str:
        """Konversi tanggal dari d/m/Y (Indonesia) ke Y-m-d (ISO) untuk validasi Laravel.

        Menerima format: dd/mm/yyyy, dd-mm-yyyy, d/m/yyyy, d-m-yyyy.
        Return string ISO YYYY-MM-DD, atau raw string jika tidak bisa diparse.
        """
        import datetime as _dt
        for fmt in ("%d/%m/%Y", "%d-%m-%Y", "%d/%m/%y", "%d-%m-%y"):
            try:
                return _dt.datetime.strptime(raw, fmt).strftime("%Y-%m-%d")
            except ValueError:
                continue
        return raw

    def _line_has_quantity(self, line: str) -> bool:
        """Cek apakah baris diakhiri dengan pola jumlah+satuan (bukan dosis atau tanggal)."""
        # Buang tanggal dari akhir baris (dd/mm/yyyy, dd-mm-yyyy, d/m/yy)
        cleaned = re.sub(r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\s*$', '', line).strip()
        if not cleaned:
            return False
        return bool(re.search(
            r'(?<![\/\d-])\d+(?:[.,]\d+)?\s*(' + '|'.join(self._SP_UNIT_WORDS) + r')?\s*$',
            cleaned, re.IGNORECASE,
        ))

    def _line_extract_quantity(self, line: str) -> tuple[float, str] | None:
        """Ekstrak jumlah dan satuan dari akhir baris."""
        # Buang tanggal dari akhir baris
        cleaned = re.sub(r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\s*$', '', line).strip()
        if not cleaned:
            return None
        m = re.search(
            r'(?P<qty>(?<![\/\d-])\d+(?:[.,]\d+)?)\s*(?P<unit>' + '|'.join(self._SP_UNIT_WORDS) + r')?\s*$',
            cleaned, re.IGNORECASE,
        )
        if not m:
            return None
        qty = float(m.group("qty").replace(",", "."))
        unit = (m.group("unit") or "pcs").strip().lower()
        return qty, unit

    def _looks_like_purchase_order(self, text: str) -> bool:
        first_line = text.splitlines()[0].strip().lower()
        # Original prefix check — command eksplisit
        if first_line.startswith("/sp ") or first_line.startswith("sp "):
            return True

        # Deteksi format WhatsApp/Excel informal:
        # Baris pertama pendek (kode/nama supplier), baris selanjutnya item+dosis+jumlah
        lines = [l.strip() for l in text.splitlines() if l.strip()]
        if len(lines) < 2:
            return False

        first = lines[0]
        first_words = first.split()
        # Baris pertama: pendek, bukan command, tidak mengandung pola jumlah
        if len(first_words) > 6 or len(first) > 60:
            return False
        if first.startswith(("/", "cari ", "stok ", "info ")):
            return False
        if self._line_has_quantity(first):
            return False  # baris pertama sudah item, bukan header supplier

        # Baris 2+ harus minimal 1 seperti item (punya jumlah di akhir)
        item_lines = [l for l in lines[1:] if self._line_has_quantity(l)]
        return len(item_lines) >= 1

    def _parse_purchase_order_text(self, text: str) -> dict[str, Any] | None:
        lines = [line.strip().rstrip(",") for line in text.splitlines() if line.strip()]
        if not lines:
            return None

        header = lines[0]
        # Coba parse format command /sp atau sp ...
        match = re.match(r"^(?:/sp|sp)\s+(?:(pk|prekursor|oot|biasa)\s+)?(.+)$", header, re.IGNORECASE)
        if match:
            jenis_map = {
                "pk": "PREKURSOR",
                "prekursor": "PREKURSOR",
                "oot": "OOT",
                "biasa": "BIASA",
            }
            jenis_raw = (match.group(1) or "biasa").lower()
            jenis_surat = jenis_map[jenis_raw]
            supplier_query = match.group(2).strip(" ,:")
            items = lines[1:]
            if not supplier_query or not items:
                return None
            return {
                "jenis_surat": jenis_surat,
                "supplier_query": supplier_query,
                "items": items,
                "is_informal": False,
            }

        # Parse format WhatsApp/Excel informal:
        # Baris 1: [supplier_code] [optional: tanggal atau "oot"/"pk"]
        # Baris 2+: nama_produk [dosis] [pabrik] jumlah satuan
        first = header.strip()
        supplier_query = first
        jenis_surat = "BIASA"

        # Deteksi jika baris pertama mengandung penanda jenis surat
        jenis_marker = re.search(r'\b(pk|prekursor|oot|biasa)\b', first, re.IGNORECASE)
        if jenis_marker:
            jm = jenis_marker.group(1).lower()
            jenis_map = {"pk": "PREKURSOR", "prekursor": "PREKURSOR", "oot": "OOT", "biasa": "BIASA"}
            jenis_surat = jenis_map.get(jm, "BIASA")
            # Buang marker jenis dari supplier query
            supplier_query = re.sub(r'\b(pk|prekursor|oot|biasa)\b', '', first, flags=re.IGNORECASE).strip(" ,;:-")

        # Deteksi jika ada tanggal di baris pertama (format: dd/mm/yyyy atau dd-mm-yyyy)
        tanggal_match = re.search(r'(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})', supplier_query)
        tanggal_surat = None
        if tanggal_match:
            tanggal_surat = self._normalize_date(tanggal_match.group(1))
            supplier_query = supplier_query.replace(tanggal_match.group(0), '').strip(" ,;:-")

        items = lines[1:]
        if not supplier_query or not items:
            return None

        return {
            "jenis_surat": jenis_surat,
            "supplier_query": supplier_query,
            "items": items,
            "is_informal": True,
            "tanggal_surat": tanggal_surat,
        }

    def _parse_item_line(self, line: str) -> dict[str, Any] | None:
        line = line.strip()
        # Ekstrak jumlah + satuan dari akhir baris
        qty_info = self._line_extract_quantity(line)
        if not qty_info:
            return None

        quantity, unit = qty_info
        # Ambil nama produk (semua sebelum jumlah di akhir)
        qty_pattern = r'\s+\d+(?:[.,]\d+)?\s*(?:' + '|'.join(self._SP_UNIT_WORDS) + r')?\s*$'
        name = re.sub(qty_pattern, '', line, flags=re.IGNORECASE).strip().rstrip(',')

        if not name:
            return None

        return {
            "name": name,
            "quantity": quantity,
            "unit": unit.upper(),
        }

    def _safe_base_quantity(self, quantity: float) -> int:
        base = int(quantity)
        return base if base > 0 else 1

    def _format_sp_result(
        self,
        supplier_name: str,
        jenis_surat: str,
        items: list[dict[str, Any]],
        pending_candidates: list[dict[str, Any]],
        rejected_items: list[str],
        allow_finalize: bool,
    ) -> str:
        lines = [
            f"PBF: {supplier_name}",
            f"Jenis: {jenis_surat}",
            "Item:",
        ]

        for index, item in enumerate(items, start=1):
            lines.append(
                f"{index}. {item.get('supplier_product_name') or item.get('product_name', '-')}"
                f" - {item.get('base_quantity', 0)} {item.get('base_unit', '-')}"
                f" [{item.get('classification', '-')}]"
            )

        if pending_candidates:
            lines.append("")
            lines.append("Perlu review produk baru:")
            for candidate in pending_candidates:
                lines.append(f"- {candidate['name']} (kandidat #{candidate.get('candidate_id') or '-'})")

        if rejected_items:
            lines.append("")
            lines.append("Item ditolak:")
            lines.extend(rejected_items)

        lines.append("")
        if allow_finalize:
            lines.append("Lanjutkan sebagai draft atau kirim ke APJ.")
        else:
            lines.append("Belum ada item valid yang bisa dijadikan draft.")

        return "\n".join(lines)

    def _purchase_order_format_help(self) -> str:
        return (
            "Format draft SP:\n"
            "sp <kode supplier>\n"
            "atau\n"
            "sp <jenis> <kode supplier>\n"
            "<nama item> <jumlah> <satuan>\n\n"
            "Contoh:\n"
            "sp GLOSMED\n"
            "Paracetamol 2 box\n\n"
            "sp pk APL\n"
            "Actifed Kuning 60 ml 2 botol\n"
            "Panadol Cold and Flu 1 box\n\n"
            "Atau langsung kirim daftar belanja:\n"
            "<kode supplier>\n"
            "<nama obat> <jumlah> <satuan>\n"
            "...\n\n"
            "Contoh:\n"
            "Gafarin\n"
            "Allopurinol 100 hj 5 box\n"
            "Amlodipine 5mg dexa 3 box"
        )

    def _ensure_registered_message(self, message: dict[str, Any]) -> bool:
        chat_id = message["chat"]["id"]
        user = message.get("from") or {}
        return self._ensure_registered_user(user, chat_id)

    def _ensure_registered_actor(
        self,
        actor: dict[str, Any],
        chat_id: int,
        callback_id: str | None = None,
    ) -> bool:
        allowed = self._ensure_registered_user(actor, chat_id)
        if not allowed and callback_id:
            self.telegram.answer_callback_query(callback_id, "Anda tidak terdaftar.")
        return allowed

    def _ensure_registered_user(self, user: dict[str, Any], chat_id: int) -> bool:
        try:
            self.laravel.get_bot_profile(self._with_actor_from_user(user, {}))
            return True
        except Exception as exc:
            self.logger.exception(
                "Gagal memverifikasi user Telegram",
                extra={
                    "telegram_user_id": user.get("id"),
                    "telegram_username": user.get("username"),
                },
            )
            message = str(exc).lower()
            if "belum terhubung" in message or "tidak terdaftar" in message:
                self.telegram.send_message(chat_id, "Anda tidak terdaftar. Hubungi admin.")
            else:
                self.telegram.send_message(chat_id, "Verifikasi akun Telegram gagal. Coba lagi atau hubungi admin.")
            return False

    def _consume_pending_text_flow(self, message: dict[str, Any], text: str) -> bool:
        chat_id = message["chat"]["id"]

        # Auto-cancel pending flow jika user mengirim command baru yang eksplisit
        stripped = text.strip()
        if stripped.startswith("/") or self._extract_lookup_request(stripped):
            # Cek apakah ada pending flow yang aktif
            active_tokens = [
                self._defecta_qty_token(chat_id),
                self._defecta_pick_token(chat_id),
                self._defecta_lookup_qty_token(chat_id),
                f"composition_pick:{chat_id}",
                f"composition_confirm:{chat_id}",
                self._purchase_order_review_token(chat_id),
            ]
            for token in active_tokens:
                if self.pending_actions.get(token):
                    self.pending_actions.delete(token)
            # Fallthrough — biarkan command baru diproses oleh handler lain

        # Pilihan komposisi (setelah user diminta pilih dari multi-produk lookup)
        composition_pick = self.pending_actions.get(f"composition_pick:{chat_id}")
        if composition_pick and (composition_pick.get("type") or "") == "composition_pick":
            if text.strip().lower() in {"batal", "/batal", "cancel"}:
                self.pending_actions.delete(f"composition_pick:{chat_id}")
                self.telegram.send_message(chat_id, "Pencarian komposisi dibatalkan.")
                return True

            try:
                idx = int(text.strip()) - 1
                items = composition_pick.get("items") or []
                if 0 <= idx < len(items):
                    self.pending_actions.delete(f"composition_pick:{chat_id}")
                    self._send_composition_for_product(message, items[idx])
                else:
                    self.telegram.send_message(chat_id, "Nomor tidak valid. Pilih nomor yang tercantum.")
            except ValueError:
                pass  # Bukan angka — biarkan handler lain yang proses
            else:
                return True

        # Batal / cancel untuk composition_confirm (konfirmasi data Halodoc)
        composition_confirm = self.pending_actions.get(f"composition_confirm:{chat_id}")
        if composition_confirm and text.strip().lower() in {"batal", "/batal", "cancel"}:
            self.pending_actions.delete(f"composition_confirm:{chat_id}")
            self.telegram.send_message(chat_id, "Konfirmasi komposisi dibatalkan.")
            return True

        # ── Composition CRUD: Tambah komposisi (comp_add_text) ───────────────
        comp_add = self.pending_actions.get(f"comp_add_text:{chat_id}")
        if comp_add and (comp_add.get("type") or "") == "comp_add_text":
            if text.strip().lower() in {"batal", "/batal", "cancel"}:
                self.pending_actions.delete(f"comp_add_text:{chat_id}")
                self.telegram.send_message(chat_id, "Penambahan komposisi dibatalkan.")
                return True

            # Parse: "Nama Kandungan Kekuatan"
            parsed = self._parse_comp_row(text)
            if not parsed:
                self.telegram.send_message(
                    chat_id,
                    "Format tidak dikenali. Kirim dengan format: `Nama Kandungan Kekuatan`\n"
                    "Contoh: `Pseudoephedrine HCl 7.5 mg`\n"
                    "Ketik `batal` untuk membatalkan.",
                    parse_mode="Markdown",
                )
                return True

            product_id = int(comp_add.get("product_id") or 0)
            # Ambil komposisi existing dari comp_edit
            edit_pending = self.pending_actions.get(f"comp_edit:{chat_id}")
            items = (edit_pending.get("komposisi") or []) if edit_pending else []
            items.append({"name": parsed[0], "strength": parsed[1]})
            self.pending_actions.put(
                f"comp_edit:{chat_id}",
                {"type": "comp_edit", "product_id": product_id, "komposisi": items},
                ttl_minutes=15,
            )
            self.pending_actions.delete(f"comp_add_text:{chat_id}")
            self.telegram.send_message(
                chat_id,
                f"✅ Ditambahkan: *{parsed[0]} — {parsed[1]}*\n"
                f"Total: {len(items)} baris. Klik [💾 Simpan] untuk menyimpan ke DEWA.",
                reply_markup={"inline_keyboard": [[
                    {"text": "💾 Simpan ke DEWA", "callback_data": f"comp_save:{chat_id}:{product_id}"},
                ]]},
                parse_mode="Markdown",
            )
            return True

        # ── Composition CRUD: Edit baris komposisi (comp_edit_row) ────────────
        comp_edit_row_pending = self.pending_actions.get(f"comp_edit_row:{chat_id}")
        if comp_edit_row_pending and (comp_edit_row_pending.get("type") or "") == "comp_edit_row":
            if text.strip().lower() in {"batal", "/batal", "cancel"}:
                self.pending_actions.delete(f"comp_edit_row:{chat_id}")
                self.telegram.send_message(chat_id, "Edit komposisi dibatalkan.")
                return True

            # Parse: "Nama Kandungan Kekuatan"
            parsed = self._parse_comp_row(text)
            if not parsed:
                self.telegram.send_message(
                    chat_id,
                    "Format tidak dikenali. Kirim dengan format: `Nama Kekuatan`\n"
                    "Contoh: `Pseudoephedrine HCl 7.5 mg`\n"
                    "Ketik `batal` untuk membatalkan.",
                    parse_mode="Markdown",
                )
                return True

            row = int(comp_edit_row_pending.get("row") or 0)
            product_id = int(comp_edit_row_pending.get("product_id") or 0)
            # Ambil komposisi existing dari comp_edit
            edit_pending = self.pending_actions.get(f"comp_edit:{chat_id}")
            items = (edit_pending.get("komposisi") or []) if edit_pending else []
            if 0 <= row < len(items):
                old_name = items[row].get("name", "-") if isinstance(items[row], dict) else str(items[row])
                old_strength = items[row].get("strength", "") if isinstance(items[row], dict) else ""
                items[row] = {"name": parsed[0], "strength": parsed[1]}
                self.pending_actions.put(
                    f"comp_edit:{chat_id}",
                    {"type": "comp_edit", "product_id": product_id, "komposisi": items},
                    ttl_minutes=15,
                )
                self.pending_actions.delete(f"comp_edit_row:{chat_id}")
                self.telegram.send_message(
                    chat_id,
                    f"✅ Baris {row + 1} diubah:\n"
                    f"~{old_name} — {old_strength}~\n"
                    f"→ *{parsed[0]} — {parsed[1]}*\n\n"
                    f"Klik [💾 Simpan] untuk menyimpan ke DEWA.",
                    reply_markup={"inline_keyboard": [[
                        {"text": "💾 Simpan ke DEWA", "callback_data": f"comp_save:{chat_id}:{product_id}"},
                    ]]},
                    parse_mode="Markdown",
                )
            else:
                self.pending_actions.delete(f"comp_edit_row:{chat_id}")
                self.telegram.send_message(chat_id, "Baris tidak valid. Edit dibatalkan.")
            return True

        po_review = self.pending_actions.get(self._purchase_order_review_token(message["chat"]["id"]))
        if po_review and (po_review.get("type") or "") == "po_review":
            return self._consume_purchase_order_review_flow(message, text, po_review)

        defecta_lookup_qty = self.pending_actions.get(self._defecta_lookup_qty_token(message["chat"]["id"]))
        if defecta_lookup_qty and (defecta_lookup_qty.get("type") or "") == "defecta_lookup_qty":
            return self._consume_defecta_lookup_qty_flow(message, text, defecta_lookup_qty)

        defecta_pick = self.pending_actions.get(self._defecta_pick_token(message["chat"]["id"]))
        if defecta_pick and (defecta_pick.get("type") or "") == "defecta_pick":
            return self._consume_defecta_pick_flow(message, text, defecta_pick)

        # Fase 5.2: Handler untuk input satuan defecta
        satuan_pending = self.pending_actions.get(self._defecta_satuan_token(message["chat"]["id"]))
        if satuan_pending and (satuan_pending.get("type") or "") == "defecta_satuan":
            return self._consume_defecta_satuan_text(message, text, satuan_pending)

        pending = self.pending_actions.get(self._defecta_qty_token(message["chat"]["id"]))
        if not pending:
            return False

        if (pending.get("type") or "") != "defecta_qty":
            return False

        if text.strip().lower() in {"batal", "/batal", "cancel"}:
            self.pending_actions.delete(self._defecta_qty_token(message["chat"]["id"]))
            self.telegram.send_message(message["chat"]["id"], "Input defecta dibatalkan.")
            return True

        jumlah = self._parse_positive_int(text)
        satuan_dari_input = None
        if jumlah is None:
            # Terima format "1 box", "3 tab", dst.
            m = re.match(r"^(\d+)\s+([a-zA-Z]{2,15})$", text.strip())
            if m:
                jumlah = self._parse_positive_int(m.group(1))
                if jumlah and not self._is_dose_pattern(m.group(2)):
                    satuan_dari_input = m.group(2).strip()
        if jumlah is None:
            self.telegram.send_message(
                message["chat"]["id"],
                "Jumlah defecta harus angka positif. Ketik angka saja, misalnya `3`, atau ketik `batal`.",
            )
            return True

        self.pending_actions.delete(self._defecta_qty_token(message["chat"]["id"]))
        satuan = satuan_dari_input or pending.get("satuan")
        self._create_defecta_for_product(message, pending.get("product") or {}, jumlah, satuan=satuan)
        return True

    def _consume_defecta_satuan_text(self, message: dict[str, Any], text: str, pending: dict[str, Any]) -> bool:
        """Handler untuk input satuan defecta (Fase 5.2)."""
        chat_id = message["chat"]["id"]
        product = pending.get("product") or {}
        jumlah = pending.get("jumlah")
        valid_units = pending.get("valid_units") or []

        if text.strip().lower() in {"batal", "/batal", "cancel"}:
            self.pending_actions.delete(self._defecta_satuan_token(chat_id))
            self.telegram.send_message(chat_id, "Input satuan dibatalkan.")
            return True

        satuan = text.strip()
        self.logger.info(
            "Defecta manual unit reply chat_id=%s product_id=%s unit=%s valid_units=%s",
            chat_id,
            product.get("id"),
            satuan,
            valid_units,
        )

        self.pending_actions.delete(self._defecta_satuan_token(chat_id))
        self._create_defecta_for_product(message, product, jumlah, satuan=satuan)
        return True

    def _consume_lookup_navigation_text(self, message: dict[str, Any], text: str) -> bool:
        chat_id = message["chat"]["id"]
        lookup = self.pending_actions.get(self._last_lookup_token(chat_id))
        if not lookup or (lookup.get("type") or "") != "lookup_last":
            return False

        navigation = self._parse_lookup_navigation(text)
        if navigation is None:
            return False

        all_items = lookup.get("all_items") or lookup.get("items") or []
        page_size = max(1, int(lookup.get("page_size") or _LOOKUP_PAGE_SIZE))
        total_count = int(lookup.get("total_count") or len(all_items))
        total_pages = max(1, (len(all_items) + page_size - 1) // page_size)
        current_page = max(1, int(lookup.get("page") or 1))

        if navigation["type"] == "all":
            lookup["page"] = 1
            lookup["items"] = all_items
            lookup["show_all"] = True
            self.pending_actions.put(self._last_lookup_token(chat_id), lookup, ttl_minutes=180)
            self._send_lookup_page(chat_id)
            return True

        target_page = current_page + 1 if navigation["type"] == "next" else int(navigation["page"] or 1)
        if target_page < 1 or target_page > total_pages:
            if navigation["type"] == "next" and total_count > len(all_items):
                self.telegram.send_message(
                    chat_id,
                    "Hasil berikutnya belum ada di cache bot. Persempit query atau ulangi pencarian yang lebih spesifik.",
                )
                return True

            self.telegram.send_message(chat_id, f"Halaman tidak valid. Tersedia 1 sampai {total_pages}.")
            return True

        start = (target_page - 1) * page_size
        end = start + page_size
        lookup["page"] = target_page
        lookup["items"] = all_items[start:end]
        lookup["show_all"] = False
        self.pending_actions.put(self._last_lookup_token(chat_id), lookup, ttl_minutes=180)
        self._send_lookup_page(chat_id)
        return True

    def _consume_lookup_selection_text(self, message: dict[str, Any], text: str) -> bool:
        """Cek apakah user mengirim nomor untuk memilih item dari lookup sebelumnya."""
        chat_id = message["chat"]["id"]
        lookup = self.pending_actions.get(self._last_lookup_token(chat_id))
        if not lookup or (lookup.get("type") or "") != "lookup_last":
            return False

        selection = self._parse_lookup_multi_selection(text)
        if not selection or len(selection) != 1:
            return False

        idx = selection[0]
        items = lookup.get("items") or []
        if idx < 1 or idx > len(items):
            return False

        item = items[idx - 1]
        kind = lookup.get("kind") or "gpos"
        mode = lookup.get("mode") or "stok"

        if kind == "product":
            # DEWA results → cari di GPOS pakai PLU
            plu = item.get("plu") or ""
            name = item.get("name") or item.get("display_name") or ""
            if plu:
                self._handle_gpos_item_lookup(message, plu, mode=mode)
            elif name:
                self._handle_gpos_item_lookup(message, name, mode=mode)
            else:
                self.telegram.send_message(chat_id, "Item tidak memiliki PLU/nama yang valid.")
        else:
            # GPOS results → tampilkan info stok untuk item terpilih
            self._send_gpos_item_stock_card(message, item)

        return True

    def _send_gpos_item_stock_card(self, message: dict[str, Any], item: dict[str, Any]) -> None:
        """Tampilkan single-item stock card untuk hasil GPOS."""
        chat_id = message["chat"]["id"]
        name = item.get("name") or item.get("display_name") or "-"
        plu = str(item.get("plu") or "-")
        barcode = str(item.get("barcode") or plu)
        stock = item.get("current_stock") or item.get("qty") or 0
        unit = item.get("unit") or item.get("satuan") or "PCS"
        price = item.get("sales_price") or item.get("harga_jual") or 0
        rack = item.get("rack_location") or item.get("rak") or "-"
        gudang = item.get("warehouse_name") or item.get("gudang") or ""
        category = item.get("category") or item.get("kategori") or ""
        if gudang:
            stock_str = f"{stock} {unit} | {gudang}"
        else:
            stock_str = f"{stock} {unit}"

        lines = [
            f"*{name}*",
            f"PLU: `{plu}`",
            f"Barcode: `{barcode}`",
        ]
        if category:
            lines.append(f"Kategori: {category}")
        lines.extend([
            f"Stok: {stock_str}",
            f"Harga: {self._format_rupiah(price)}",
            f"Rak: {rack}",
        ])
        self.telegram.send_message(chat_id, "\n".join(lines), parse_mode="Markdown")

    def _looks_like_product_query(self, text: str) -> bool:
        """
        Deteksi apakah teks terlihat seperti nama produk atau PLU yang diketik
        langsung tanpa command prefix (misal "tremenza", "200264").
        Dipanggil TERAKHIR setelah semua handler command/prefix dicoba.
        """
        stripped = text.strip()
        if not stripped:
            return False

        lowered = stripped.lower()

        # --- Numeric-only: PLU atau barcode ---
        # 1-2 digit almost always a lookup selection, not a valid PLU
        if stripped.isdigit() and len(stripped) >= 3:
            return True

        # --- Alphanumeric PLU (e.g. "PLU12345") ---
        if re.match(r'^[A-Za-z]{1,5}\d{3,}$', stripped):
            return True

        # --- Common stop words / chat words yang BUKAN produk ---
        stop_words = {
            # Chat / sapaan
            "halo", "hai", "hey", "hi", "test", "tes", "ping", "pong",
            "ok", "oke", "okay", "ya", "tidak", "nggak", "iya", "kak",
            "mas", "mbak", "bos", "gan", "bro", "sis", "bang", "min",
            "admin", "bot", "lagi", "aja",
            # Singkatan umum
            "sp", "po", "op", "loh", "wah", "nah", "eh", "ih",
            # Pertanyaan umum
            "apa", "bagaimana", "kenapa", "mengapa", "kapan", "dimana",
            "siapa", "gimana", "berapa", "bisa", "boleh", "minta",
            "tolong", "mohon", "help", "bantuan",
        }
        if lowered in stop_words:
            return False

        # --- Hindari pertanyaan / kalimat panjang (biarkan chat AI) ---
        if "?" in stripped:
            return False

        # --- Frasa pertanyaan ---
        question_starts = (
            "apa ", "bagaimana ", "kenapa ", "mengapa ", "kapan ",
            "dimana ", "di mana ", "siapa ", "gimana ", "berapa ",
            "bisa ", "boleh ", "minta ", "tolong ", "mohon ",
            "apakah ", "adakah ", "bisakah ",
        )
        if lowered.startswith(question_starts):
            return False

        # --- Cek jumlah kata: max 4 kata (nama produk biasanya pendek) ---
        words = stripped.split()
        if len(words) > 4:
            return False

        # --- Single word: harus 3+ karakter, bukan stop word ---
        if len(words) == 1:
            return len(stripped) >= 3

        # --- Multi-word (2-4 kata): cek tidak mengandung stop word di tiap kata ---
        # Jika SEMUA kata adalah stop word → skip. Produk biasanya kata benda.
        if all(w.lower() in stop_words for w in words):
            return False

        return True

    # Keyword natural untuk deteksi defecta tanpa prefix
    _DEFECTA_NATURAL_KEYWORDS = (
        "nyohok", "kosong", "habis", "absen", "ngga ada", "ga ada",
        "defecta", "defekta", "defecta", "stok habis", "stok kosong",
    )

    def _looks_like_defecta_text(self, text: str) -> bool:
        lowered = text.strip().lower()
        if lowered == "defecta" or lowered.startswith("defecta ") or lowered.startswith("defecta\n"):
            return True
        # Deteksi kata kunci natural: "Paracetamol kosong", "Woods habis", dll
        # Hanya trigger jika pesan pendek (1-2 baris) mengandung keyword
        if "\n" not in lowered and len(lowered) < 200:
            # Hindari false positive: "bukan defecta", "ga jadi defecta"
            if re.match(r"(bukan|ga\s+jadi|ga\s+jadi)\s+defecta", lowered):
                return False
            for kw in self._DEFECTA_NATURAL_KEYWORDS:
                if kw in lowered:
                    return True
        return False

    @staticmethod
    def _parse_indonesian_date(raw: str) -> str | None:
        """Parse tanggal format Indonesia seperti '5 april 2026' → '2026-04-05'."""
        bulan_map: dict[str, int] = {
            "januari": 1, "februari": 2, "maret": 3, "april": 4,
            "mei": 5, "juni": 6, "juli": 7, "agustus": 8,
            "september": 9, "oktober": 10, "november": 11, "desember": 12,
        }
        cleaned = raw.strip().lower()
        # Coba format: "5 april 2026" atau "5 april"
        match = re.match(r"(\d{1,2})\s+(\w+)(?:\s+(\d{4}))?", cleaned)
        if not match:
            return None
        day_str, month_name, year_str = match.groups()
        try:
            day = int(day_str)
            month = bulan_map.get(month_name)
            if month is None:
                return None
            year = int(year_str) if year_str else datetime.now().year
            return f"{year:04d}-{month:02d}-{day:02d}"
        except (ValueError, OverflowError):
            return None

    def _extract_lookup_request(self, text: str) -> dict[str, str] | None:
        normalized = " ".join(text.strip().split())
        patterns = [
            (r"^(?:/)?stok\s+(.+)$", "gpos", "stok", "stok"),
            (r"^(?:cek\s+stok)\s+(.+)$", "gpos", "stok", "stok"),
            (r"^(?:/)?harga\s+(.+)$", "gpos", "harga", "harga"),
            (r"^(?:cek\s+harga)\s+(.+)$", "gpos", "harga", "harga"),
            (r"^(?:/)?produk\s+(.+)$", "product", "produk", "produk"),
            (r"^(?:cari\s+produk|search\s+produk)\s+(.+)$", "product", "produk", "produk"),
            (r"^(?:plu|barcode|kategori)\s+(.+)$", "product", "produk", "produk"),
        ]

        for pattern, kind, mode, label in patterns:
            match = re.match(pattern, normalized, re.IGNORECASE)
            if not match:
                continue

            return {
                "kind": kind,
                "mode": mode,
                "label": label,
                "query": match.group(1).strip(),
            }

        return None

    def _extract_history_request(self, text: str) -> dict[str, Any] | None:
        normalized = " ".join(text.strip().split())
        patterns = [
            (r"^kapan terakhir beli\s+(.+)$", "purchase", 1),
            (r"^pembelian terakhir\s+(.+)$", "purchase", 1),
            (r"^pembelian terakhir$", "purchase", 1),
            (r"^terakhir beli\s+(.+)$", "purchase", 1),
            (r"^terakhir beli$", "purchase", 1),
            (r"^5 pembelian terakhir\s+(.+)$", "purchase", 5),
            (r"^kapan terakhir jual\s+(.+)$", "sale", 1),
            (r"^penjualan terakhir\s+(.+)$", "sale", 1),
            (r"^penjualan terakhir$", "sale", 1),
            (r"^terakhir jual\s+(.+)$", "sale", 1),
            (r"^terakhir jual$", "sale", 1),
            (r"^5 penjualan terakhir\s+(.+)$", "sale", 5),
            (r"^(?:faktur pembelian|invoice pembelian|info faktur)\s+(.+)$", "purchase_invoice", 1),
        ]

        for pattern, kind, limit in patterns:
            match = re.match(pattern, normalized, re.IGNORECASE)
            if match:
                return {
                    "kind": kind,
                    "limit": limit,
                    "query": (match.group(1).strip() if match.lastindex else ""),
                }

        return None

    def _extract_knowledge_request(self, text: str) -> dict[str, str] | None:
        """
        Deteksi query knowledge produk farmasi.

        Aturan diperketat agar tidak false-positive pada frasa umum:
        - Dosis/aturan minum: awalan eksplisit
        - Knowledge kandungan: harus ada frasa KONTEKS + OBYEK farmasi yang spesifik
        - Tidak cukup satu kata umum seperti "vitamin" atau "apa saja" saja
        """
        normalized = " ".join(text.strip().split())
        lowered    = normalized.lower()

        if not normalized:
            return None

        # --- Mode dosis: harus ada awalan eksplisit ---
        dose_triggers = [
            "dosis ",
            "aturan minum ",
            "cara minum ",
            "berapa dosis ",
        ]
        if any(lowered.startswith(t) for t in dose_triggers):
            return {"mode": "dose_reference", "query": normalized}

        # --- Mode knowledge kandungan: butuh marker KONTEKS + kata farmasi ---
        # Marker konteks: menunjukkan pertanyaan tentang kandungan/komposisi
        context_markers = [
            "mengandung",
            "kandungan",
            "kombinasi",
            "zat aktif",
            "komposisi",
        ]
        # Marker objek: kata farmasi spesifik untuk hindari false-positive
        pharma_anchors = [
            "obat", "tablet", "kapsul", "sirup", "injeksi",
            "mg", "ml", "strip", "botol",
            "cetirizine", "paracetamol", "ibuprofen", "amoxicillin",
            "multivitamin", "vitamin c", "vitamin d", "vitamin b",
            "ginseng", "herbal", "probiotik",
            "obat batuk", "obat flu", "obat demam", "obat alergi",
        ]

        has_context = any(m in lowered for m in context_markers)
        has_anchor  = any(a in lowered for a in pharma_anchors)

        # Perlu setidaknya konteks + anchor, atau awalan pertanyaan spesifik
        specific_prefixes = [
            "cari obat",
            "ada obat",
            "stok obat",
            "obat apa",
            "produk apa",
        ]
        if has_context and has_anchor:
            return {"mode": "product_knowledge", "query": normalized}
        if any(lowered.startswith(p) for p in specific_prefixes) and has_anchor:
            return {"mode": "product_knowledge", "query": normalized}

        # Hanya kata konteks saja tanpa anchor farmasi → cek apakah ada nama produk
        # Contoh: "kandungan" saja → follow-up dari lookup terakhir
        #          "kandungan tremenza" → cari "tremenza" lalu tampilkan komposisi
        if has_context and not has_anchor:
            remaining = lowered
            for m in context_markers:
                remaining = remaining.replace(m, "")
            remaining = remaining.strip()
            if remaining:
                return {"mode": "composition_followup", "query": normalized, "search_term": remaining}
            return {"mode": "composition_followup", "query": normalized}

        return None

    def _handle_history_request(self, message: dict[str, Any], request: dict[str, Any]) -> None:
        chat_id = message["chat"]["id"]
        kind = request["kind"]
        query = str(request.get("query") or "").strip()

        if kind == "purchase_invoice":
            self._handle_purchase_invoice_lookup(message, query)
            return

        lookup_product = self._resolve_history_product_from_lookup(message, query)
        if lookup_product:
            self._send_history_for_product(message, lookup_product, kind, int(request["limit"]))
            return

        if not query:
            self.telegram.send_message(
                chat_id,
                "Sebutkan nama produk, atau kirim `pembelian terakhir` / `penjualan terakhir` setelah hasil `stok` atau `harga` tampil.",
            )
            return

        try:
            result = self.laravel.search_products(self._with_actor(message, {"query": query, "limit": 5}))
        except Exception as exc:
            self.telegram.send_message(chat_id, f"Gagal mencari produk histori: {exc}")
            return

        matches = result.get("data") or []
        if not matches:
            # Fallback: coba cari dari lookup GPOS terakhir dan query langsung ke GPOS
            gpos_item = self._resolve_gpos_item_from_lookup(chat_id, query)
            if gpos_item:
                self._send_gpos_item_history(message, gpos_item, kind, int(request["limit"]))
                return
            self.telegram.send_message(
                chat_id,
                f"Saya belum menemukan produk yang cocok untuk `{query}`.\n"
                "Jika produk ini dari GPOS, coba jalankan *Sync Produk Penuh* di panel admin terlebih dahulu.",
            )
            return

        if len(matches) == 1:
            self._send_history_for_product(message, matches[0], kind, int(request["limit"]))
            return

        action_token = secrets.token_urlsafe(10)
        self.pending_actions.put(action_token, {
            "type": "history_pick",
            "kind": kind,
            "limit": int(request["limit"]),
            "query": query,
        })

        prompt = [f"{query.title()} yang mana?"]
        keyboard_rows: list[list[dict[str, Any]]] = []
        for idx, item in enumerate(matches[:5]):
            label = item.get("display_name") or item.get("name") or "-"
            prompt.append(f"{idx + 1}. {label} | PLU: {item.get('plu', '-')} | {item.get('unit', '-')}")
            keyboard_rows.append([{
                "text": f"{idx + 1}. {label[:30]}",
                "callback_data": f"history_pick:{action_token}:{item['id']}",
            }])

        self.telegram.send_message(
            chat_id,
            "\n".join(prompt),
            reply_markup={"inline_keyboard": keyboard_rows},
        )

    def _resolve_history_product_from_lookup(
        self,
        message: dict[str, Any],
        query: str,
    ) -> dict[str, Any] | None:
        chat_id = message["chat"]["id"]
        lookup = self.pending_actions.get(self._last_lookup_token(chat_id))
        if not lookup or (lookup.get("type") or "") != "lookup_last":
            return None

        lookup_kind = str(lookup.get("kind") or "")
        current_items = lookup.get("items") or []
        all_items = lookup.get("all_items") or current_items
        normalized_query = self._normalize_reference(query)
        normalized_lookup_query = self._normalize_reference(lookup.get("query"))

        candidates: list[dict[str, Any]] = []

        if normalized_query:
            for item in all_items:
                references = [
                    item.get("display_name"),
                    item.get("name"),
                    item.get("plu"),
                    item.get("barcode"),
                ]
                if any(self._normalize_reference(reference) == normalized_query for reference in references):
                    candidates.append(item)

            if not candidates and normalized_query == normalized_lookup_query:
                if len(all_items) == 1:
                    candidates = [all_items[0]]
                elif len(current_items) == 1:
                    candidates = [current_items[0]]
        else:
            if len(all_items) == 1:
                candidates = [all_items[0]]
            elif len(current_items) == 1:
                candidates = [current_items[0]]

        for item in candidates:
            product = self._resolve_lookup_product_for_defecta(message, item, lookup_kind)
            if product is not None:
                return product

        return None

    def _resolve_gpos_item_from_lookup(self, chat_id: int, query: str) -> dict[str, Any] | None:
        lookup = self.pending_actions.get(self._last_lookup_token(chat_id))
        if not lookup or (lookup.get("type") or "") != "lookup_last":
            return None
        if (lookup.get("kind") or "") not in {"gpos", "product"}:
            return None
        all_items = lookup.get("all_items") or lookup.get("items") or []
        if not all_items:
            return None
        normalized_query = self._normalize_reference(query)
        for item in all_items:
            refs = [
                item.get("name"), item.get("display_name"),
                item.get("plu"), item.get("barcode"),
            ]
            if any(self._normalize_reference(r) == normalized_query for r in refs if r):
                return item
        if not normalized_query and len(all_items) == 1:
            return all_items[0]
        if normalized_query == self._normalize_reference(lookup.get("query") or ""):
            if len(all_items) == 1:
                return all_items[0]
        return None

    def _send_gpos_item_history(
        self,
        message: dict[str, Any],
        gpos_item: dict[str, Any],
        kind: str,
        limit: int,
    ) -> None:
        chat_id = message["chat"]["id"]
        item_id = str(gpos_item.get("item_id") or "").strip()
        item_name = str(gpos_item.get("name") or gpos_item.get("display_name") or "").strip()
        if not item_id:
            self.telegram.send_message(chat_id, "Produk ini belum memiliki ID GPOS yang valid untuk pencarian histori.")
            return

        # Beri tahu user bahwa data diambil langsung dari GPOS, bukan DEWA
        self.telegram.send_message(
            chat_id,
            f"ℹ️ _Produk *{item_name}* belum tersinkronisasi ke DEWA. "
            "Data histori diambil langsung dari GPOS._",
        )

        try:
            if kind == "purchase":
                rows = self.gpos.get_purchase_history_by_item(item_id, item_name, limit=limit)
                self.telegram.send_message(chat_id, self._format_gpos_purchase_history(item_name, rows, limit))
            else:
                rows = self.gpos.get_sales_history_by_item(item_id, item_name, limit=limit)
                self.telegram.send_message(chat_id, self._format_gpos_sales_history(item_name, rows, limit))
        except Exception as exc:
            self.telegram.send_message(chat_id, f"Gagal mengambil histori dari GPOS: {exc}")

    def _format_gpos_purchase_history(self, item_name: str, rows: list[dict[str, Any]], limit: int) -> str:
        if not rows:
            return f"Belum ada histori pembelian untuk {item_name or 'produk ini'} dalam 1 tahun terakhir."
        row = rows[0]
        inv_no = row.get("purchase_invoice_no") or row.get("vendor_invoice_no") or "-"
        lines = [
            f"Pembelian terakhir untuk: {item_name or '-'}",
            "",
            f"Tanggal: {row.get('transaction_date') or '-'}",
            f"No. faktur: {inv_no}",
            f"Supplier: {row.get('vendor_name') or '-'}",
            f"Status: {row.get('transaction_status') or '-'}",
            f"Total: {self._format_rupiah(row.get('raw_total_amount') or row.get('total_amount'))}",
        ]
        return "\n".join(lines)

    def _format_gpos_sales_history(self, item_name: str, rows: list[dict[str, Any]], limit: int) -> str:
        if not rows:
            return f"Belum ada histori penjualan untuk {item_name or 'produk ini'} dalam 1 tahun terakhir."
        row = rows[0]
        payment_raw = str(row.get("payment_type_desc") or row.get("payment_type_code") or "").upper()
        payment_label = (
            "QRIS" if "QRIS" in payment_raw
            else "Debit Mandiri (On Us)" if "MANDIRI" in payment_raw and "BUKAN" not in payment_raw
            else "Debit Non-Mandiri (Off Us)" if "BUKAN MANDIRI" in payment_raw or "OFF" in payment_raw
            else "Kartu Kredit" if "KREDIT" in payment_raw or "CREDIT CARD" in payment_raw
            else "Tunai" if payment_raw in {"CASH", ""} else payment_raw.title()
        )
        lines = [
            f"Penjualan terakhir untuk: {item_name or '-'}",
            "",
            f"Tanggal: {row.get('transaction_date') or '-'}",
            f"No. transaksi: {row.get('sales_invoice_no') or '-'}",
            f"Pelanggan: {row.get('customer_name') or 'Regular'}",
            f"Total: {self._format_rupiah(row.get('raw_total_amount') or row.get('total_amount'))}",
            f"Jenis Pembayaran: {payment_label}",
        ]
        return "\n".join(lines)

    def _handle_knowledge_request(self, message: dict[str, Any], request: dict[str, str]) -> None:
        chat_id = message["chat"]["id"]

        if request["mode"] == "composition_followup":
            self._handle_composition_followup(message, request)
            return

        try:
            result = self.laravel.knowledge_query(
                self._with_actor(message, {
                    "query": request["query"],
                    "mode": request["mode"],
                })
            )
        except Exception as exc:
            self.logger.exception("Gagal memproses knowledge query")
            self.telegram.send_message(chat_id, f"Gagal memproses pertanyaan knowledge internal: {exc}")
            return

        data = result.get("data") or {}
        answer = data.get("answer") or "Saya belum menemukan rujukan internal yang cukup kuat untuk pertanyaan ini."
        warning = data.get("warning")

        if warning:
            answer = f"{answer}\n\nPeringatan:\n{warning}"

        self.telegram.send_message(chat_id, answer)

    def _handle_composition_followup(self, message: dict[str, Any], request: dict[str, str] | None = None) -> None:
        """
        Tangani pertanyaan 'kandungan/komposisi/zat aktif'.
        - Jika ada search_term (nama produk/PLU) → cari di DEWA dulu, lalu fallback Halodoc.
        - Jika tidak ada → cek lookup_last → tampilkan dari DEWA → fallback Halodoc/Alodokter.
        """
        chat_id = message["chat"]["id"]
        search_term = (request or {}).get("search_term", "").strip()

        # --- Jika user menyebut nama produk/PLU spesifik: cari dulu ---
        if search_term:
            try:
                search_result = self.laravel.search_products(
                    self._with_actor(message, {"query": search_term, "limit": 8})
                )
                items = search_result.get("data") or []
            except Exception as exc:
                self.logger.exception("Gagal mencari produk untuk komposisi: %s", search_term)
                self.telegram.send_message(chat_id, f"Gagal mencari produk '{search_term}': {exc}")
                return

            if not items:
                # Tidak ketemu di DEWA → coba GPOS dulu untuk dapatkan PLU asli
                gpos_plu = ""
                gpos_name = search_term
                if self.gpos.is_configured():
                    try:
                        gpos_page = self.gpos.search_items_page(search_term, limit=3, start=0)
                        gpos_result = gpos_page.get("items") or []
                    except Exception:
                        gpos_result = []
                    if gpos_result:
                        gpos_plu = str(gpos_result[0].get("plu") or "")
                        gpos_name = str(gpos_result[0].get("name") or search_term)
                        # Jika dapat PLU dari GPOS, coba cari ulang DEWA dengan PLU tersebut
                        if gpos_plu:
                            try:
                                dewa_retry = self.laravel.search_products(
                                    self._with_actor(message, {"query": gpos_plu, "limit": 5})
                                )
                                dewa_retry_items = dewa_retry.get("data") or []
                                # Cocokkan exact PLU
                                for d in dewa_retry_items:
                                    if str(d.get("plu") or "").strip() == gpos_plu:
                                        items = [d]
                                        break
                            except Exception:
                                pass

                # Jika setelah retry DEWA via PLU tetap kosong → lanjut Halodoc
                if not items:
                    self.telegram.send_message(chat_id, f"Mencari data komposisi *{search_term}* dari sumber eksternal...")
                    try:
                        result = self.laravel.drug_lookup_by_name(
                            search_term,
                            self._with_actor(message, {}),
                        )
                    except Exception as exc:
                        self.logger.exception("Gagal drug_lookup_by_name: %s", search_term)
                        self.telegram.send_message(
                            chat_id,
                            f"Data komposisi *{search_term}* belum ada di DEWA dan pencarian eksternal gagal.",
                        )
                        return

                    if not result.get("found"):
                        self.telegram.send_message(
                            chat_id,
                            f"Data komposisi *{search_term}* belum ditemukan di sumber manapun.\n"
                            "Coba jalankan enrichment AI melalui panel admin DEWA.",
                        )
                        return

                    # Simpan sementara sebagai produk virtual dengan PLU dari GPOS jika ada
                    product = {
                        "id": 0,
                        "plu": gpos_plu,
                        "display_name": gpos_name,
                        "name": gpos_name,
                        "zat_aktif": "",
                        "nama_generik": "",
                        "komposisi": [],
                    }
                    self._send_composition_for_product(message, product, external_result=result)
                    return

            # Simpan ke lookup_last agar konsisten
            lookup_data: dict[str, Any] = {
                "type": "lookup_last",
                "items": items[:5],
                "all_items": items,
                "ts": datetime.now().isoformat(),
            }
            self.pending_actions.put(self._last_lookup_token(chat_id), lookup_data, ttl_minutes=180)

            if len(items) == 1:
                self._send_composition_for_product(message, items[0])
            else:
                # Multi hasil → minta user pilih
                lines = ["Kandungan produk mana yang ingin diketahui?\n"]
                for idx, item in enumerate(items[:8], start=1):
                    name = item.get("display_name") or item.get("name") or "-"
                    lines.append(f"{idx}. {name}")
                lines.append("\nBalas dengan nomor urut.")
                self.telegram.send_message(chat_id, "\n".join(lines))
                self.pending_actions.put(
                    f"composition_pick:{chat_id}",
                    {"type": "composition_pick", "items": items[:8]},
                    ttl_minutes=15,
                )
            return

        # --- Tidak ada search_term: gunakan lookup_last ---
        lookup = self.pending_actions.get(self._last_lookup_token(chat_id))

        if not lookup or (lookup.get("type") or "") != "lookup_last":
            self.telegram.send_message(
                chat_id,
                "Sebutkan nama produk yang ingin diketahui kandungannya, "
                "atau cari produk terlebih dahulu (misal: *stok paracetamol*) "
                "lalu kirim *kandungan*.",
            )
            return

        all_items = lookup.get("all_items") or lookup.get("items") or []
        if not all_items:
            self.telegram.send_message(chat_id, "Daftar produk terakhir kosong. Cari produk terlebih dahulu.")
            return

        # Jika lebih dari 1 produk di lookup → minta user pilih
        if len(all_items) > 1:
            lines = ["Kandungan produk mana yang ingin diketahui?\n"]
            for idx, item in enumerate(all_items[:8], start=1):
                name = item.get("display_name") or item.get("name") or "-"
                lines.append(f"{idx}. {name}")
            lines.append("\nBalas dengan nomor urut.")
            self.telegram.send_message(chat_id, "\n".join(lines))

            # Simpan pending pilihan komposisi
            self.pending_actions.put(
                f"composition_pick:{chat_id}",
                {"type": "composition_pick", "items": all_items[:8]},
                ttl_minutes=15,
            )
            return

        # Satu produk — tampilkan langsung
        product = all_items[0]
        self._send_composition_for_product(message, product)

    def _send_composition_for_product(self, message: dict[str, Any], product: dict[str, Any], external_result: dict[str, Any] | None = None) -> None:
        """Format dan kirim data komposisi satu produk. Fallback ke Halodoc jika DEWA kosong.

        Jika external_result diberikan (sudah di-fetch oleh caller), gunakan langsung
        tanpa memanggil API lagi.
        """
        chat_id = message["chat"]["id"]
        name = product.get("display_name") or product.get("name") or "-"
        product_id = int(product.get("id") or 0)

        zat_aktif    = (product.get("zat_aktif") or "").strip()
        nama_generik = (product.get("nama_generik") or "").strip()
        komposisi    = product.get("komposisi") or []
        if isinstance(komposisi, str):
            import json as _json
            try:
                komposisi = _json.loads(komposisi)
            except Exception:
                komposisi = []

        # --- Resolusi DEWA product via PLU jika produk dari GPOS tanpa data DEWA ---
        # Ini harus dilakukan SEBELUM has_dewa_data DAN external_result check agar:
        # 1. Data yang sudah disimpan ke DEWA (misal dari Halodoc save sebelumnya) langsung terbaca.
        # 2. external_result bisa di-save ke DEWA product yang baru ditemukan via PLU.
        plu = (product.get("plu") or "").strip()
        if not product_id and plu and not bool(zat_aktif or nama_generik or komposisi):
            try:
                search_result = self.laravel.search_products(
                    self._with_actor(message, {"query": plu, "limit": 5})
                )
                dewa_items = search_result.get("data") or []
                for dewa_p in dewa_items:
                    if str(dewa_p.get("plu") or "").strip() == plu:
                        product_id = int(dewa_p.get("id") or 0)
                        product["id"] = product_id
                        product["nama_generik"] = dewa_p.get("nama_generik", "")
                        product["zat_aktif"]    = dewa_p.get("zat_aktif", "")
                        product["komposisi"]     = dewa_p.get("komposisi", [])
                        product["bentuk_sediaan"] = dewa_p.get("bentuk_sediaan", "")
                        product["kekuatan"]       = dewa_p.get("kekuatan", "")
                        product["isi_kemasan"]    = dewa_p.get("isi_kemasan", "")
                        # Re-ekstrak setelah resolusi
                        zat_aktif    = (product.get("zat_aktif") or "").strip()
                        nama_generik = (product.get("nama_generik") or "").strip()
                        komposisi    = product.get("komposisi") or []
                        if isinstance(komposisi, str):
                            import json as _json
                            try:
                                komposisi = _json.loads(komposisi)
                            except Exception:
                                komposisi = []
                        self.logger.info("DEWA product ditemukan via PLU %s → id=%s", plu, product_id)
                        break
            except Exception:
                self.logger.warning("Gagal mencari DEWA product via PLU %s", plu, exc_info=True)

        # --- Jika caller sudah menyediakan hasil eksternal, tampilkan DAN simpan ke DEWA ---
        if external_result and external_result.get("found"):
            self._display_external_composition(message, product, external_result)
            # Jika setelah resolusi PLU product_id valid, simpan enrichment dari external_result
            if product_id > 0:
                try:
                    enrichment_payload = {
                        "zat_aktif": external_result.get("zat_aktif", ""),
                        "nama_generik": external_result.get("nama_generik", ""),
                        "komposisi": external_result.get("komposisi", []),
                        "bentuk_sediaan": external_result.get("bentuk_sediaan", ""),
                        "kekuatan": external_result.get("kekuatan", ""),
                        "isi_kemasan": external_result.get("isi_kemasan", ""),
                        "source": external_result.get("source", "Halodoc"),
                        "confidence": 1.0,
                    }
                    self.laravel.save_product_composition(
                        product_id, enrichment_payload,
                        self._with_actor(message, {}),
                    )
                    self.logger.info("external_result disimpan ke DEWA product %s via PLU %s", product_id, plu)
                except Exception:
                    self.logger.warning("Gagal menyimpan external_result ke DEWA product %s", product_id, exc_info=True)
            return

        has_dewa_data = bool(zat_aktif or nama_generik or komposisi)

        if has_dewa_data:
            bentuk_sediaan = (product.get("bentuk_sediaan") or "").strip()
            kekuatan_val   = (product.get("kekuatan") or "").strip()
            isi_kemasan    = (product.get("isi_kemasan") or "").strip()
            plu_val        = (product.get("plu") or "").strip()

            lines = [f"📋 *Komposisi / Kandungan Detail*", f"*Produk:* {name}"]
            if plu_val:
                lines[-1] += f" (PLU: `{plu_val}`)"
            lines.append("")

            if zat_aktif:
                lines.append(f"• Zat Aktif: {zat_aktif}")
            if nama_generik:
                lines.append(f"• Nama Generik: {nama_generik}")
            if bentuk_sediaan:
                lines.append(f"• Bentuk Sediaan: {bentuk_sediaan}")
            if kekuatan_val:
                lines.append(f"• Kekuatan: {kekuatan_val}")
            if isi_kemasan:
                lines.append(f"• Isi Kemasan: {isi_kemasan}")

            if komposisi:
                lines.append("")
                lines.append("*Komposisi Detail:*")
                for idx, comp in enumerate(komposisi, start=1):
                    if isinstance(comp, dict):
                        comp_name = comp.get("name", "")
                        strength  = comp.get("strength", "")
                        lines.append(f"  {idx}. {comp_name} — {strength}".strip())
                    else:
                        lines.append(f"  {idx}. {comp}")

            lines.append("")
            lines.append("_Data tersimpan di DEWA ✅_")

            # --- CRUD keyboard untuk komposisi ---
            keyboard_rows = [[
                {"text": "➕ Tambah", "callback_data": f"comp_add:{chat_id}:{product_id}"},
                {"text": "✏️ Edit", "callback_data": f"comp_edit:{chat_id}:{product_id}"},
                {"text": "🗑 Hapus", "callback_data": f"comp_delete:{chat_id}:{product_id}"},
            ]]
            self.telegram.send_message(
                chat_id, "\n".join(lines),
                reply_markup={"inline_keyboard": keyboard_rows},
                parse_mode="Markdown",
            )
            # Pre-populate comp_edit pending action agar tombol Edit/Hapus bisa membaca data komposisi
            self.pending_actions.put(
                f"comp_edit:{chat_id}",
                {"type": "comp_edit", "product_id": product_id, "komposisi": komposisi},
                ttl_minutes=15,
            )
            return

        # Tidak ada data di DEWA → coba Halodoc/Alodokter
        # (product_id & plu sudah di-resolve di atas jika memungkinkan)

        if product_id:
            self.telegram.send_message(chat_id, f"Mencari data komposisi *{name}* dari sumber eksternal...")
            try:
                result = self.laravel.drug_lookup(
                    int(product_id),
                    self._with_actor(message, {}),
                )
            except Exception as exc:
                self.logger.exception("Gagal drug_lookup via Laravel")
                self.telegram.send_message(
                    chat_id,
                    f"Data komposisi *{name}* belum ada di DEWA dan pencarian eksternal gagal: {exc}",
                )
                return
        else:
            # Produk dari GPOS tanpa product_id → lookup by name
            self.telegram.send_message(chat_id, f"Mencari data komposisi *{name}* dari sumber eksternal...")
            try:
                result = self.laravel.drug_lookup_by_name(
                    name,
                    self._with_actor(message, {}),
                )
            except Exception as exc:
                self.logger.exception("Gagal drug_lookup_by_name via Laravel")
                self.telegram.send_message(
                    chat_id,
                    f"Data komposisi *{name}* belum ada di DEWA dan pencarian eksternal gagal: {exc}",
                )
                return
            product_id = 0  # marker: produk belum terdaftar di DEWA

        if not result.get("found"):
            self.telegram.send_message(
                chat_id,
                f"Data komposisi *{name}* belum ditemukan di DEWA maupun sumber referensi eksternal.\n"
                "Coba jalankan enrichment AI melalui panel admin DEWA.",
            )
            return

        source  = result.get("source", "sumber eksternal")
        context = result.get("context") or ""
        matched = result.get("matched_name") or name

        # --- Multi varian: tampilkan daftar opsi untuk user pilih ---
        if result.get("multiple") and result.get("variants"):
            variants = result.get("variants") or []
            variant_lines = [
                f"*{name}*",
                f"_{source} memiliki beberapa opsi. Pilih yang sesuai:_",
                "",
            ]
            keyboard_rows: list[list[dict[str, Any]]] = []
            for idx, v in enumerate(variants[:5], start=1):
                v_name = v.get("name", "-")
                v_slug = v.get("slug", "")
                variant_lines.append(f"{idx}. {v_name}")
                keyboard_rows.append([{
                    "text": str(idx),
                    "callback_data": f"composition_variant:{chat_id}:{product_id}:{v_slug}",
                }])

            self.pending_actions.put(
                f"composition_confirm:{chat_id}",
                {
                    "product_id": product_id,
                    "product_name": name,
                    "source": source,
                    "variants": variants,
                },
                ttl_minutes=15,
            )
            self.telegram.send_message(
                chat_id,
                "\n".join(variant_lines),
                reply_markup={"inline_keyboard": keyboard_rows},
            )
            return

        lines = [
            f"*{name}*",
            f"_(Data dari {source} — belum tersimpan di DEWA)_",
            "",
            f"Dicocokkan dengan: {matched}",
            "",
        ]
        if context:
            lines.append(context[:1200])

        lines += [
            "",
            f"ℹ️ Data ini diambil langsung dari *{source}*.",
        ]

        # Simpan data lookup untuk digunakan saat user konfirmasi simpan
        self.pending_actions.put(
            f"composition_confirm:{chat_id}",
            {
                "product_id": product_id,
                "product_name": name,
                "zat_aktif": result.get("zat_aktif") or "",
                "nama_generik": result.get("nama_generik") or "",
                "komposisi": result.get("komposisi") or [],
                "bentuk_sediaan": result.get("bentuk_sediaan") or "",
                "kekuatan": result.get("kekuatan") or "",
                "isi_kemasan": result.get("isi_kemasan") or "",
                "source": source,
                "context": context,
                "matched_name": matched,
            },
            ttl_minutes=15,
        )

        reply_markup = {
            "inline_keyboard": [[
                {"text": "Ya, data benar", "callback_data": f"composition_yes:{chat_id}:{product_id}"},
                {"text": "Tidak", "callback_data": f"composition_no:{chat_id}"},
            ]]
        }
        self.telegram.send_message(chat_id, "\n".join(lines), reply_markup=reply_markup)

    def _display_external_composition(self, message: dict[str, Any], product: dict[str, Any], result: dict[str, Any]) -> None:
        """Tampilkan hasil lookup eksternal (Halodoc/Alodokter) dengan keyboard konfirmasi."""
        chat_id = message["chat"]["id"]
        name = product.get("display_name") or product.get("name") or "-"
        product_id = int(product.get("id") or 0)
        source = result.get("source", "sumber eksternal")
        context = result.get("context") or ""
        matched = result.get("matched_name") or name

        # --- Multi varian ---
        if result.get("multiple") and result.get("variants"):
            variants = result.get("variants") or []
            variant_lines = [
                f"*{name}*",
                f"_{source} memiliki beberapa opsi. Pilih yang sesuai:_",
                "",
            ]
            keyboard_rows: list[list[dict[str, Any]]] = []
            for idx, v in enumerate(variants[:5], start=1):
                v_name = v.get("name", "-")
                v_slug = v.get("slug", "")
                variant_lines.append(f"{idx}. {v_name}")
                keyboard_rows.append([{
                    "text": str(idx),
                    "callback_data": f"composition_variant:{chat_id}:{product_id}:{v_slug}",
                }])
            self.pending_actions.put(
                f"composition_confirm:{chat_id}",
                {"product_id": product_id, "product_name": name, "source": source, "variants": variants},
                ttl_minutes=15,
            )
            self.telegram.send_message(
                chat_id, "\n".join(variant_lines),
                reply_markup={"inline_keyboard": keyboard_rows},
            )
            return

        # --- Single result ---
        lines = [
            f"*{name}*",
            f"_(Data dari {source} — belum tersimpan di DEWA)_",
            "",
            f"Dicocokkan dengan: {matched}",
            "",
        ]
        if context:
            lines.append(context[:1200])
        lines += ["", f"ℹ️ Data ini diambil langsung dari *{source}*."]

        self.pending_actions.put(
            f"composition_confirm:{chat_id}",
            {
                "product_id": product_id,
                "product_name": name,
                "zat_aktif": result.get("zat_aktif") or "",
                "nama_generik": result.get("nama_generik") or "",
                "komposisi": result.get("komposisi") or [],
                "bentuk_sediaan": result.get("bentuk_sediaan") or "",
                "kekuatan": result.get("kekuatan") or "",
                "isi_kemasan": result.get("isi_kemasan") or "",
                "source": source,
                "context": context,
                "matched_name": matched,
            },
            ttl_minutes=15,
        )

        reply_markup = {
            "inline_keyboard": [[
                {"text": "Ya, data benar", "callback_data": f"composition_yes:{chat_id}:{product_id}"},
                {"text": "Tidak", "callback_data": f"composition_no:{chat_id}"},
            ]]
        }
        self.telegram.send_message(chat_id, "\n".join(lines), reply_markup=reply_markup)

    # ── Composition confirm callbacks ──────────────────────────────────────────

    def _handle_composition_variant(
        self, callback_id: str, chat_id: int, product_id: int, slug: str, actor: dict[str, Any]
    ) -> None:
        """User memilih varian dari daftar multi hasil Halodoc."""
        pending = self.pending_actions.get(f"composition_confirm:{chat_id}")
        if not pending:
            self.telegram.answer_callback_query(callback_id, "Sesi sudah kadaluarsa.")
            return

        variants = pending.get("variants") or []
        selected_name = "varian terpilih"
        for v in variants:
            if v.get("slug") == slug:
                selected_name = v.get("name", selected_name)
                break

        self.telegram.answer_callback_query(callback_id, f"Memilih: {selected_name}")

        # Ambil detail dari Halodoc via Laravel untuk slug terpilih
        try:
            detail = self.laravel.drug_lookup_by_slug(
                slug,
                self._with_actor_from_user(actor, {}),
            )
        except Exception as exc:
            self.telegram.send_message(chat_id, f"Gagal mengambil detail varian: {exc}")
            return

        if not detail.get("found"):
            self.telegram.send_message(chat_id, f"Detail untuk {selected_name} tidak ditemukan di Halodoc.")
            return

        context = detail.get("context") or ""
        self.pending_actions.put(
            f"composition_confirm:{chat_id}",
            {
                "product_id": product_id,
                "product_name": pending.get("product_name", ""),
                "zat_aktif": detail.get("zat_aktif") or "",
                "nama_generik": detail.get("nama_generik") or "",
                "komposisi": detail.get("komposisi") or [],
                "bentuk_sediaan": detail.get("bentuk_sediaan") or "",
                "kekuatan": detail.get("kekuatan") or "",
                "isi_kemasan": detail.get("isi_kemasan") or "",
                "source": detail.get("source", "Halodoc"),
                "context": context,
                "matched_name": detail.get("matched_name") or selected_name,
            },
            ttl_minutes=15,
        )

        lines = [
            f"*{pending.get('product_name', '-')}*",
            f"_(Data dari Halodoc — belum tersimpan di DEWA)_",
            "",
            f"Dicocokkan dengan: {selected_name}",
            "",
        ]
        if context:
            lines.append(context[:1200])
        lines += [
            "",
            "ℹ️ Data ini diambil langsung dari Halodoc.",
        ]

        reply_markup = {
            "inline_keyboard": [[
                {"text": "Ya, data benar", "callback_data": f"composition_yes:{chat_id}:{product_id}"},
                {"text": "Tidak", "callback_data": f"composition_no:{chat_id}"},
            ]]
        }
        self.telegram.send_message(chat_id, "\n".join(lines), reply_markup=reply_markup)

    def _handle_composition_yes(self, callback_id: str, chat_id: int, product_id: int) -> None:
        """User mengkonfirmasi data komposisi dari Halodoc benar."""
        pending = self.pending_actions.get(f"composition_confirm:{chat_id}")
        if not pending:
            self.telegram.answer_callback_query(callback_id, "Sesi konfirmasi sudah kadaluarsa.")
            return

        if product_id > 0:
            reply_markup = {
                "inline_keyboard": [[
                    {"text": "Simpan ke DEWA", "callback_data": f"composition_save:{chat_id}:{product_id}"},
                    {"text": "Tidak perlu", "callback_data": f"composition_skip:{chat_id}"},
                ]]
            }
            self.telegram.send_message(
                chat_id,
                "Apakah data ini ingin disimpan ke database DEWA?",
                reply_markup=reply_markup,
            )
            self.telegram.answer_callback_query(callback_id, "Data dikonfirmasi benar.")
        else:
            # Produk belum terdaftar di DEWA → tampilkan data yang sudah ditemukan
            self.telegram.answer_callback_query(callback_id, "Produk belum terdaftar di DEWA.")
            zat_aktif    = pending.get("zat_aktif") or "-"
            nama_generik = pending.get("nama_generik") or "-"
            source       = pending.get("source") or "sumber eksternal"
            matched_name = pending.get("matched_name") or pending.get("product_name") or "-"
            context      = pending.get("context") or ""
            lines = [
                f"*{pending.get('product_name', '-')}*",
                f"_(Data dari {source})_",
                f"Dicocokkan dengan: {matched_name}",
                "",
                f"Nama generik: {nama_generik}",
                f"Zat aktif: {zat_aktif}",
            ]
            if context:
                lines.append(f"\n{context[:1000]}")
            lines.append("\n⚠️ Produk ini belum terdaftar di DEWA. Minta admin menambahkan produk dengan PLU tersebut terlebih dahulu, lalu gunakan perintah *kandungan* lagi untuk menyimpan data.")
            self.telegram.send_message(chat_id, "\n".join(lines))

    def _handle_composition_no(self, callback_id: str, chat_id: int) -> None:
        """User menolak data komposisi dari Halodoc."""
        self.pending_actions.delete(f"composition_confirm:{chat_id}")
        self.telegram.answer_callback_query(callback_id, "Oke, data diabaikan.")

    def _handle_composition_save(self, callback_id: str, chat_id: int, product_id: int, actor: dict[str, Any]) -> None:
        """User meminta simpan data komposisi ke DEWA."""
        pending = self.pending_actions.get(f"composition_confirm:{chat_id}")
        if not pending:
            self.telegram.answer_callback_query(callback_id, "Sesi konfirmasi sudah kadaluarsa.")
            return

        try:
            payload = {
                "zat_aktif": pending.get("zat_aktif", ""),
                "nama_generik": pending.get("nama_generik", ""),
                "komposisi": pending.get("komposisi", []),
                "bentuk_sediaan": pending.get("bentuk_sediaan", ""),
                "kekuatan": pending.get("kekuatan", ""),
                "isi_kemasan": pending.get("isi_kemasan", ""),
                "source": pending.get("source", "sumber eksternal"),
            }
            result = self.laravel.save_product_composition(product_id, payload, self._with_actor_from_user(actor, {}))
        except Exception as exc:
            self.logger.exception("Gagal menyimpan komposisi")
            self.telegram.answer_callback_query(callback_id, f"Gagal menyimpan: {exc}")
            return

        self.pending_actions.delete(f"composition_confirm:{chat_id}")
        self.telegram.answer_callback_query(callback_id, "Data komposisi berhasil disimpan ke DEWA!")
        self.telegram.send_message(
            chat_id,
            f"✅ Data komposisi *{pending.get('product_name', '-')}* berhasil disimpan ke DEWA.\n"
            f"Sumber: {pending.get('source', 'sumber eksternal')}.",
        )

    def _handle_composition_skip(self, callback_id: str, chat_id: int) -> None:
        """User memutuskan tidak perlu menyimpan data komposisi."""
        self.pending_actions.delete(f"composition_confirm:{chat_id}")
        self.telegram.answer_callback_query(callback_id, "Oke, data tidak disimpan.")

    # ── Composition CRUD (Tambah/Edit/Hapus dari tampilan DEWA) ────────────────

    # Regex untuk ekstrak dosis dari akhir string (ekuivalen PHP ZatAktifHelper::extractDose)
    _DOSE_RE = re.compile(
        r'\b(\d+(?:[.,]\d+)?\s*(?:/\s*\d+(?:[.,]\d+)?\s*)?(?:m?g|ml|mcg|IU|g|%)(?:/\d+(?:[.,]\d+)?\s*(?:m?g|ml|mcg|IU|g|%))?)\s*$',
        re.IGNORECASE,
    )

    @classmethod
    def _parse_comp_row(cls, text: str) -> tuple[str, str] | None:
        """Parse user input "Nama Kandungan Kekuatan" menjadi (name, strength).

        Dosis diekstrak dari akhir string via regex. Jika tidak ada dosis,
        seluruh teks dianggap sebagai name.
        """
        t = text.strip()
        if not t:
            return None
        m = cls._DOSE_RE.search(t)
        if m:
            dose = m.group(1)
            name = t[:m.start()].strip()
            if name:
                return name, dose
        # Tidak ada dosis → seluruh teks sebagai nama
        return t, ""

    def _get_composition_for_edit(self, chat_id: int) -> tuple[list[dict[str, Any]], int] | None:
        """Ambil data komposisi sementara dari pending action untuk CRUD."""
        pending = self.pending_actions.get(f"comp_edit:{chat_id}")
        if not pending or (pending.get("type") or "") != "comp_edit":
            return None
        return pending.get("komposisi") or [], int(pending.get("product_id") or 0)

    def _handle_comp_add(self, callback_id: str, chat_id: int, product_id: int) -> None:
        """User klik [➕ Tambah] — minta input komposisi baru."""
        # Ambil komposisi saat ini dari pending atau dari DB terakhir
        existing = self._get_composition_for_edit(chat_id)
        items = existing[0] if existing else []

        self.pending_actions.put(
            f"comp_edit:{chat_id}",
            {"type": "comp_edit", "product_id": product_id, "komposisi": items},
            ttl_minutes=15,
        )
        self.pending_actions.put(
            f"comp_add_text:{chat_id}",
            {"type": "comp_add_text", "product_id": product_id},
            ttl_minutes=5,
        )
        self.telegram.answer_callback_query(callback_id, "Kirim komposisi baru")
        self.telegram.send_message(
            chat_id,
            "➕ *Tambah Komposisi*\n"
            "Kirim dalam format: `Nama Kandungan Kekuatan`\n"
            "Contoh: `Pseudoephedrine HCl 7.5 mg`\n\n"
            "Ketik `batal` untuk membatalkan.",
            parse_mode="Markdown",
        )

    def _handle_comp_edit_pick(self, callback_id: str, chat_id: int, product_id: int) -> None:
        """User klik [✏️ Edit] — tampilkan daftar untuk dipilih."""
        existing = self._get_composition_for_edit(chat_id)
        items = existing[0] if existing else []
        if not items:
            self.telegram.answer_callback_query(callback_id, "Belum ada data komposisi")
            return

        self.pending_actions.put(
            f"comp_edit:{chat_id}",
            {"type": "comp_edit", "product_id": product_id, "komposisi": items},
            ttl_minutes=15,
        )
        lines = ["✏️ *Edit Komposisi — Pilih nomor:*\n"]
        keyboard_rows: list[list[dict[str, Any]]] = []
        for idx, comp in enumerate(items[:8], start=1):
            name = comp.get("name", "-") if isinstance(comp, dict) else str(comp)
            strength = comp.get("strength", "") if isinstance(comp, dict) else ""
            lines.append(f"{idx}. {name} — {strength}")
            keyboard_rows.append([{
                "text": str(idx),
                "callback_data": f"comp_edit_row:{chat_id}:{product_id}:{idx - 1}",
            }])
        self.telegram.answer_callback_query(callback_id, "Pilih nomor untuk diedit")
        self.telegram.send_message(
            chat_id, "\n".join(lines),
            reply_markup={"inline_keyboard": keyboard_rows},
            parse_mode="Markdown",
        )

    def _handle_comp_edit_row(self, callback_id: str, chat_id: int, product_id: int, row: int) -> None:
        """User pilih nomor row — minta input baru."""
        existing = self._get_composition_for_edit(chat_id)
        if not existing:
            self.telegram.answer_callback_query(callback_id, "Sesi edit sudah kadaluarsa.")
            return
        items = existing[0]
        if row < 0 or row >= len(items):
            self.telegram.answer_callback_query(callback_id, "Nomor tidak valid.")
            return
        old = items[row]
        old_name = old.get("name", "-") if isinstance(old, dict) else str(old)
        old_strength = old.get("strength", "") if isinstance(old, dict) else ""

        self.pending_actions.put(
            f"comp_edit_row:{chat_id}",
            {"type": "comp_edit_row", "product_id": product_id, "row": row},
            ttl_minutes=5,
        )
        self.telegram.answer_callback_query(callback_id, f"Edit baris {row + 1}")
        self.telegram.send_message(
            chat_id,
            f"✏️ *Edit Baris {row + 1}*\n"
            f"Data lama: `{old_name} {old_strength}`\n\n"
            f"Kirim data baru (format: `Nama Kekuatan`)\n"
            f"Ketik `batal` untuk membatalkan.",
            parse_mode="Markdown",
        )

    def _handle_comp_delete_pick(self, callback_id: str, chat_id: int, product_id: int) -> None:
        """User klik [🗑 Hapus] — tampilkan daftar untuk dipilih."""
        existing = self._get_composition_for_edit(chat_id)
        items = existing[0] if existing else []
        if not items:
            self.telegram.answer_callback_query(callback_id, "Belum ada data komposisi")
            return

        self.pending_actions.put(
            f"comp_edit:{chat_id}",
            {"type": "comp_edit", "product_id": product_id, "komposisi": items},
            ttl_minutes=15,
        )
        lines = ["🗑 *Hapus Komposisi — Pilih nomor:*\n"]
        keyboard_rows: list[list[dict[str, Any]]] = []
        for idx, comp in enumerate(items[:8], start=1):
            name = comp.get("name", "-") if isinstance(comp, dict) else str(comp)
            strength = comp.get("strength", "") if isinstance(comp, dict) else ""
            lines.append(f"{idx}. {name} — {strength}")
            keyboard_rows.append([{
                "text": f"🗑 {idx}",
                "callback_data": f"comp_delete_row:{chat_id}:{product_id}:{idx - 1}",
            }])
        self.telegram.answer_callback_query(callback_id, "Pilih nomor untuk dihapus")
        self.telegram.send_message(
            chat_id, "\n".join(lines),
            reply_markup={"inline_keyboard": keyboard_rows},
            parse_mode="Markdown",
        )

    def _handle_comp_delete_row(self, callback_id: str, chat_id: int, product_id: int, row: int) -> None:
        """User pilih nomor row untuk dihapus — langsung hapus."""
        existing = self._get_composition_for_edit(chat_id)
        if not existing:
            self.telegram.answer_callback_query(callback_id, "Sesi edit sudah kadaluarsa.")
            return
        items = existing[0]
        if row < 0 or row >= len(items):
            self.telegram.answer_callback_query(callback_id, "Nomor tidak valid.")
            return
        deleted = items.pop(row)
        del_name = deleted.get("name", "-") if isinstance(deleted, dict) else str(deleted)
        del_strength = deleted.get("strength", "") if isinstance(deleted, dict) else ""

        self.pending_actions.put(
            f"comp_edit:{chat_id}",
            {"type": "comp_edit", "product_id": product_id, "komposisi": items},
            ttl_minutes=15,
        )
        self.telegram.answer_callback_query(callback_id, f"Dihapus: {del_name}")
        self.telegram.send_message(
            chat_id,
            f"✅ Baris dihapus: *{del_name} — {del_strength}*\n"
            f"Sisa: {len(items)} baris. Klik [💾 Simpan] untuk menyimpan perubahan.",
            parse_mode="Markdown",
        )

    def _handle_comp_save_dewa(self, callback_id: str, chat_id: int, product_id: int, actor: dict[str, Any]) -> None:
        """User klik [💾 Simpan] — simpan komposisi yang sudah diedit ke DEWA."""
        existing = self._get_composition_for_edit(chat_id)
        items = existing[0] if existing else []
        if not items:
            self.telegram.answer_callback_query(callback_id, "Tidak ada perubahan untuk disimpan.")
            return

        try:
            payload = {"komposisi": items, "source": "Telegram Bot"}
            self.laravel.save_product_composition(product_id, payload, self._with_actor_from_user(actor, {}))
        except Exception as exc:
            self.logger.exception("Gagal menyimpan komposisi CRUD")
            self.telegram.answer_callback_query(callback_id, f"Gagal: {exc}")
            return

        self.pending_actions.delete(f"comp_edit:{chat_id}")
        self.telegram.answer_callback_query(callback_id, "Komposisi berhasil disimpan ke DEWA!")
        self.telegram.send_message(chat_id, "✅ Komposisi berhasil disimpan ke database DEWA.")

    # ── History ────────────────────────────────────────────────────────────────

    def _handle_history_pick_callback(
        self,
        callback_id: str,
        chat_id: int,
        action_token: str,
        product_id: int,
        actor: dict[str, Any],
    ) -> None:
        pending = self.pending_actions.pop(action_token) or {}
        kind = pending.get("kind") or "purchase"
        limit = int(pending.get("limit") or 1)

        try:
            product_result = self.laravel.get_product(product_id, self._with_actor_from_user(actor, {}))
            product = product_result.get("data") or {}
        except Exception as exc:
            self.telegram.answer_callback_query(callback_id, "Produk tidak bisa dibuka.")
            self.telegram.send_message(chat_id, f"Gagal membuka produk histori: {exc}")
            return

        self.telegram.answer_callback_query(callback_id, "Produk dipilih.")
        self._send_history_for_product({"chat": {"id": chat_id}, "from": actor}, product, kind, limit)

    def _send_history_for_product(
        self,
        message: dict[str, Any],
        product: dict[str, Any],
        kind: str,
        limit: int,
    ) -> None:
        chat_id = message["chat"]["id"]
        telegram_user_id = self._actor_id(message)

        try:
            if kind == "purchase":
                result = self.laravel.get_purchase_history(int(product["id"]), telegram_user_id, limit)
                data = result.get("data") or {}
            else:
                result = self.laravel.get_sales_history(int(product["id"]), telegram_user_id, limit)
                data = result.get("data") or {}
        except Exception as exc:
            self.telegram.send_message(chat_id, f"Gagal mengambil histori: {exc}")
            return

        items = data.get("items") or []

        # Jika DEWA punya data histori → tampilkan
        if items:
            if kind == "purchase":
                self.telegram.send_message(chat_id, self._format_purchase_history_message(data, limit))
            else:
                self.telegram.send_message(chat_id, self._format_sales_history_message(data, limit))
            return

        # DEWA tidak punya histori → coba fallback ke GPOS via PLU
        plu = str(product.get("plu") or "").strip()
        product_name = str(product.get("display_name") or product.get("name") or "").strip()

        if not plu or not self.gpos.is_configured():
            # Tidak bisa fallback ke GPOS
            if kind == "purchase":
                self.telegram.send_message(chat_id, self._format_purchase_history_message(data, limit))
            else:
                self.telegram.send_message(chat_id, self._format_sales_history_message(data, limit))
            return

        self.telegram.send_message(
            chat_id,
            f"ℹ️ _Produk *{product_name}* belum memiliki data histori di DEWA. "
            "Mencoba mengambil langsung dari GPOS..._",
        )

        try:
            if kind == "purchase":
                rows = self.gpos.get_purchase_history_by_item(plu, product_name, limit=limit)
                self.telegram.send_message(chat_id, self._format_gpos_purchase_history(product_name, rows, limit))
            else:
                rows = self.gpos.get_sales_history_by_item(plu, product_name, limit=limit)
                self.telegram.send_message(chat_id, self._format_gpos_sales_history(product_name, rows, limit))
        except Exception as exc:
            self.telegram.send_message(chat_id, f"Gagal mengambil histori dari GPOS: {exc}")

    def _handle_purchase_invoice_lookup(self, message: dict[str, Any], invoice_number: str) -> None:
        chat_id = message["chat"]["id"]
        try:
            result = self.laravel.lookup_purchase_invoice(invoice_number, self._actor_id(message))
        except Exception as exc:
            self.telegram.send_message(chat_id, f"Gagal mencari faktur pembelian: {exc}")
            return

        invoice = (result.get("data") or {}).get("invoice") or {}
        items = invoice.get("items") or []
        lines = [
            f"Faktur pembelian: {invoice.get('invoice_number') or invoice_number}",
            f"Tanggal: {invoice.get('invoice_date') or '-'}",
            f"PBF: {invoice.get('supplier_name') or '-'}",
            f"Status: {invoice.get('status') or '-'} | Pembayaran: {invoice.get('payment_status') or '-'}",
            f"Total: {self._format_rupiah(invoice.get('total_amount'))}",
            "",
            "Item:",
        ]
        for idx, item in enumerate(items[:10], start=1):
            lines.append(
                f"{idx}. {item.get('product_name') or '-'} | {item.get('quantity') or 0} {item.get('unit_code') or '-'} | {self._format_rupiah(item.get('unit_price'))}"
            )

        if len(items) > 10:
            lines.append(f"... dan {len(items) - 10} item lainnya")

        self.telegram.send_message(chat_id, "\n".join(lines))

    def _format_purchase_history_message(self, data: dict[str, Any], limit: int) -> str:
        product = data.get("product") or {}
        items = data.get("items") or []
        if not items:
            return f"Belum ada histori pembelian untuk {product.get('name') or 'produk ini'}."

        if limit == 1:
            item = items[0]
            return "\n".join([
                f"Pembelian terakhir untuk: {product.get('name') or '-'}",
                "",
                f"Tanggal: {item.get('invoice_date') or '-'}",
                f"No. faktur: {item.get('invoice_number') or '-'}",
                f"Supplier: {item.get('supplier_name') or '-'}",
                f"Qty: {self._format_quantity(item.get('quantity'))} {item.get('unit_code') or '-'}",
                f"Harga beli: {self._format_rupiah(item.get('unit_price'))}",
            ])

        lines = [f"{limit} pembelian terakhir: {product.get('name') or '-'}"]
        for idx, item in enumerate(items, start=1):
            lines.append(
                f"{idx}. {item.get('invoice_date') or '-'} | {item.get('supplier_name') or '-'} | "
                f"Faktur: {item.get('invoice_number') or '-'} | "
                f"{self._format_quantity(item.get('quantity'))} {item.get('unit_code') or '-'} | "
                f"{self._format_rupiah(item.get('unit_price'))}"
            )
            for batch in item.get("batches") or []:
                lines.append(
                    f"   Batch {batch.get('batch_no') or '-'} | ED {batch.get('expired_date') or '-'} | Qty {self._format_quantity(batch.get('quantity'))}"
                )

        return "\n".join(lines)

    def _format_sales_history_message(self, data: dict[str, Any], limit: int) -> str:
        product = data.get("product") or {}
        items = data.get("items") or []
        if not items:
            return f"Belum ada histori penjualan untuk {product.get('name') or 'produk ini'}."

        if limit == 1:
            item = items[0]
            return "\n".join([
                f"Penjualan terakhir untuk: {product.get('name') or '-'}",
                "",
                f"Tanggal: {item.get('transaction_date') or '-'}",
                f"No. transaksi: {item.get('transaction_no') or '-'}",
                f"Pelanggan: {item.get('customer_name') or '-'}",
                f"Qty: {self._format_quantity(item.get('quantity'))} {item.get('unit_code') or '-'}",
                f"Total: {self._format_rupiah(item.get('total_amount'))}",
                f"Jenis Pembayaran: {self._format_sales_payment_label(item)}",
            ])

        lines = [f"{limit} penjualan terakhir: {product.get('name') or '-'}"]
        for idx, item in enumerate(items, start=1):
            lines.append(
                f"{idx}. {item.get('transaction_date') or '-'} | Transaksi: {item.get('transaction_no') or '-'} | "
                f"{self._format_quantity(item.get('quantity'))} {item.get('unit_code') or '-'} | "
                f"Total {self._format_rupiah(item.get('total_amount'))} | "
                f"{self._format_sales_payment_label(item)}"
            )
            for batch in item.get("batches") or []:
                lines.append(
                    f"   Batch {batch.get('batch_no') or '-'} | ED {batch.get('expired_date') or '-'} | Qty {self._format_quantity(batch.get('quantity'))}"
                )

        return "\n".join(lines)

    def _format_sales_payment_label(self, item: dict[str, Any]) -> str:
        label = (item.get("payment_label") or "").strip()
        if label:
            return label

        original = str(item.get("original_payment_type") or "").strip()
        return original or "-"

    def _format_quantity(self, value: Any) -> str:
        try:
            number = float(value or 0)
        except (TypeError, ValueError):
            return "0"

        if number.is_integer():
            return str(int(number))

        return f"{number:.2f}".rstrip("0").rstrip(".")

    @staticmethod
    def _product_valid_units(product: dict[str, Any]) -> list[str]:
        valid_units = product.get("valid_units") or []
        if isinstance(valid_units, list):
            cleaned = [str(unit).strip() for unit in valid_units if str(unit).strip()]
            if cleaned:
                return cleaned

        return BotService._parse_valid_units_from_isi_kemasan(product.get("isi_kemasan"))

    @staticmethod
    def _product_preferred_unit(product: dict[str, Any]) -> str | None:
        for key in ("preferred_defecta_unit", "operational_unit", "purchase_unit_code", "unit"):
            value = product.get(key)
            if value:
                return str(value).strip()
        return None

    def _product_requires_unit_confirmation(self, product: dict[str, Any]) -> bool:
        status = str(product.get("unit_decision_status") or "").strip().lower()
        preferred = (self._product_preferred_unit(product) or "").upper()
        valid_units = self._product_valid_units(product)

        if status in {"needs_confirmation", "ambiguous"}:
            return True
        if product.get("unit_ambiguous"):
            return True
        if not preferred:
            return True
        if preferred == "PCS" and len(valid_units) <= 1:
            return True
        return False

    def _format_product_choice_meta(self, item: dict[str, Any]) -> str:
        preferred = self._product_preferred_unit(item) or "-"
        valid_units = self._product_valid_units(item)
        extras: list[str] = [f"PLU: `{item.get('plu', '-')}`"]

        bentuk = (item.get("bentuk_sediaan") or "").strip()
        if bentuk:
            extras.append(bentuk)

        extras.append(f"Satuan operasional: {preferred}")
        if self._product_requires_unit_confirmation(item):
            extras.append("Satuan perlu dikonfirmasi")

        if valid_units:
            preview = valid_units[:4]
            suffix = "" if len(valid_units) <= 4 else ", dst"
            extras.append("Pilihan: " + ", ".join(preview) + suffix)

        isi_kemasan = (item.get("isi_kemasan") or "").strip()
        if isi_kemasan:
            extras.append(isi_kemasan)

        return " | ".join(extras)

    def _prompt_defecta_unit_confirmation(
        self,
        chat_id: int,
        product: dict[str, Any],
        jumlah: int,
        valid_units: list[str],
        reason: str | None = None,
    ) -> None:
        product_name = product.get("display_name") or product.get("name") or "-"
        self.pending_actions.put(
            self._defecta_satuan_token(chat_id),
            {
                "type": "defecta_satuan",
                "product": product,
                "jumlah": jumlah,
                "valid_units": valid_units,
            },
        )
        units_list = " / ".join(valid_units) if valid_units else "satuan operasional yang dipakai"
        extra = f"\nCatatan: {reason}" if reason else ""
        self.telegram.send_message(
            chat_id,
            f"Satuan perlu dikonfirmasi untuk {product_name}.\n"
            f"Pilihan: {units_list}{extra}\n"
            f"Balas dengan satuan yang dipakai, atau ketik `batal`.",
        )

    @staticmethod
    def _parse_valid_units_from_isi_kemasan(isi_kemasan: str | None) -> list[str]:
        """Ekstrak daftar satuan valid dari string isi_kemasan.

        "1 Box = 12 Pcs" → ["box", "pcs"]
        "1 Dus = 50 Pcs" → ["dus", "pcs"]
        "" atau None → []
        """
        if not isi_kemasan:
            return []
        units: list[str] = []
        for token in re.split(r"[=\s]+", isi_kemasan):
            word = re.sub(r"[^a-zA-Z]", "", token)
            if word and len(word) >= 2:
                units.append(word.lower())
        seen: set[str] = set()
        result: list[str] = []
        for u in units:
            if u not in seen:
                seen.add(u)
                result.append(u)
        return result

    @staticmethod
    def _format_satuan_display(item: dict[str, Any]) -> str:
        """Format tampilan satuan dengan info konversi (Fase 6.1a).

        Returns string seperti "box" atau "box (12 pcs)".
        """
        satuan = (item.get("satuan_diminta") or item.get("product_unit") or "").strip()
        isi_kemasan = item.get("isi_kemasan")
        if not satuan or not isi_kemasan:
            return satuan

        m = re.match(r"^\d+\s+(\w+)\s*=\s*(\d+)\s+(\w+)$", isi_kemasan, re.IGNORECASE)
        if not m:
            return satuan

        purchase_unit = m.group(1).lower()
        conversion_qty = m.group(2)
        base_unit = m.group(3).lower()

        satuan_lower = satuan.lower()
        if satuan_lower == purchase_unit:
            return f"{satuan} ({conversion_qty} {base_unit})"
        elif satuan_lower == base_unit:
            return satuan
        else:
            return f"{satuan} (1 {purchase_unit} = {conversion_qty} {base_unit})"

    def _parse_product_and_optional_quantity(self, raw: str) -> tuple[str, int | None, str | None]:
        """Parse product name, optional quantity, and optional unit from raw text.

        Returns (product_query, quantity_or_None, unit_or_None).
        Examples: "paracetamol 10 tab" → ("paracetamol", 10, "tab")
                  "2 bisolvon box" → ("bisolvon", 2, "box")
                  "insto" → ("insto", None, None)
        """
        candidate = raw.strip()
        if not candidate:
            return "", None, None

        prefixed_match = re.match(r"^(plu|barcode|kategori|category)\s+(.+)$", candidate, re.IGNORECASE)
        if prefixed_match:
            return prefixed_match.group(2).strip(), None, None

        # 1) Angka di depan: "2 paracetamol", "2 paracetamol box"
        m = re.match(r"^(\d+)\s+(.+)$", candidate)
        if m:
            qty = self._parse_positive_int(m.group(1))
            if qty is not None:
                rest = m.group(2).strip()
                # Cek apakah ada satuan di akhir: "paracetamol box" → ("paracetamol", qty, "box")
                unit_match = re.match(r"^(.+?)\s+([a-zA-Z]{2,15})$", rest)
                if unit_match and not self._is_dose_pattern(unit_match.group(2)):
                    return unit_match.group(1).strip(), qty, unit_match.group(2).strip()
                # Jika tidak ada satuan, coba ekstrak PLU dari rest
                # "box E000012575" → product="E000012575", unit="box"
                plu_match = re.search(r"\b([A-Z]\d{5,})\b", rest)
                if plu_match:
                    plu_token = plu_match.group(1)
                    before = rest[:plu_match.start()].strip()
                    after = rest[plu_match.end():].strip()
                    if before and re.match(r"^[a-zA-Z]{2,15}$", before) and not self._is_dose_pattern(before):
                        return plu_token, qty, before
                    if after and re.match(r"^[a-zA-Z]{2,15}$", after) and not self._is_dose_pattern(after):
                        return plu_token, qty, after
                    return plu_token, qty, None
                return rest, qty, None

        # 2) Angka polos di akhir: "carmed 10"
        parts = candidate.rsplit(None, 1)
        if len(parts) == 2:
            qty = self._parse_positive_int(parts[1])
            if qty is not None:
                return parts[0].strip(), qty, None

        # 3) Angka + satuan di akhir: "paracetamol 10 tab", "allercyl 2 botol"
        m = re.match(r"^(.+)\s+(\d+)\s+([a-zA-Z]{2,15})$", candidate)
        if m:
            qty = self._parse_positive_int(m.group(2))
            if qty is not None and not self._is_dose_pattern(m.group(3)):
                return m.group(1).strip(), qty, m.group(3).strip()

        return candidate, None, None

    def _is_dose_pattern(self, token: str) -> bool:
        """Cek apakah token terlihat seperti dosis (mg, ml, iu, dll.), bukan satuan kemasan."""
        return bool(re.match(
            r"^(mg|ml|gr|g|kg|mcg|iu|meq|mmol|cc|m[lg]|gram|liter)$",
            token,
            re.IGNORECASE,
        ))

    def _parse_positive_int(self, value: str) -> int | None:
        try:
            jumlah = int(float(value.replace(",", ".")))
            return jumlah if jumlah > 0 else None
        except ValueError:
            return None

    def _describe_packaging(self, item: dict[str, Any], satuan_diminta: str, product_unit: str) -> str:
        """Bangun deskripsi kemasan untuk ditampilkan di daftar defecta.
        Contoh: ' (3 strip @ 10 tab)' atau ' (isi 12 pcs)'
        """
        isi = item.get("product_isi_kemasan") or ""
        conv = item.get("product_unit_conversion")
        product_name = item.get("product_name") or ""

        if isi:
            return f" ({isi})"

        if conv and str(conv).replace(".", "").replace(",", "").isdigit():
            conv_num = float(str(conv).replace(",", "."))
            if conv_num > 1:
                return f" (1 {satuan_diminta} = {int(conv_num)} {product_unit})"

        # Fallback: ekstrak info strip dari product_name (contoh: "BOX 3 STR @ 10 TAB")
        m = re.search(r'(?:BOX|DUS)\s+(\d+\s*STR\s*@\s*\d+\s*\w+)', product_name, re.IGNORECASE)
        if m:
            return f" ({m.group(1).strip()})"
        # Polos: "BOX 30 TABLET"
        m = re.search(r'(?:BOX|DUS)\s+(\d+\s*\w+)', product_name, re.IGNORECASE)
        if m:
            return f" ({m.group(1).strip()})"

        return ""

    def _defecta_qty_token(self, chat_id: int) -> str:
        return f"defecta_qty:{chat_id}"

    def _defecta_satuan_token(self, chat_id: int) -> str:
        return f"defecta_satuan:{chat_id}"

    def _defecta_pick_token(self, chat_id: int) -> str:
        return f"defecta_pick:{chat_id}"

    def _defecta_lookup_qty_token(self, chat_id: int) -> str:
        return f"defecta_lookup_qty:{chat_id}"

    def _last_lookup_token(self, chat_id: int) -> str:
        return f"lookup_last:{chat_id}"

    def _product_mismatch_token(self, chat_id: int) -> str:
        return f"product_mismatch:{chat_id}"

    def _purchase_order_review_token(self, chat_id: int) -> str:
        return f"po_review:{chat_id}"

    def _consume_defecta_pick_flow(self, message: dict[str, Any], text: str, pending: dict[str, Any]) -> bool:
        chat_id = message["chat"]["id"]

        if text.strip().lower() in {"batal", "/batal", "cancel"}:
            self.pending_actions.delete(self._defecta_pick_token(chat_id))
            self.telegram.send_message(chat_id, "Pemilihan produk defecta dibatalkan.")
            return True

        selection = self._parse_defecta_selection(text)
        if not selection:
            self.telegram.send_message(
                chat_id,
                "Balas dengan format `nomor` atau `nomor jumlah satuan`, misalnya `3` atau `3 5 btl`.",
            )
            return True

        items = pending.get("items") or []
        index = selection["index"] - 1
        if index < 0 or index >= len(items):
            self.telegram.send_message(chat_id, "Nomor pilihan defecta tidak valid.")
            return True

        product_id = items[index].get("id")
        try:
            product_result = self.laravel.get_product(int(product_id), self._with_actor(message, {}))
            product = product_result.get("data") or {}
        except Exception as exc:
            self.telegram.send_message(chat_id, f"Gagal membuka produk defecta: {exc}")
            return True

        self.pending_actions.delete(self._defecta_pick_token(chat_id))

        jumlah = selection["qty"] or pending.get("jumlah")
        satuan = selection["unit"] or pending.get("satuan")
        if jumlah is None:
            self.pending_actions.put(
                self._defecta_qty_token(chat_id),
                {"type": "defecta_qty", "product": product, "satuan": satuan},
            )
            self.telegram.send_message(
                chat_id,
                f"Berapa jumlah defecta untuk {product.get('display_name') or product.get('name') or '-'}?",
            )
            return True

        self._create_defecta_for_product(message, product, int(jumlah), satuan)
        return True

    def _consume_purchase_order_review_flow(self, message: dict[str, Any], text: str, pending: dict[str, Any]) -> bool:
        chat_id = message["chat"]["id"]
        lowered = text.strip().lower()

        if lowered in {"batal", "/batal", "cancel"}:
            self.pending_actions.delete(self._purchase_order_review_token(chat_id))
            session_id = pending.get("session_id")
            if session_id:
                try:
                    self.laravel.cancel_purchase_order_session(int(session_id), self._with_actor(message, {}))
                except Exception:
                    self.logger.exception("Gagal membatalkan sesi SP saat membatalkan review")
            self.telegram.send_message(chat_id, "Review item SP dibatalkan.")
            return True

        parsed = self._parse_purchase_order_review_reply(text, pending.get("ambiguous_items") or [])
        if not parsed:
            self.telegram.send_message(
                chat_id,
                "Balas dengan format `nama-item nomor-opsi` atau `nomor-item nomor-opsi`, misalnya `asmef 2`.",
            )
            return True

        ambiguous_items = pending.get("ambiguous_items") or []
        review_index = parsed["review_index"] - 1
        option_index = parsed["option_index"] - 1
        if review_index < 0 or review_index >= len(ambiguous_items):
            self.telegram.send_message(chat_id, "Nomor item review tidak valid.")
            return True

        review_item = ambiguous_items[review_index]
        options = review_item.get("options") or []
        if option_index < 0 or option_index >= len(options):
            self.telegram.send_message(chat_id, "Nomor opsi produk tidak valid.")
            return True

        selected = options[option_index]
        try:
            add_result = self.laravel.add_purchase_order_session_item(
                int(pending["session_id"]),
                self._with_actor(
                    message,
                    {
                        "product_id": selected["id"],
                        "requested_quantity": review_item["requested_quantity"],
                        "requested_unit": review_item["requested_unit"],
                        "base_quantity": self._safe_base_quantity(review_item["requested_quantity"]),
                        "base_unit": selected.get("unit") or review_item["requested_unit"] or "PCS",
                        "conversion_qty": 1,
                        "original_text": review_item["source_name"],
                    },
                ),
            )
            added_items = pending.get("added_items") or []
            added_items.append(add_result.get("item") or {})
            pending["added_items"] = added_items
            self._maybe_save_product_alias(
                message,
                int(selected["id"]),
                review_item["source_name"],
                supplier_id=pending.get("supplier_id"),
            )
        except Exception as exc:
            rejected_items = pending.get("rejected_items") or []
            rejected_items.append(f"- {review_item['source_name']} ({exc})")
            pending["rejected_items"] = rejected_items

        remaining = [
            item for idx, item in enumerate(ambiguous_items)
            if idx != review_index
        ]
        pending["ambiguous_items"] = remaining

        if remaining:
            self.pending_actions.put(self._purchase_order_review_token(chat_id), pending)
            self.telegram.send_message(
                chat_id,
                self._format_purchase_order_review_prompt(
                    str(pending.get("supplier_name") or "-"),
                    str(pending.get("jenis_surat") or "-"),
                    pending.get("added_items") or [],
                    pending.get("pending_candidates") or [],
                    pending.get("rejected_items") or [],
                    remaining,
                ),
            )
            return True

        self.pending_actions.delete(self._purchase_order_review_token(chat_id))
        preview = self._format_sp_result(
            str(pending.get("supplier_name") or "-"),
            str(pending.get("jenis_surat") or "-"),
            pending.get("added_items") or [],
            pending.get("pending_candidates") or [],
            pending.get("rejected_items") or [],
            allow_finalize=bool(pending.get("added_items")),
        )
        keyboard = {
            "inline_keyboard": [
                [
                    {"text": "Simpan Draft", "callback_data": f"po_finalize:{pending['session_id']}:draft"},
                    {"text": "Kirim APJ", "callback_data": f"po_finalize:{pending['session_id']}:submit"},
                ],
                [
                    {"text": "Batal", "callback_data": f"po_cancel:{pending['session_id']}"},
                ],
            ]
        }
        self.telegram.send_message(chat_id, preview, reply_markup=keyboard)
        return True

    def _consume_defecta_lookup_qty_flow(self, message: dict[str, Any], text: str, pending: dict[str, Any]) -> bool:
        chat_id = message["chat"]["id"]
        lowered = text.strip().lower()

        if lowered in {"batal", "/batal", "cancel"}:
            self.pending_actions.delete(self._defecta_lookup_qty_token(chat_id))
            self.telegram.send_message(chat_id, "Input defecta dari hasil lookup dibatalkan.")
            return True

        jumlah = self._parse_positive_int(text)
        if jumlah is None:
            self.telegram.send_message(
                chat_id,
                "Jumlah defecta harus angka positif. Ketik angka saja, misalnya `5`, atau ketik `batal`.",
            )
            return True

        products = pending.get("products") or []
        current_index = int(pending.get("current_index") or 0)
        if current_index < 0 or current_index >= len(products):
            self.pending_actions.delete(self._defecta_lookup_qty_token(chat_id))
            self.telegram.send_message(chat_id, "Sesi defecta dari hasil lookup sudah tidak valid.")
            return True

        product = products[current_index]
        self._create_defecta_for_product(message, product, jumlah)

        next_index = current_index + 1
        if next_index >= len(products):
            self.pending_actions.delete(self._defecta_lookup_qty_token(chat_id))
            self.telegram.send_message(chat_id, "Semua item defecta dari hasil lookup sudah diproses.")
            return True

        pending["current_index"] = next_index
        self.pending_actions.put(self._defecta_lookup_qty_token(chat_id), pending)
        next_product = products[next_index]
        self.telegram.send_message(
            chat_id,
            f"Berapa jumlah defecta untuk {next_product.get('display_name') or next_product.get('name') or '-'}?",
        )
        return True

    def _parse_defecta_selection(self, text: str) -> dict[str, int | str | None] | None:
        match = re.match(
            r"^\s*(?P<index>\d+)(?:\s+(?P<qty>\d+))?(?:\s+(?P<unit>[a-zA-Z]+))?\s*$",
            text,
            re.IGNORECASE,
        )
        if not match:
            return None

        return {
            "index": int(match.group("index")),
            "qty": int(match.group("qty")) if match.group("qty") else None,
            "unit": (match.group("unit") or "").upper() or None,
        }

    def _parse_purchase_order_review_reply(
        self,
        text: str,
        ambiguous_items: list[dict[str, Any]],
    ) -> dict[str, int] | None:
        numeric_match = re.match(r"^\s*(?P<review>\d+)\s+(?P<option>\d+)\s*$", text)
        if numeric_match:
            return {
                "review_index": int(numeric_match.group("review")),
                "option_index": int(numeric_match.group("option")),
            }

        named_match = re.match(r"^\s*(?P<name>.+?)\s+(?P<option>\d+)\s*$", text)
        if not named_match:
            return None

        reference = self._normalize_reference(named_match.group("name"))
        for idx, item in enumerate(ambiguous_items, start=1):
            if self._normalize_reference(item.get("source_name")) == reference:
                return {
                    "review_index": idx,
                    "option_index": int(named_match.group("option")),
                }

        return None

    def _parse_lookup_multi_selection(self, raw: str) -> list[int] | None:
        normalized = " ".join(raw.strip().lower().split())
        normalized = re.sub(r"^(?:add\s+)?", "", normalized)
        if not normalized:
            return None

        if not re.fullmatch(r"\d+(?:\s*(?:,|dan|&)\s*\d+)+|\d+", normalized):
            return None

        tokens = [part for part in re.split(r"\s*(?:,|dan|&)\s*", normalized) if part]
        indices: list[int] = []
        for token in tokens:
            value = self._parse_positive_int(token)
            if value is None:
                return None
            indices.append(value)

        return indices or None

    def _parse_lookup_navigation(self, raw: str) -> dict[str, int | str | None] | None:
        normalized = " ".join(raw.strip().lower().split())
        if normalized in {"lagi", "next", "selanjutnya", "lanjut"}:
            return {"type": "next", "page": None}
        if normalized in {"semua", "all"}:
            return {"type": "all", "page": None}

        match = re.match(r"^(?:halaman|page)\s+(\d+)$", normalized)
        if not match:
            return None

        return {"type": "page", "page": int(match.group(1))}

    def _store_last_lookup(
        self,
        chat_id: int,
        kind: str,
        query: str,
        items: list[dict[str, Any]],
        *,
        all_items: list[dict[str, Any]] | None = None,
        total_count: int | None = None,
        page: int = 1,
        page_size: int = _LOOKUP_PAGE_SIZE,
        mode: str | None = None,
        show_all: bool = False,
    ) -> None:
        self.pending_actions.put(
            self._last_lookup_token(chat_id),
            {
                "type": "lookup_last",
                "kind": kind,
                "query": query,
                "items": items,
                "all_items": all_items or items,
                "total_count": total_count if total_count is not None else len(all_items or items),
                "page": page,
                "page_size": page_size,
                "mode": mode,
                "show_all": show_all,
            },
            ttl_minutes=180,
        )

    def _send_lookup_page(self, chat_id: int) -> None:
        lookup = self.pending_actions.get(self._last_lookup_token(chat_id))
        if not lookup or (lookup.get("type") or "") != "lookup_last":
            self.telegram.send_message(chat_id, "Daftar lookup terakhir tidak ditemukan.")
            return

        lines, parse_mode = self._build_lookup_lines(lookup)
        self.telegram.send_message(chat_id, "\n".join(lines).strip(), parse_mode=parse_mode)

    def _build_lookup_lines(self, lookup: dict[str, Any]) -> tuple[list[str], str | None]:
        kind = str(lookup.get("kind") or "")
        query = str(lookup.get("query") or "")
        items = lookup.get("items") or []
        all_items = lookup.get("all_items") or items
        total_count = int(lookup.get("total_count") or len(all_items))
        page = max(1, int(lookup.get("page") or 1))
        page_size = max(1, int(lookup.get("page_size") or _LOOKUP_PAGE_SIZE))
        show_all = bool(lookup.get("show_all"))

        if kind == "gpos":
            parse_mode = "HTML"
            lines = [f"Info produk untuk: {query}", ""]
            for idx, item in enumerate(items, start=1):
                stock_value = item.get("total_stock") or item.get("stock") or "-"
                lines.extend([
                    f"{idx}. <b>{html.escape(str(item.get('name') or '-'))}</b>",
                    f"PLU: <code>{html.escape(str(item.get('plu') or '-'))}</code>",
                ])
                if item.get("barcode"):
                    lines.append(f"Barcode: {html.escape(str(item.get('barcode')))}")
                if item.get("category_name"):
                    lines.append(f"Kategori: {html.escape(str(item.get('category_name')))}")
                lines.append(f"Stok: {html.escape(str(stock_value))} | Harga: {self._format_rupiah(item.get('sales_price'))}")
                if item.get("rak"):
                    lines.append(f"Rak: {html.escape(str(item.get('rak')))}")
                lines.append("")
        else:
            parse_mode = "HTML"
            lines = [f"Hasil pencarian untuk: {html.escape(query)}", ""]
            for idx, item in enumerate(items, start=1):
                unit = str(item.get("unit", "-"))
                lines.extend([
                    f"{idx}. <b>{html.escape(str(item.get('display_name') or item.get('name', '-')))}</b>",
                    f"PLU: <code>{html.escape(str(item.get('plu', '-')))}</code>",
                    f"Satuan dasar: {html.escape(unit)}",
                    f"Klasifikasi: {html.escape(str(item.get('classification', '-')))}",
                    f"Barcode utama: {html.escape(str(item.get('barcode') or '-'))}",
                    "",
                ])

        current_count = len(items)
        if current_count:
            if show_all:
                lines.append(f"Menampilkan semua hasil yang sedang tersedia di bot: {current_count} item.")
            else:
                start_number = ((page - 1) * page_size) + 1
                end_number = start_number + current_count - 1
                cached = len(all_items)
                if total_count > cached:
                    lines.append(
                        f"Menampilkan {start_number}-{end_number} "
                        f"(cache bot: {cached} dari {total_count} di GPOS)."
                    )
                else:
                    lines.append(f"Menampilkan {start_number}-{end_number} dari {total_count} hasil.")

        total_pages = max(1, (len(all_items) + page_size - 1) // page_size)
        if not show_all and len(all_items) > page_size:
            lines.extend([
                "",
                f"Halaman {page}/{total_pages}. Balas `lagi`, `halaman {min(page + 1, total_pages)}`, atau `semua`.",
            ])
        elif not show_all and total_count > len(all_items):
            lines.extend([
                "",
                "Masih ada hasil lain di GPOS. Persempit query untuk hasil lebih tepat.",
            ])

        return lines, parse_mode

    def _start_defecta_from_lookup(self, message: dict[str, Any], indexes: list[int]) -> None:
        chat_id = message["chat"]["id"]
        lookup = self.pending_actions.get(self._last_lookup_token(chat_id))
        if not lookup or (lookup.get("type") or "") != "lookup_last":
            self.telegram.send_message(
                chat_id,
                "Saya belum punya daftar lookup terakhir untuk dipakai. Mulai dulu dengan `stok ...`, `harga ...`, atau `produk ...`.",
            )
            return

        items = lookup.get("items") or []
        invalid_indexes = [idx for idx in indexes if idx < 1 or idx > len(items)]
        if invalid_indexes:
            invalid_text = ", ".join(str(idx) for idx in invalid_indexes)
            self.telegram.send_message(chat_id, f"Nomor pilihan tidak valid: {invalid_text}.")
            return

        selected_items = [items[idx - 1] for idx in indexes]
        resolved_products: list[dict[str, Any]] = []
        unavailable_lines: list[str] = []

        for idx, item in zip(indexes, selected_items, strict=False):
            product = self._resolve_lookup_product_for_defecta(message, item, str(lookup.get("kind") or ""))
            if product:
                resolved_products.append(product)
                continue

            unavailable_lines.append(
                f"- Opsi {idx}: {item.get('display_name') or item.get('name') or '-'} belum punya master produk DEWA."
            )

        if not resolved_products:
            self._store_product_mismatch(chat_id, selected_items, source="lookup_last")
            lines = ["Item yang dipilih belum bisa dicatat sebagai defecta karena item ini ada di GPOS tetapi belum punya master produk DEWA."]
            if unavailable_lines:
                lines.extend(["", *unavailable_lines])
            lines.extend([
                "",
                "Langkah lanjut: sync produk lewat Admin → GPOS Sync → Sync Produk, lalu ulangi perintah defecta.",
                "Ketik `belum terdaftar` untuk penjelasan lebih lanjut.",
            ])
            self.telegram.send_message(chat_id, "\n".join(lines))
            return

        self.pending_actions.put(
            self._defecta_lookup_qty_token(chat_id),
            {
                "type": "defecta_lookup_qty",
                "products": resolved_products,
                "current_index": 0,
            },
        )

        lines = ["Baik, saya lanjutkan defecta dari hasil lookup terakhir."]
        if unavailable_lines:
            lines.extend(["", "Item yang dilewati:", *unavailable_lines])
        lines.extend([
            "",
            f"Berapa jumlah defecta untuk {resolved_products[0].get('display_name') or resolved_products[0].get('name') or '-'}?",
        ])
        self.telegram.send_message(chat_id, "\n".join(lines))

    def _start_defecta_from_lookup_selection(
        self,
        message: dict[str, Any],
        selection: dict[str, int | str | None],
    ) -> None:
        chat_id = message["chat"]["id"]
        lookup = self.pending_actions.get(self._last_lookup_token(chat_id))
        if not lookup or (lookup.get("type") or "") != "lookup_last":
            self.telegram.send_message(
                chat_id,
                "Saya belum punya daftar lookup terakhir untuk dipakai. Mulai dulu dengan `stok ...`, `harga ...`, atau `produk ...`.",
            )
            return

        items = lookup.get("items") or []
        index = int(selection["index"]) - 1
        if index < 0 or index >= len(items):
            self.telegram.send_message(chat_id, "Nomor pilihan tidak valid.")
            return

        item = items[index]
        product = self._resolve_lookup_product_for_defecta(message, item, str(lookup.get("kind") or ""))
        if product is None:
            self._store_product_mismatch(chat_id, [item], source="lookup_last")
            self.telegram.send_message(
                chat_id,
                "Item yang dipilih belum bisa dicatat sebagai defecta karena item ini ada di GPOS tetapi belum punya master produk DEWA.",
            )
            return

        jumlah = selection.get("qty")
        satuan = selection.get("unit")
        if jumlah is None:
            self.pending_actions.put(
                self._defecta_qty_token(chat_id),
                {
                    "type": "defecta_qty",
                    "product": product,
                    "satuan": satuan,
                },
            )
            self.telegram.send_message(
                chat_id,
                f"Berapa jumlah defecta untuk {product.get('display_name') or product.get('name') or '-'}?",
            )
            return

        self._create_defecta_for_product(message, product, int(jumlah), satuan)

    def _resolve_lookup_product_for_defecta(
        self,
        message: dict[str, Any],
        item: dict[str, Any],
        lookup_kind: str,
    ) -> dict[str, Any] | None:
        if lookup_kind == "product" and item.get("id"):
            try:
                result = self.laravel.get_product(int(item["id"]), self._with_actor(message, {}))
                return result.get("data") or None
            except Exception:
                self.logger.exception("Gagal membuka produk DEWA dari hasil lookup produk")
                return None

        query_candidates = [
            str(item.get("plu") or "").strip(),
            str(item.get("barcode") or "").strip(),
            str(item.get("display_name") or item.get("name") or "").strip(),
        ]
        query = next((candidate for candidate in query_candidates if candidate), "")
        if not query:
            return None

        try:
            result = self.laravel.search_products(self._with_actor(message, {"query": query, "limit": 10}))
        except Exception:
            self.logger.exception("Gagal mencari produk DEWA dari hasil lookup GPOS")
            return None

        matches = result.get("data") or []
        if not matches:
            return None

        target_plu = str(item.get("plu") or "").strip()
        target_barcode = str(item.get("barcode") or "").strip()
        target_name = self._normalize_reference(item.get("display_name") or item.get("name"))

        for product in matches:
            if target_plu and str(product.get("plu") or "").strip() == target_plu:
                return product
            if target_barcode and str(product.get("barcode") or "").strip() == target_barcode:
                return product
            if self._normalize_reference(product.get("display_name") or product.get("name")) == target_name:
                return product

        return matches[0] if len(matches) == 1 else None

    def _store_product_mismatch(self, chat_id: int, items: list[dict[str, Any]], source: str) -> None:
        self.pending_actions.put(
            self._product_mismatch_token(chat_id),
            {
                "type": "product_mismatch",
                "source": source,
                "items": items,
            },
            ttl_minutes=180,
        )

    def _explain_product_mismatch(self, chat_id: int, mismatch: dict[str, Any]) -> None:
        items = mismatch.get("items") or []
        lines = [
            '"Belum terdaftar di DEWA" di konteks ini bukan soal BPOM atau izin edar.',
            "Maksudnya: item tersebut ada di hasil GPOS, tetapi belum ada atau belum ketemu padanan master produknya di sistem DEWA.",
            "",
            "Dampaknya:",
            "- bot belum bisa mencatat defecta ke master produk DEWA",
            "- bot belum bisa memakai item itu secara aman untuk flow operasional yang bergantung pada product_id DEWA",
            "",
            "Langkah lanjut yang benar:",
            "- sinkronkan/masterkan produk dulu ke DEWA",
            "- atau cocokkan item GPOS ke produk DEWA yang paling mirip",
            "- lalu ulangi perintah defecta/SP setelah mapping benar",
        ]

        if items:
            lines.extend(["", "Item terkait:"])
            for item in items[:5]:
                lines.append(f"- {item.get('display_name') or item.get('name') or '-'}")

        self.telegram.send_message(chat_id, "\n".join(lines))

    def _format_purchase_order_review_prompt(
        self,
        supplier_name: str,
        jenis_surat: str,
        items: list[dict[str, Any]],
        pending_candidates: list[dict[str, Any]],
        rejected_items: list[str],
        ambiguous_items: list[dict[str, Any]],
    ) -> str:
        lines = [
            self._format_sp_result(supplier_name, jenis_surat, items, pending_candidates, rejected_items, allow_finalize=False),
            "",
            f"⚠️ {len(ambiguous_items)} produk perlu dipilih varian — tap tombol di bawah:",
            "",
        ]

        for idx, review_item in enumerate(ambiguous_items, start=1):
            lines.append(
                f"{idx}. {review_item.get('source_name')} — "
                f"{self._safe_base_quantity(float(review_item.get('requested_quantity') or 0))} "
                f"{review_item.get('requested_unit') or 'PCS'}"
            )
            for option_index, option in enumerate(review_item.get("options") or [], start=1):
                lines.append(
                    f"   {option_index}. {option.get('display_name') or option.get('name') or '-'} "
                    f"(PLU: {option.get('plu') or '-'})"
                )
            lines.append("")

        return "\n".join(lines).strip()

    def _maybe_save_product_alias(
        self,
        message: dict[str, Any],
        product_id: int,
        alias_name: str,
        supplier_id: int | None = None,
    ) -> None:
        alias_name = alias_name.strip()
        if len(alias_name) < 3:
            return

        try:
            self.laravel.save_product_alias(
                self._with_actor(
                    message,
                    {
                        "product_id": product_id,
                        "alias_name": alias_name,
                        "supplier_id": supplier_id,
                        "is_global": False,
                        "notes": "Alias dipelajari dari konfirmasi user di draft SP Telegram.",
                    },
                )
            )
        except Exception:
            self.logger.exception("Gagal menyimpan alias produk dari flow SP")

    def _normalize_reference(self, value: Any) -> str:
        return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())

    def _is_rate_limited(self, chat_id: int) -> bool:
        """Sliding-window rate limiter per chat_id via SQLite (shared antar workers)."""
        limited = self._rate_limiter.is_rate_limited(chat_id)
        if limited:
            self.logger.warning("Rate limit exceeded for chat_id=%s", chat_id)
        return limited

    def _is_chat_allowed(self, chat_id: int) -> bool:
        allowed = self.config.telegram_allowed_chat_ids
        if not allowed:
            return True

        return chat_id in allowed

    def _start_message(self) -> str:
        return (
            f"{self.config.bot_name} aktif.\n\n"
            "Kirim /help untuk melihat command.\n"
            "Kirim screenshot bukti transfer untuk disimpan.\n"
            "Untuk OCR faktur, kirim foto dengan caption /faktur.\n"
            "Saya juga bisa bantu defecta dan cari histori beli/jual produk."
        )

    def _help_message(self) -> str:
        return (
            "Command yang tersedia:\n"
            "/start - cek bot aktif\n"
            "/help - lihat bantuan\n"
            "/stok <nama/PLU> - cek stok dari GPOS\n"
            "/harga <nama/PLU> - cek harga jual dari GPOS\n"
            "/produk <nama> - cari produk\n"
            "/defecta - daftar defecta menunggu\n"
            "/defecta ordered - daftar defecta sudah dipesan\n"
            "/defecta add <produk> [jumlah] - tambah defecta\n"
            "/opname aktif - lihat sesi stock opname aktif\n"
            "/opname buka <nama sesi> - buka sesi stock opname\n"
            "/opname pending <session_id> - lihat item pending\n"
            "/opname hitung <session_id> | <nama/PLU> | <stok fisik> | [catatan]\n"
            "/reset - reset riwayat percakapan AI\n\n"
            "Query cepat:\n"
            "defecta cendo xitrol\n"
            "kapan terakhir beli paracetamol\n"
            "pembelian terakhir polysilane\n"
            "kapan terakhir jual etawalin\n"
            "penjualan terakhir polysilane\n"
            "5 pembelian terakhir mefinal\n"
            "faktur pembelian 833095596\n\n"
            "Setelah hasil lookup tampil, balas `lagi`, `halaman 2`, atau `semua` untuk melihat hasil lain.\n\n"
            "Format draft SP:\n"
            "sp pk APL\n"
            "Actifed Kuning 60 ml 2 botol\n"
            "Panadol Cold and Flu 1 box\n\n"
            "Foto default diperlakukan sebagai bukti transfer.\n"
            "Gunakan caption /faktur jika yang dikirim adalah faktur pembelian.\n\n"
            "Teks bebas akan dijawab oleh AI asisten apotek."
        )

    def _format_invoice_preview(self, analysis: dict[str, Any]) -> str:
        items = analysis.get("items") or []
        lines = [
            "Preview hasil ekstraksi faktur:",
            f"Supplier: {analysis.get('supplier_name') or '-'}",
            f"Nomor faktur: {analysis.get('invoice_number') or '-'}",
            f"Tanggal: {analysis.get('invoice_date') or '-'}",
            f"Grand total: {analysis.get('grand_total') or 0}",
            f"Confidence: {analysis.get('confidence') or 0}",
            "",
            f"Jumlah item terbaca: {len(items)}",
        ]

        for item in items[:5]:
            lines.append(
                f"- {item.get('name') or '-'} | qty: {item.get('quantity') or 0} | "
                f"harga: {item.get('purchase_price') or 0}"
            )

        if len(items) > 5:
            lines.append(f"... dan {len(items) - 5} item lainnya")

        lines.append("")
        lines.append("Tekan Konfirmasi Simpan jika hasilnya sudah masuk akal.")
        return "\n".join(lines)

    def _format_payment_evidence_preview(self, analysis: dict[str, Any]) -> str:
        lines = [
            "Preview hasil ekstraksi bukti pembayaran:",
            f"Klasifikasi: {analysis.get('classification') or '-'}",
            f"Metode: {analysis.get('payment_method') or '-'}",
            f"Nominal: {self._format_rupiah(analysis.get('amount'))}",
            f"Tanggal: {analysis.get('transfer_date') or '-'}",
            f"Waktu: {analysis.get('transfer_time') or '-'}",
            f"Penerima: {analysis.get('recipient_name') or '-'}",
            f"Bank: {analysis.get('bank_name') or '-'}",
            f"Rekening: {analysis.get('account_number') or '-'}",
            f"Ref: {analysis.get('reference_id') or '-'}",
            f"Confidence: {analysis.get('confidence') or 0}",
        ]

        if analysis.get("notes"):
            lines.append(f"Catatan: {analysis.get('notes')}")

        lines.append("")
        lines.append("Tekan Konfirmasi Simpan jika hasilnya sudah masuk akal.")
        return "\n".join(lines)

    def _format_rupiah(self, value: Any) -> str:
        amount = int(float(value or 0))
        return f"Rp {amount:,.0f}".replace(",", ".")

    def _extract_stock_number(self, value: Any) -> float:
        match = re.search(r"(\d+(?:[.,]\d+)?)", str(value or ""))
        if not match:
            return 0.0
        return float(match.group(1).replace(",", "."))

    def _actor_id(self, message: dict[str, Any]) -> str:
        return str((message.get("from") or {}).get("id", ""))

    def _with_actor(self, message: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
        user = message.get("from") or {}
        return self._with_actor_from_user(user, payload)

    def _with_actor_from_user(self, user: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
        username = user.get("username")
        display_name = " ".join(
            part for part in [user.get("first_name"), user.get("last_name")] if part
        ).strip()

        return {
            **payload,
            "telegram_user_id": str(user.get("id", "")),
            "telegram_username": username,
            "telegram_display_name": display_name or username or "Telegram User",
        }
