﻿
from datetime import datetime
from typing import Optional, List, Dict
import logging
from pypos.core.base_controller import BaseController
from pypos.modules.printer.models.printer_settings_model import PrinterSettingsModel
from pypos.modules.printer.services.printer_diagnostic_service import PrinterDiagnosticService
from pypos.modules.printer.services.printer_paper_service import PrinterPaperService
from pypos.modules.printer.services.printer_settings_ui_state_service import (
    PrinterSettingsUiStateService,
)
from pypos.modules.printer.services.printer_settings_value_service import (
    PrinterSettingsValueService,
)
from pypos.modules.printer.services.printer_raw_painter_render_service import (
    PrinterRawPainterRenderService,
)
from pypos.modules.printer.config.printer_config import DEFAULT_PAPER_SIZE, THERMAL_DPI
from PySide6.QtPrintSupport import QPrinter, QPrintPreviewDialog
from PySide6.QtGui import (
    QTextDocument, QPainter, QFont
)
from PySide6.QtCore import QSizeF

import base64
from io import BytesIO
import os
LOGGER = logging.getLogger(__name__)

try:
    import qrcode
    QRCODE_AVAILABLE = True
except ImportError:
    QRCODE_AVAILABLE = False
    LOGGER.warning("qrcode library not installed. QR codes will not be generated.")

try:
    from escpos.printer import Win32Raw, Network, Usb
    ESCPOS_AVAILABLE = True
except ImportError:
    ESCPOS_AVAILABLE = False
    LOGGER.warning("Vendor escpos tidak tersedia. ESC/POS printing tidak tersedia.")

from PySide6.QtPrintSupport import QPrinterInfo
from pypos.core.utils.path_utils import get_app_data_resource_dir
from pypos.core.utils.device_utils import get_active_device_info, get_device_id



class PrinterSettingsController(BaseController):
    def _mm_to_points(self, mm: float) -> float:
        return self.paper_service.mm_to_points(mm)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.model = PrinterSettingsModel()
        self.paper_service = PrinterPaperService()
        self.printer_settings_value_service = PrinterSettingsValueService()
        self.printer_settings_ui_state_service = PrinterSettingsUiStateService()

        # ------------------ Print Mode (Preview/Auto) ------------------
    # edited by glg
    def _get_printer_settings_value_service(self) -> PrinterSettingsValueService:
        service = getattr(self, "printer_settings_value_service", None)
        if service is None:
            service = PrinterSettingsValueService()
            self.printer_settings_value_service = service
        return service

    # edited by glg
    def _get_printer_settings_ui_state_service(self) -> PrinterSettingsUiStateService:
        service = getattr(self, "printer_settings_ui_state_service", None)
        if service is None:
            service = PrinterSettingsUiStateService()
            self.printer_settings_ui_state_service = service
        return service

    # ------------------ Print Mode (Preview/Auto) ------------------
    # edited by glg
    def _to_non_negative_int(self, value, default=0):
        return self._get_printer_settings_value_service().to_non_negative_int(value, default)

    # edited by glg
    def _extract_point_from_diskon_log(self, diskon_log: str) -> int:
        return self._get_printer_settings_value_service().extract_point_from_diskon_log(diskon_log)

    # edited by glg
    def _resolve_point_transaksi_value(self, transaksi_data_dict: Dict) -> int:
        return self._get_printer_settings_value_service().resolve_point_transaksi_value(transaksi_data_dict)

    # edited by glg
    def _normalize_ppn_mode(self, value: str, default: str = "exclude") -> str:
        return self._get_printer_settings_value_service().normalize_ppn_mode(value, default)

    # edited by glg
    def _extract_ppn_mode_from_diskon_log(self, diskon_log: str) -> str:
        return self._get_printer_settings_value_service().extract_ppn_mode_from_diskon_log(diskon_log)

    # edited by glg
    def _resolve_ppn_mode(self, transaksi_data_dict: Dict) -> str:
        return self._get_printer_settings_value_service().resolve_ppn_mode(transaksi_data_dict)

    def get_print_mode(self) -> str:
        return self.model.get_print_mode()

    def set_print_mode(self, mode: str) -> None:
        self.model.set_print_mode(mode)

    def get_history_retention_days(self) -> int:
        return self.model.get_history_retention_days()

    def set_history_retention_days(self, days: int) -> None:
        self.model.set_history_retention_days(days)

    # edited by glg
    def get_settlement_print_option(self, option_key: str, default: bool = False) -> bool:
        if hasattr(self.model, "get_settlement_print_option"):
            try:
                return bool(self.model.get_settlement_print_option(option_key, default=default))
            except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError):
                return bool(default)
        return bool(default)

    # edited by glg
    def set_settlement_print_option(self, option_key: str, enabled: bool) -> None:
        if hasattr(self.model, "set_settlement_print_option"):
            self.model.set_settlement_print_option(option_key, bool(enabled))
            self.log_info(
                self._get_printer_settings_ui_state_service().build_settlement_option_log(
                    option_key=option_key,
                    enabled=enabled,
                )
            )

    def get_receipt_setting(self, cabang_id=None) -> Dict:
        if hasattr(self.model, "get_receipt_setting"):
            return self.model.get_receipt_setting(cabang_id)
        return {}

    def build_receipt_qr_data(self, transaksi_data_dict: Dict = None, wifi_code: str = "") -> str:
        if hasattr(self.model, "build_receipt_qr_data"):
            return self.model.build_receipt_qr_data(transaksi_data_dict=transaksi_data_dict, wifi_code=wifi_code)
        return ""

    def _get_demo_print_context(self) -> Dict:
        if hasattr(self.model, "get_demo_print_context"):
            return self.model.get_demo_print_context(qr_builder=self.build_receipt_qr_data)
        return {"items": [], "payment": {}, "wifi_code": "", "qr_data": "", "transaksi_data_dict": {}}

    def _load_setting_struk_by_cabang(self, cabang_id=None) -> Dict:
        db_setting = {}
        if hasattr(self.model, "get_setting_struk"):
            db_setting = self.model.get_setting_struk(cabang_id)
        if db_setting:
            return db_setting

        if cabang_id is None:
            try:
                device_info = get_active_device_info(get_device_id()) or {}
                cabang_id = device_info.get("cabang_id")
            except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError):
                cabang_id = None
        if hasattr(self.model, "load_setting_struk_csv"):
            return self.model.load_setting_struk_csv(cabang_id)
        return {}

    def _build_setting_from_company_profile(self, profile: Dict) -> Dict:
        if hasattr(self.model, "build_setting_from_company_profile"):
            return self.model.build_setting_from_company_profile(profile)
        return {}

    def _resolve_company_logo_path(self, profile: Dict) -> str:
        if hasattr(self.model, "resolve_company_logo_path"):
            return self.model.resolve_company_logo_path(profile)
        return ""

    def _get_company_logo_cache_path(self):
        if hasattr(self.model, "_get_company_logo_cache_path"):
            return self.model._get_company_logo_cache_path()
        return None

    def refresh_company_logo_assets(self, force_refresh=True) -> bool:
        if hasattr(self.model, "refresh_company_logo_assets"):
            return bool(self.model.refresh_company_logo_assets(force_refresh=force_refresh))
        return False

    def get_printers(self) -> List[Dict]:
        return self.model.load_printers()

    def add_printer(self, name: str, paper_size: str, is_default: bool = False) -> None:
        printers = self.model.load_printers()
        if is_default:
            for p in printers:
                p["default"] = False
        printers.append(
            self._get_printer_settings_ui_state_service().normalize_printer_entry(
                name=name,
                paper_size=paper_size,
                is_default=is_default,
            )
        )
        self.model.save_printers(printers)

    def remove_printer(self, index: int) -> None:
        self.model.remove_printer(index)

    def set_default(self, index: int) -> None:
        self.model.set_default(index)

    def run_printer_diagnostic(self, timeout_seconds: float = 2.0, output_path: str = "") -> Dict:
        service = PrinterDiagnosticService(timeout_seconds=timeout_seconds)
        report = service.run()
        report_path = service.save_report(output_path=output_path or None)
        return {
            "report": report,
            "report_path": report_path,
        }

    def _render_doc_to_printer(self, printer: QPrinter, doc: QTextDocument) -> bool:
        """
        Render QTextDocument ke printer thermal dengan DPI scaling fix.
        PATCH[ThermalFix]: NO SCALING jika DPI > 300 (high DPI thermal printer)
        """
        full_width = doc.textWidth()

        self.log_debug("[DEBUG RENDER]")
        self.log_debug(f"Doc textWidth: {full_width:.2f} pt")
        self.log_debug(f"Printer DPI: {printer.logicalDpiX()}")

        painter = QPainter()
        if not painter.begin(printer):
            self.log_error("Cannot start painter")
            return False

        printer_dpi = printer.logicalDpiX()
        if printer_dpi < 300:
            # Only scale if low DPI printer
            thermal_dpi = 203
            scale_factor = printer_dpi / thermal_dpi
            painter.scale(scale_factor, scale_factor)
            self.log_debug(f"DPI scale applied: {scale_factor:.3f}")
        else:
            self.log_debug(f"High DPI printer ({printer_dpi}) - no scaling")

        doc.drawContents(painter)
        painter.end()

        self.log_debug("Render complete")
        return True

    # ------------------ PREVIEW ------------------
    def preview_print(self, index: int, parent=None) -> bool:
        printers = self.model.load_printers()
        if not self._get_printer_settings_ui_state_service().is_valid_printer_index(index, len(printers)):
            return False

        cfg = printers[index]
        paper_label = cfg.get("paper_size", DEFAULT_PAPER_SIZE)
        w_mm = self.paper_service.get_paper_mm(paper_label)

        preview_printer = self._make_preview_printer(w_mm)

        demo_ctx = self._get_demo_print_context()
        items = demo_ctx["items"]
        payment = demo_ctx["payment"]
        wifi_code = demo_ctx["wifi_code"]
        qr_data = demo_ctx["qr_data"]

        cols = self._estimate_cols(preview_printer, css_font_pt=10.0, family="DejaVu Sans Mono", paper_label=paper_label)
        setting = self.get_receipt_setting()

        transaksi_data_dict = demo_ctx["transaksi_data_dict"]

        w_pt = self._mm_to_points(w_mm)
        # edited by glg
        # Samakan engine preview di menu Settings dengan preview struk pembayaran:
        # gunakan raw painter pipeline agar alignment/spacing konsisten.
        render_func = self._create_doc_raw_painter(
            items,
            payment,
            wifi_code,
            qr_data,
            cols,
            setting,
            transaksi_data_dict,
            w_pt,
            preview_printer,
        )

        preview = QPrintPreviewDialog(preview_printer, parent)
        preview.setWindowTitle("Preview Struk")
        try:
            preview.setZoomMode(QPrintPreviewDialog.FitToWidth)
        except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError):
            pass

        # edited by glg
        # Callback identik dengan PrintView.show_preview_raw.
        def paint_preview(preview_target):
            painter = QPainter()
            if painter.begin(preview_target):
                render_func(painter)
                painter.end()

        preview.paintRequested.connect(paint_preview)
        preview.exec()
        return True

    def _get_default_printer_name(self):
        """
        Ambil default printer dari aplikasi (printers.json) dulu.
        Jika tidak ditemukan atau tidak tersedia di sistem, fallback ke default printer Windows.
        """
        from PySide6.QtPrintSupport import QPrinterInfo

        try:
            app_default = ""
            if hasattr(self.model, "get_app_default_printer_name"):
                app_default = self.model.get_app_default_printer_name()
            if app_default:
                available = [x.printerName() for x in QPrinterInfo.availablePrinters()]
                if app_default in available:
                    self.log_info(f"Default printer dari aplikasi: {app_default}")
                    return app_default
                self.log_warning(f"Default printer di JSON tidak ditemukan di sistem: {app_default}")

            # fallback ke default printer sistem
            default_info = QPrinterInfo.defaultPrinter()
            if default_info and not default_info.isNull():
                sys_default = default_info.printerName()
                self.log_info(f"Default printer dari sistem: {sys_default}")
                return sys_default
        except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e:
            self.log_warning(f"Gagal ambil default printer: {e}")

    def test_printer_width(self, printer_name: str):
        """
        Test actual character width printer untuk deteksi CPL real.
        Print test pattern untuk menemukan batas terpotong.
        """
        if not ESCPOS_AVAILABLE:
            self.log_error("Dependency `escpos` tidak tersedia.")
            return False

        try:
            printer = Win32Raw(printer_name)

            # Test dengan font 'b' (9x17 dots)
            printer.set(align='left', font='b', width=1, height=1)
            printer.text("=== TEST CPL FONT B (9x17) ===\n")

            for n in range(25, 50):
                line = f"{n:02d}:" + "X" * n
                printer.text(line + "\n")

            printer.text("\n")

            # Test dengan font 'a' (12x24 dots)
            printer.set(align='left', font='a', width=1, height=1)
            printer.text("=== TEST CPL FONT A (12x24) ===\n")

            for n in range(20, 40):
                line = f"{n:02d}:" + "X" * n
                printer.text(line + "\n")

            printer.text("\n\n\n")
            printer.cut()

            self.log_info("Test width printed. Cek hasil fisik, cari baris terakhir yang tidak terpotong.")
            self.log_info("Angka di baris tersebut = CPL (Characters Per Line) actual printer Anda.")
            return True

        except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e:
            self.log_error(f"Test width error: {e}")
            import traceback
            traceback.print_exc()
            return False

    def print_escpos_raw(self, items: List[Dict], payment: Dict, wifi_code: str, qr_data: str, setting: Dict, transaksi_data_dict: Dict, printer_name: str):
        """
        Print menggunakan ESC/POS RAW - bypass Windows GDI driver sepenuhnya.
        Solusi untuk masalah text terpotong di thermal printer.

        MENGGUNAKAN ESC/POS ALIGNMENT COMMAND (bukan ljust/rjust) untuk menghindari truncation.
        ESC a 0 = left align
        ESC a 1 = center align
        ESC a 2 = right align
        """
        if not ESCPOS_AVAILABLE:
            self.log_error("Dependency `escpos` tidak tersedia.")
            return False

        try:
            # Connect ke printer menggunakan Win32Raw
            printer = Win32Raw(printer_name)

            # Set printer profile untuk thermal 58mm
            # Ini penting untuk image centering dan capabilities
            printer.profile.media['width']['pixels'] = 384  # 58mm = 384 dots
            printer.profile.media['width']['mm'] = 58.0

            # Conservative width untuk thermal 58mm (compatible dengan 320-384 dots)
            PAPER_WIDTH = 32  # Safe untuk semua thermal 58mm

            # Set font 'a' (lebih reliable untuk alignment)
            printer.set(align='center', font='a', width=1, height=1)

            # === LOGO ===
            logo_path = setting.get("logo", "")
            if logo_path:
                full_logo_path = os.path.join(get_app_data_resource_dir(), "logo", logo_path)
                if os.path.exists(full_logo_path):
                    try:
                        printer.image(full_logo_path, center=True)
                        printer.text("\n")
                    except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e:
                        self.log_warning(f"Logo image error: {e}")

            # === HEADER ===
            printer.set(align='center', font='a', bold=True)
            self.log_debug("[DEBUG ESC/POS] Printing header...")
            for i in range(1, 4):
                header_text = setting.get(f"header{i}", "")
                if header_text:
                    printer.text(header_text + "\n")
                    self.log_debug(f"[DEBUG ESC/POS] Header{i}: {header_text}")

            printer.set(bold=False)
            printer.text("=" * PAPER_WIDTH + "\n")

            # === INVOICE LABEL ===
            printer.set(bold=True, height=1, width=1)
            printer.text("INVOICE\n")
            self.log_debug("[DEBUG ESC/POS] Printed INVOICE label")
            printer.set(bold=False, height=1, width=1)
            printer.text("=" * PAPER_WIDTH + "\n")

            # === TRANSACTION INFO ===
            # Gunakan format 2-line per field: label (left) + value (right) dengan ESC a command
            inv_no = transaksi_data_dict.get("nomer", "-")
            dtime = transaksi_data_dict.get("dtime", "-")
            kasir = transaksi_data_dict.get("oleh_nama", "-")
            customer = transaksi_data_dict.get("customers_nama", "Umum")

            self.log_debug(f"[DEBUG ESC/POS] Transaction info: inv={inv_no}, date={dtime}")

            def print_field(label, value):
                """Print field tanpa truncation, value dibungkus jika panjang."""
                max_label = 10
                label_text = (label or "")[:max_label].ljust(max_label)
                value_text = str(value or "")
                max_value = PAPER_WIDTH - max_label - 2  # 2 for spacing

                printer._raw(b"\x1B\x61\x00")  # ESC a 0 = left align
                if max_value > 6 and len(value_text) <= max_value:
                    line = label_text + ": " + value_text.rjust(max_value)
                    self.log_debug(f"[DEBUG ESC/POS] Field line: '{line}'")
                    printer.text(line + "\n")
                    return

                printer.text(label_text + ":\n")
                for i in range(0, len(value_text), PAPER_WIDTH):
                    printer.text(value_text[i:i + PAPER_WIDTH] + "\n")

            print_field("No.Invoice", inv_no)
            print_field("Tanggal", dtime)
            print_field("Kasir", kasir)
            print_field("Customer", customer)

            printer._raw(b"\x1B\x61\x00")  # Reset left
            printer.text("-" * PAPER_WIDTH + "\n")

            # === ITEMS ===
            printer._raw(b"\x1B\x61\x00")  # Left align
            for item in items:
                name = item.get("name", "Item")
                qty = item.get("qty", 1)
                price = item.get("price", 0)
                disc = item.get("disc", 0)
                is_free = item.get("free", False)

                subtotal = (price * qty) - disc
                if is_free:
                    subtotal = 0

                # Item name (truncate to safe width)
                name_safe = name[:PAPER_WIDTH - 2] if len(name) > PAPER_WIDTH - 2 else name
                printer.text(name_safe + "\n")

                # Qty x Price (left) and Subtotal (right) - safe calculation
                qty_price = f" {qty}x {price:,}"
                subtotal_str = f"{subtotal:,}"

                # Calculate spacing
                space_needed = PAPER_WIDTH - len(qty_price) - len(subtotal_str)
                if space_needed < 0:
                    space_needed = 0

                if is_free:
                    printer.text(qty_price + " " * (PAPER_WIDTH - len(qty_price) - 4) + "FREE\n")
                elif disc > 0:
                    # Show with discount
                    printer.text(qty_price + " " * space_needed + subtotal_str + "\n")
                    printer.text("  (disc -" + f"{disc:,}" + ")\n")
                else:
                    printer.text(qty_price + " " * space_needed + subtotal_str + "\n")

            printer.text("-" * PAPER_WIDTH + "\n")

            # === TOTALS ===
            # Calculate totals
            subtotal_sebelum_diskon_item = sum(
                (item.get("price", 0) * item.get("qty", 0))
                if not item.get("free", False) else 0
                for item in items
            )

            total_diskon_item = sum(
                item.get("disc", 0)
                if not item.get("free", False) else 0
                for item in items
            )

            diskon_persen = transaksi_data_dict.get("diskon_persen", 0)
            ppn_nominal = transaksi_data_dict.get("ppn_persen", 0)
            ppn_mode = self._resolve_ppn_mode(transaksi_data_dict)

            subtotal_setelah_diskon_item = subtotal_sebelum_diskon_item - total_diskon_item
            diskon_tambahan_nominal = 0
            if diskon_persen > 0:
                diskon_tambahan_nominal = int(subtotal_setelah_diskon_item * diskon_persen / 100)

            total_setelah_semua_diskon = subtotal_setelah_diskon_item - diskon_tambahan_nominal
            grand_total_final = total_setelah_semua_diskon + ppn_nominal
            ppn_label = "PPN"
            if ppn_mode == "include":
                grand_total_final = total_setelah_semua_diskon
                ppn_label = "Termasuk PPN"

            # Print totals dengan safe spacing (no hardcoded ljust/rjust)
            def print_total_line(label, value, is_bold=False):
                """Print total line dengan spacing calculation"""
                # Handle both int and str values
                if isinstance(value, str):
                    value_str = value
                else:
                    value_str = f"{value:,}"

                space_count = PAPER_WIDTH - len(label) - len(value_str) - 1
                if space_count < 1:
                    space_count = 1

                if is_bold:
                    printer.set(bold=True)
                printer.text(label + " " * space_count + value_str + "\n")
                if is_bold:
                    printer.set(bold=False)

            print_total_line("Subtotal", subtotal_sebelum_diskon_item)

            if total_diskon_item > 0:
                print_total_line("Diskon Item", f"-{total_diskon_item:,}")

            if diskon_tambahan_nominal > 0:
                print_total_line("Diskon Tamb", f"-{diskon_tambahan_nominal:,}")

            print_total_line("TOTAL", total_setelah_semua_diskon, is_bold=True)

            if ppn_nominal > 0:
                print_total_line(ppn_label, ppn_nominal)

            print_total_line("GRAND TOTAL", grand_total_final, is_bold=True)
            point_transaksi = self._resolve_point_transaksi_value(transaksi_data_dict)
            if point_transaksi > 0:
                print_total_line("POINT DIPEROLEH", point_transaksi)

            # edited by glg
            free_items_summary = transaksi_data_dict.get("free_items_summary", []) or []
            if free_items_summary:
                printer.text("-" * PAPER_WIDTH + "\n")
                printer.text("FREE ITEM\n")
                for free_item in free_items_summary:
                    nama = str(free_item.get("nama", "-") or "-")
                    try:
                        qty = int(free_item.get("qty", 0) or 0)
                    except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError):
                        qty = 0
                    if qty <= 0:
                        continue
                    line = f"- {qty} x {nama}"
                    if len(line) <= PAPER_WIDTH:
                        printer.text(line + "\n")
                        continue
                    head = f"- {qty} x "
                    available = max(PAPER_WIDTH - len(head), 8)
                    chunks = [nama[i:i + available] for i in range(0, len(nama), available)]
                    if chunks:
                        printer.text(head + chunks[0] + "\n")
                        for chunk in chunks[1:]:
                            printer.text("  " + chunk + "\n")

            printer.text("=" * PAPER_WIDTH + "\n")

            # === PAYMENT ===
            payment_list = payment.get("payment_list", [])
            if payment_list:
                # Multi-payment mode
                for pay_item in payment_list:
                    method = pay_item.get("method", "Tunai")
                    amount = pay_item.get("amount", 0)
                    print_total_line(f"Bayar {method}", amount)
            else:
                # Single payment mode
                method = payment.get("method", "Tunai")
                jumlah_dibayar = payment.get("jumlah_dibayar", 0)
                kembalian = payment.get("kembalian", 0)

                print_total_line(f"Bayar ({method})", jumlah_dibayar)
                if kembalian > 0:
                    print_total_line("Kembalian", kembalian)

            printer.text("=" * PAPER_WIDTH + "\n")

            # === FOOTER ===
            printer.set(align='center', font='a')
            for i in range(1, 4):
                footer_text = setting.get(f"footer{i}", "")
                if footer_text:
                    # Word wrap jika footer panjang
                    if len(footer_text) > PAPER_WIDTH:
                        words = footer_text.split()
                        line = ""
                        for word in words:
                            if len(line) + len(word) + 1 <= PAPER_WIDTH:
                                line += (word + " ")
                            else:
                                printer.text(line.strip() + "\n")
                                line = word + " "
                        if line:
                            printer.text(line.strip() + "\n")
                    else:
                        printer.text(footer_text + "\n")

            if wifi_code and wifi_code != "-":
                printer.text(f"WiFi: {wifi_code}\n")

            # === QR CODE ===
            if qr_data:
                try:
                    printer.qr(qr_data, center=True, size=4)
                    printer.text("\n")
                    # Print URL dengan word wrap
                    if len(qr_data) > PAPER_WIDTH:
                        for i in range(0, len(qr_data), PAPER_WIDTH):
                            printer.text(qr_data[i:i+PAPER_WIDTH] + "\n")
                    else:
                        printer.text(qr_data + "\n")
                except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e:
                    self.log_warning(f"QR code error: {e}")

            # Cut paper
            printer.text("\n\n\n")
            printer.cut()

            self.log_info("ESC/POS RAW print berhasil")
            return True

        except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e:
            error_msg = str(e)
            self.log_error(f"ESC/POS print error: {error_msg}")

            # Provide helpful hints based on error type
            if "Incorrect printer name" in error_msg or "Device not found" in error_msg:
                self.log_warning("Printer name mismatch")
                self.log_warning(f"Config: '{printer_name}'")
                self.log_warning("Hint: Buka Windows Settings -> Printers -> cek nama exact printer")
                self.log_warning("Pastikan nama sama persis (case-sensitive, termasuk spasi)")

            import traceback
            traceback.print_exc()
            return False

    def _create_doc_raw_painter(self, items: List[Dict], payment: Dict, wifi_code: str, qr_data: str, cols: int, setting: Dict, transaksi_data_dict: Dict, width_pt: float = None, printer: QPrinter = None):
        return PrinterRawPainterRenderService(controller=self).build_renderer(
            items=items,
            payment=payment,
            wifi_code=wifi_code,
            qr_data=qr_data,
            cols=cols,
            setting=setting,
            transaksi_data_dict=transaksi_data_dict,
            width_pt=width_pt,
            printer=printer,
        )

    def _create_doc(self, items: List[Dict], payment: Dict, wifi_code: str, qr_data: str, cols: int, setting: Dict, transaksi_data_dict: Dict, width_pt: float = None) -> QTextDocument:
        """Buat QTextDocument berisi struk HTML dengan setting & transaksi nyata."""
        from PySide6.QtGui import QTextCursor, QTextBlockFormat, QTextCharFormat, QTextImageFormat
        from PySide6.QtCore import Qt

        doc = QTextDocument()
        doc.setDefaultFont(QFont("DejaVu Sans Mono", 10))

        if width_pt:
            doc.setTextWidth(width_pt)
            doc.setPageSize(QSizeF(width_pt, 999999))

        doc.setDocumentMargin(0)

        cursor = QTextCursor(doc)

        # Format untuk alignment
        fmt_center = QTextBlockFormat()
        fmt_center.setAlignment(Qt.AlignCenter)
        fmt_center.setTopMargin(2)
        fmt_center.setBottomMargin(2)

        fmt_right = QTextBlockFormat()
        fmt_right.setAlignment(Qt.AlignRight)
        fmt_right.setTopMargin(1)
        fmt_right.setBottomMargin(1)

        fmt_left = QTextBlockFormat()
        fmt_left.setAlignment(Qt.AlignLeft)
        fmt_left.setTopMargin(1)
        fmt_left.setBottomMargin(1)

        # === LOGO (CENTER) ===
        logo_path = setting.get("logo", "")
        if logo_path:
            full_logo_path = os.path.join(get_app_data_resource_dir(), "logo", logo_path)
            if os.path.exists(full_logo_path):
                cursor.insertBlock(fmt_center)
                img_fmt = QTextImageFormat()
                img_fmt.setName(full_logo_path)
                img_fmt.setWidth(75)
                img_fmt.setHeight(48)
                cursor.insertImage(img_fmt)
                cursor.insertBlock()

        # === HEADER (RIGHT ALIGN) ===
        char_fmt_hdr = QTextCharFormat()
        char_fmt_hdr.setFontWeight(QFont.Bold)
        char_fmt_hdr.setFontPointSize(12)

        char_fmt_sub = QTextCharFormat()
        char_fmt_sub.setFontPointSize(10)

        for i in range(1, 4):
            header_text = setting.get(f"header{i}", "")
            if header_text:
                cursor.insertBlock(fmt_right)
                if i == 1:
                    cursor.insertText(header_text, char_fmt_hdr)
                else:
                    cursor.insertText(header_text, char_fmt_sub)

        cursor.insertBlock(fmt_left)
        cursor.insertText("-" * cols)

        # === TRANSACTION INFO (LEFT) ===
        inv_no = transaksi_data_dict.get("nomer", "-")
        dtime = transaksi_data_dict.get("dtime", "-")
        kasir = transaksi_data_dict.get("oleh_nama", "-")
        customer = transaksi_data_dict.get("customers_nama", "Umum")

        def fmt_line(left: str, right: str, width: int = cols) -> str:
            left = str(left)[:width-len(str(right))-1]
            spaces = width - len(left) - len(str(right))
            return left + (" " * max(spaces, 1)) + str(right)

        cursor.insertBlock(fmt_left)
        cursor.insertText(fmt_line('No. Inv', inv_no))
        cursor.insertBlock(fmt_left)
        cursor.insertText(fmt_line('Tanggal', dtime))
        cursor.insertBlock(fmt_left)
        cursor.insertText(fmt_line('Kasir', kasir))
        cursor.insertBlock(fmt_left)
        cursor.insertText(fmt_line('Customer', customer))

        cursor.insertBlock(fmt_left)
        cursor.insertText("-" * cols)

        # === ITEMS ===
        for item in items:
            name = item.get("name", "Item")
            qty = item.get("qty", 1)
            price = item.get("price", 0)
            disc = item.get("disc", 0)
            is_free = item.get("free", False)

            subtotal = (price * qty) - disc
            if is_free:
                subtotal = 0

            cursor.insertBlock(fmt_left)
            cursor.insertText(name[:cols])

            qty_price = f"{qty} x {price:,}"
            if disc > 0:
                cursor.insertBlock(fmt_left)
                cursor.insertText(fmt_line(qty_price, f'-{disc:,}'))
                cursor.insertBlock(fmt_left)
                cursor.insertText(fmt_line('', f'{subtotal:,}'))
            elif is_free:
                cursor.insertBlock(fmt_left)
                cursor.insertText(fmt_line(qty_price, 'FREE'))
            else:
                cursor.insertBlock(fmt_left)
                cursor.insertText(fmt_line(qty_price, f'{subtotal:,}'))

        cursor.insertBlock(fmt_left)
        cursor.insertText("-" * cols)

        # === TOTAL ===
        nilai_total = transaksi_data_dict.get("transaksi_nilai", 0)
        ppn_nominal = self._to_non_negative_int(transaksi_data_dict.get("ppn_persen", 0), 0)
        ppn_mode = self._resolve_ppn_mode(transaksi_data_dict)
        ppn_label = "Termasuk PPN" if ppn_mode == "include" else "PPN"
        grand_total = nilai_total

        char_fmt_bold = QTextCharFormat()
        char_fmt_bold.setFontWeight(QFont.Bold)

        cursor.insertBlock(fmt_left)
        cursor.insertText(fmt_line('TOTAL', f'Rp {nilai_total:,}'), char_fmt_bold)
        if ppn_nominal > 0:
            cursor.insertBlock(fmt_left)
            cursor.insertText(fmt_line(ppn_label, f'Rp {ppn_nominal:,}'))
        cursor.insertBlock(fmt_left)
        cursor.insertText(fmt_line('GRAND TOTAL', f'Rp {grand_total:,}'), char_fmt_bold)
        point_transaksi = self._resolve_point_transaksi_value(transaksi_data_dict)
        if point_transaksi > 0:
            cursor.insertBlock(fmt_left)
            cursor.insertText(fmt_line('POINT DIPEROLEH', f'{point_transaksi:,}'))

        cursor.insertBlock(fmt_left)
        cursor.insertText("-" * cols)

        # === PAYMENT ===
        method = payment.get("method", "Tunai")
        cursor.insertBlock(fmt_left)
        cursor.insertText(fmt_line('Metode', method))

        cursor.insertBlock(fmt_left)
        cursor.insertText("-" * cols)

        # === FOOTER (RIGHT ALIGN) ===
        char_fmt_ftr = QTextCharFormat()
        char_fmt_ftr.setFontPointSize(9)

        for i in range(1, 4):
            footer_text = setting.get(f"footer{i}", "")
            if footer_text:
                cursor.insertBlock(fmt_right)
                cursor.insertText(footer_text, char_fmt_ftr)

        if wifi_code and wifi_code != "-":
            cursor.insertBlock(fmt_right)
            cursor.insertText(f"WiFi: {wifi_code}", char_fmt_ftr)

        # === QR CODE (RIGHT ALIGN) ===
        if qr_data and QRCODE_AVAILABLE:
            try:
                import tempfile
                qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=3, border=2)
                qr.add_data(qr_data)
                qr.make(fit=True)
                img = qr.make_image(fill_color="black", back_color="white")

                # Save to temp file
                with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmp:
                    img.save(tmp.name)
                    tmp_path = tmp.name

                cursor.insertBlock(fmt_right)
                img_fmt = QTextImageFormat()
                img_fmt.setName(tmp_path)
                img_fmt.setWidth(120)
                img_fmt.setHeight(120)
                cursor.insertImage(img_fmt)

                cursor.insertBlock(fmt_right)
                cursor.insertText(qr_data[:50], char_fmt_ftr)
            except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e:
                self.log_warning(f"[_create_doc] Error generating QR: {e}")

        return doc

    def test_print(self, index: int) -> bool:
        from PySide6.QtPrintSupport import QPrinter, QPrinterInfo
        infos = QPrinterInfo.availablePrinters()
        self.log_debug("[Printer List Detected by Qt:]")
        for p in infos:
            self.log_debug(f" - {p.printerName()}")
        printers = self.model.load_printers()
        if not self._get_printer_settings_ui_state_service().is_valid_printer_index(index, len(printers)):
            return False

        cfg = printers[index]
        printer_name = str(cfg.get("name") or "").strip()
        paper_label = str(cfg.get("paper_size") or DEFAULT_PAPER_SIZE).strip() or DEFAULT_PAPER_SIZE
        default_name = self._get_default_printer_name()
        if not printer_name and default_name:
            printer_name = str(default_name or "").strip()
            self.log_info(f"[Printer] Default printer terdeteksi: {printer_name}")
        elif printer_name:
            self.log_info(f"[Printer] Printer terkonfigurasi: {printer_name}")
        else:
            self.log_warning("[Printer] Tidak ada printer default.")
            return False

        demo_ctx = self._get_demo_print_context()
        items = demo_ctx["items"]
        payment = demo_ctx["payment"]
        wifi_code = demo_ctx["wifi_code"]
        qr_data = demo_ctx["qr_data"]
        transaksi_data_dict = demo_ctx["transaksi_data_dict"]
        setting = self.get_receipt_setting()

        try:
            if self.print_escpos_raw(
                items=items,
                payment=payment,
                wifi_code=wifi_code,
                qr_data=qr_data,
                setting=setting,
                transaksi_data_dict=transaksi_data_dict,
                printer_name=printer_name,
            ):
                self.log_info("[TestPrint] Test print complete (ESC/POS)")
                return True
        except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e:
            self.log_warning(f"[TestPrint] ESC/POS gagal, fallback ke QPainter: {e}")

        printer = QPrinter(QPrinter.HighResolution)
        printer.setPrinterName(printer_name)
        printer.setResolution(THERMAL_DPI)
        try:
            printer.setOutputFormat(QPrinter.NativeFormat)
        except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError):
            pass
        try:
            printer.setFullPage(True)
        except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError):
            pass
        self._apply_paper_size(printer, paper_label)

        cols = self._estimate_cols(printer, css_font_pt=10.0, family="DejaVu Sans Mono", paper_label=paper_label)
        w_mm = self.paper_service.get_paper_mm(paper_label)
        w_pt = self._mm_to_points(w_mm)
        render_func = self._create_doc_raw_painter(
            items, payment, wifi_code, qr_data, cols, setting, transaksi_data_dict, w_pt, printer
        )

        painter = QPainter()
        if not painter.begin(printer):
            self.log_error("[TestPrint] Cannot start painter")
            return False

        render_func(painter)
        painter.end()
        self.log_info("[TestPrint] Test print complete (QPainter fallback)")
        return True

    # ------------------ ambil printer default ------------------
    def get_default_printer(self) -> Optional[Dict]:
        printers = self.model.load_printers()
        for p in printers:
            if p.get("default"):
                return p

        # fallback ke default printer OS
        try:
            sys_printer = QPrinterInfo.defaultPrinter()
            if not sys_printer.isNull():
                default_paper_size = (
                    self.model.get_default_printer_paper_size()
                    if hasattr(self.model, "get_default_printer_paper_size")
                    else DEFAULT_PAPER_SIZE
                )
                return {
                    "name": sys_printer.printerName(),
                    "paper_size": str(default_paper_size or DEFAULT_PAPER_SIZE),
                    "default": True
                }
        except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError):
            pass
        return None

    # ------------------ cetak langsung ------------------
    def print_struk(self, items, payment, wifi_code, qr_data, setting, transaksi_data_dict, copy_no: int = 0) -> bool:
        cfg = self.get_default_printer()
        if not cfg:
            self.show_warning("Printer", "Belum ada default printer, silakan setting dulu!")
            return False

        printer_name = cfg.get("name", "")
        paper_label = cfg.get("paper_size", "")

        printer = QPrinter(QPrinter.HighResolution)
        printer.setPrinterName(printer_name)
        printer.setResolution(THERMAL_DPI)
        try:
            printer.setOutputFormat(QPrinter.NativeFormat)
        except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError):
            pass
        self._apply_paper_size(printer, paper_label)

        # hitung kolom
        cols = self._estimate_cols(printer, css_font_pt=10.0, family="DejaVu Sans Mono", paper_label=paper_label)

        # Calculate paper width in points
        w_mm = self.paper_service.get_paper_mm(paper_label)
        w_pt = self._mm_to_points(w_mm)

        # buat doc
        doc = self._create_doc(items, payment, wifi_code, qr_data, cols, setting, transaksi_data_dict, w_pt)

        # tambahkan tanda copy jika perlu
        if copy_no > 0:
            html = doc.toHtml()
            html = html.replace("<body>", f"<body><div class='hdr'>*** COPY KE-{copy_no} ***</div>")
            doc.setHtml(html)

        try:
            return self._render_doc_to_printer(printer, doc)
        except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e:
            self.log_error(f"[PrintStruk] Error cetak: {e}")
            return False

    def _apply_paper_size(self, printer: QPrinter, paper_label: str):
        self.paper_service.apply_paper_size(printer, paper_label)

    def _estimate_cols(self, printer: QPrinter, css_font_pt: float = 10.0, family: str = "DejaVu Sans Mono", paper_label: str = DEFAULT_PAPER_SIZE) -> int:
        w_mm = self.paper_service.get_paper_mm(paper_label)
        cols = self.paper_service.estimate_cols(paper_label)
        self.log_debug(f"[_estimate_cols] Paper: {w_mm}mm, Standard cols: {cols}")
        return cols

    def _make_preview_printer(self, w_mm: float) -> QPrinter:
        return self.paper_service.make_preview_printer(w_mm)

    def _build_receipt_html(
        self,
        items: List[Dict],
        payment: Dict,
        wifi_code: str,
        qr_data: str,
        cols: int,
        setting: Dict = None,
        transaksi_data_dict: Dict = None
    ) -> str:

        if setting is None:
            setting = {}
        if transaksi_data_dict is None:
            transaksi_data_dict = {}

        # Helper function untuk format baris dengan padding
        def fmt_line(left: str, right: str, width: int = cols) -> str:
            """Format line with left-right alignment."""
            left = str(left)[:width-len(str(right))-1]
            spaces = width - len(left) - len(str(right))
            return left + (" " * max(spaces, 1)) + str(right)

        def fmt_line_wrap(label: str, value: str, width: int = cols):
            label_text = str(label or "")
            value_text = str(value or "")
            max_value = width - len(label_text) - 1
            if max_value > 6 and len(value_text) <= max_value:
                return [fmt_line(label_text, value_text, width)]
            lines = [f"{label_text}:" if label_text else ""]
            for i in range(0, len(value_text), width):
                lines.append(value_text[i:i + width])
            return lines

        # Load CSS dari file
        css = ""
        try:
            css_path = os.path.join(get_app_data_resource_dir(), "styles", "struk_style.css")
            if os.path.exists(css_path):
                with open(css_path, "r", encoding="utf-8") as f:
                    css = f.read()
        except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e:
            self.log_warning(f"[_build_receipt_html] Warning: Gagal load CSS: {e}")

        # Build HTML parts
        html_parts = []
        import time
        timestamp = int(time.time())
        html_parts.append(f"<html><head><!-- v{timestamp} --><style>{css}</style></head><body>")
        html_parts.append("<div class='struk-container'>")

        # === HEADER ===
        logo_path = setting.get("logo", "")
        if logo_path:
            full_logo_path = os.path.join(get_app_data_resource_dir(), "logo", logo_path)
            if os.path.exists(full_logo_path):
                try:
                    with open(full_logo_path, "rb") as img_file:
                        img_base64 = base64.b64encode(img_file.read()).decode()
                        html_parts.append(f"<div class='section text-center'><img class='logo' src='data:image/png;base64,{img_base64}' /></div>")
                except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e:
                    self.log_warning(f"[_build_receipt_html] Warning: Gagal load logo: {e}")

        for i in range(1, 4):
            header_text = setting.get(f"header{i}", "")
            if header_text:
                cls = "hdr" if i == 1 else "sub"
                # PATCH: Qt fixed-layout table - 2 kolom untuk force right align
                html_parts.append(f"<table width='100%' style='table-layout:fixed;'><tr><td width='10%'></td><td width='90%' align='right' class='{cls}'>{header_text}</td></tr></table>")

        html_parts.append("<hr/>")

        # === TRANSACTION INFO ===
        inv_no = transaksi_data_dict.get("nomer", "-")
        dtime = transaksi_data_dict.get("dtime", "-")
        kasir = transaksi_data_dict.get("oleh_nama", "-")
        customer = transaksi_data_dict.get("customers_nama", "Umum")

        html_parts.append("<div class='section text-left'>")
        for line in fmt_line_wrap("No. Inv", inv_no):
            html_parts.append(f"<pre>{line}</pre>")
        for line in fmt_line_wrap("Tanggal", dtime):
            html_parts.append(f"<pre>{line}</pre>")
        for line in fmt_line_wrap("Kasir", kasir):
            html_parts.append(f"<pre>{line}</pre>")
        for line in fmt_line_wrap("Customer", customer):
            html_parts.append(f"<pre>{line}</pre>")
        html_parts.append("</div>")
        html_parts.append("<hr/>")

        # === ITEMS ===
        html_parts.append("<div class='section text-left'>")
        for item in items:
            name = item.get("name", "Item")
            qty = item.get("qty", 1)
            price = item.get("price", 0)
            disc = item.get("disc", 0)
            is_free = item.get("free", False)

            subtotal = (price * qty) - disc
            if is_free:
                subtotal = 0

            # Line 1: Nama barang
            html_parts.append(f"<pre>{name[:cols]}</pre>")

            # Line 2: qty x price = subtotal
            qty_price = f"{qty} x {price:,}"
            if disc > 0:
                html_parts.append(f"<pre>{fmt_line(qty_price, f'-{disc:,}')}</pre>")
                html_parts.append(f"<pre>{fmt_line('', f'{subtotal:,}')}</pre>")
            elif is_free:
                html_parts.append(f"<pre>{fmt_line(qty_price, 'FREE')}</pre>")
            else:
                html_parts.append(f"<pre>{fmt_line(qty_price, f'{subtotal:,}')}</pre>")

        html_parts.append("</div>")
        html_parts.append("<hr/>")

        # === TOTAL ===
        nilai_total = transaksi_data_dict.get("transaksi_nilai", 0)
        diskon_persen = transaksi_data_dict.get("diskon_persen", 0)
        ppn_nominal = self._to_non_negative_int(transaksi_data_dict.get("ppn_persen", 0), 0)
        ppn_mode = self._resolve_ppn_mode(transaksi_data_dict)
        ppn_label = "Termasuk PPN" if ppn_mode == "include" else "PPN"
        grand_total = nilai_total

        html_parts.append("<div class='section text-left'>")
        html_parts.append(f"<pre><b>{fmt_line('TOTAL', f'Rp {nilai_total:,}')}</b></pre>")
        if diskon_persen > 0:
            html_parts.append(f"<pre>{fmt_line(f'Diskon ({diskon_persen}%)', f'- Rp XXX')}</pre>")
        if ppn_nominal > 0:
            html_parts.append(f"<pre>{fmt_line(ppn_label, f'Rp {ppn_nominal:,}')}</pre>")
        html_parts.append(f"<pre><b>{fmt_line('GRAND TOTAL', f'Rp {grand_total:,}')}</b></pre>")
        point_transaksi = self._resolve_point_transaksi_value(transaksi_data_dict)
        if point_transaksi > 0:
            html_parts.append(f"<pre>{fmt_line('POINT DIPEROLEH', f'{point_transaksi:,}')}</pre>")
        html_parts.append("</div>")
        html_parts.append("<hr/>")

        # === PAYMENT ===
        method = payment.get("method", "Tunai")
        html_parts.append("<div class='section text-left'>")
        html_parts.append(f"<pre>{fmt_line('Metode', method)}</pre>")

        if method in ("Debit Card", "Kredit", "Debit"):
            card_brand = payment.get("card_brand", "-")
            last4 = payment.get("last4", "XXXX")
            approval = payment.get("approval_code", "-")
            html_parts.append(f"<pre>{fmt_line('Card', f'{card_brand} ***{last4}')}</pre>")
            html_parts.append(f"<pre>{fmt_line('Approval', approval)}</pre>")

        html_parts.append("</div>")
        html_parts.append("<hr/>")

        # === FOOTER ===
        for i in range(1, 4):
            footer_text = setting.get(f"footer{i}", "")
            if footer_text:
                html_parts.append(f"<table width='100%' style='table-layout:fixed;'><tr><td width='10%'></td><td width='90%' align='right' class='ftr'>{footer_text}</td></tr></table>")

        if wifi_code and wifi_code != "-":
            html_parts.append(f"<table width='100%' style='table-layout:fixed;'><tr><td width='10%'></td><td width='90%' align='right' class='ftr'>WiFi: {wifi_code}</td></tr></table>")

        if qr_data and QRCODE_AVAILABLE:
            try:
                # Generate QR code
                qr = qrcode.QRCode(
                    version=1,
                    error_correction=qrcode.constants.ERROR_CORRECT_L,
                    box_size=3,
                    border=2,
                )
                qr.add_data(qr_data)
                qr.make(fit=True)

                # Create image
                img = qr.make_image(fill_color="black", back_color="white")

                # Convert to base64
                buffered = BytesIO()
                img.save(buffered, format="PNG")
                img_base64 = base64.b64encode(buffered.getvalue()).decode()

                html_parts.append(f"<table width='100%' style='table-layout:fixed;'><tr><td width='10%'></td><td width='90%' align='right'>")
                html_parts.append(f"<img src='data:image/png;base64,{img_base64}' style='width: 120px; height: 120px;' />")
                html_parts.append(f"<br/><span class='ftr'><small>{qr_data[:50]}</small></span>")
                html_parts.append(f"</td></tr></table>")
            except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e:
                self.log_warning(f"[_build_receipt_html] Error generating QR code: {e}")
                # Fallback to text only
                html_parts.append(f"<div class='ftr'><small>Scan QR: {qr_data[:40]}...</small></div>")
        elif qr_data:
            # Fallback jika qrcode library tidak tersedia
            html_parts.append(f"<div class='ftr'><small>URL: {qr_data[:40]}...</small></div>")

        html_parts.append("</div></body></html>")
        return "".join(html_parts)
