import os
import json
import time
import ssl
import gzip
import base64
from math import gcd
from functools import reduce
import websocket
from urllib.parse import urlencode
from typing import Any, Dict, List, Optional, Tuple

from escpos.printer import Network

# === REQUIRED FOR IMAGE ===
try:
    from PIL import Image, ImageDraw, ImageFont
except Exception as e:
    raise RuntimeError("Pillow belum terinstall. Jalankan: pip install pillow") from e

# === PUSHER ===
PUSHER_APP_KEY = "bbaaf618b8257bd9e819"
PUSHER_CLUSTER = "ap1"
CHANNEL_NAME = "print-orders"
EVENT_NAME = "order.print"

# === PRINTER CONFIG ===
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")

# === UKURAN FONT ===
STAND_FONT_SIZE = 70    # Header besar (stand / meja / pelanggan)
TYPE_FONT_SIZE = 46     # DINE IN / TAKE AWAY
DETAIL_FONT_SIZE = 34   # Meja/Pelanggan line
ITEM_FONT_SIZE = 30     # Nama menu

# Font TTF Windows
TTF_FONT_PATHS = [
    "C:\\Windows\\Fonts\\arialbd.ttf",
    "C:\\Windows\\Fonts\\Arial.ttf",
]

# === NORMAL TEXT ===
ITEM_CHARS_PER_LINE = 48
ITEM_TEXT_SCALE = 1
ITEM_BITMAP_FONT_SIZE = ITEM_FONT_SIZE * ITEM_TEXT_SCALE
ITEM_NOTE_BITMAP_FONT_SIZE = ITEM_FONT_SIZE - 2
META_LABEL_WIDTH = 10


def load_config() -> Dict[str, Any]:
    with open(CONFIG_PATH, "r", encoding="utf-8") as f:
        return json.load(f)


def get_printer_cfg(printer_key: str) -> Dict[str, Any]:
    cfg = load_config()
    printers = cfg.get("printers", {})
    if printer_key not in printers:
        raise KeyError(f"Printer '{printer_key}' tidak ada di config.json. Available: {list(printers.keys())}")
    return printers[printer_key]


def get_agent_token() -> Optional[str]:
    cfg = load_config()
    return cfg.get("agent_token")


def _meta_row(label: str, value: Optional[str], width: int = 0) -> Optional[str]:
    if value is None:
        return None
    v = str(value).strip()
    if not v:
        return None
    if width and label:
        return f"{label:<{width}}: {v}"
    return f"{label}: {v}"


def _print_left_right_text(p, left: Optional[str], right: Optional[str], width: int):
    left_text = (left or "").strip()
    right_text = (right or "").strip()
    if not left_text and not right_text:
        return
    if left_text and right_text:
        if len(left_text) + len(right_text) + 1 <= width:
            spaces = width - len(left_text) - len(right_text)
            p.text(left_text + (" " * spaces) + right_text + "\n")
        else:
            p.text(left_text + "\n")
            p.text(right_text.rjust(width) + "\n")
        return
    if left_text:
        p.text(left_text + "\n")
        return
    p.text(right_text.rjust(width) + "\n")


def _pick_first(data: Dict[str, Any], keys: List[str]) -> Optional[str]:
    for k in keys:
        v = data.get(k)
        if v not in (None, ""):
            return v
    return None


def _pick_first_from_items(items: Optional[List[Dict[str, Any]]], keys: List[str]) -> Optional[str]:
    if not items:
        return None
    for it in items:
        if not isinstance(it, dict):
            continue
        for k in keys:
            v = it.get(k)
            if v not in (None, ""):
                return v
    return None


def _pick_from_lines(lines: Optional[List[str]], labels: List[str]) -> Optional[str]:
    if not lines:
        return None
    labels_lower = {str(l).strip().lower() for l in labels}
    for line in lines:
        if not isinstance(line, str):
            continue
        if ":" not in line:
            continue
        left, right = line.split(":", 1)
        if left.strip().lower() in labels_lower:
            value = right.strip()
            if value:
                return value
    return None


def _normalize_printer_key(requested: Optional[str], payload: Dict[str, Any]) -> str:
    cfg = load_config()
    available = set((cfg.get("printers") or {}).keys())

    raw = (requested or "").strip().lower()

    if not raw:
        raw = str(_pick_first(payload, ["target", "area", "station", "print_to"]) or "").strip().lower()

    if not raw:
        t = str(_pick_first(payload, ["type_menu", "category", "jenis"]) or "").strip().lower()
        if t in ("minuman", "drink", "beverage", "bar"):
            raw = "bar"

    alias_map = {
        "kitchen1": "kitchen",
        "kitchen2": "kitchen",
        "dapur": "kitchen",
        "kitchen": "kitchen",
        "food": "kitchen",
        "makanan": "kitchen",
        "bar": "bar",
        "drink": "bar",
        "minuman": "bar",
        "beverage": "bar",
    }
    raw = alias_map.get(raw, raw)

    if raw in available:
        return raw

    if "kitchen" in available:
        return "kitchen"

    if available:
        return sorted(list(available))[0]

    return "kitchen"


def _get_font(size: int):
    """Cari font TTF, fallback ke default"""
    for font_path in TTF_FONT_PATHS:
        if os.path.exists(font_path):
            try:
                return ImageFont.truetype(font_path, size)
            except Exception:
                pass
    return ImageFont.load_default()


def _measure_text(text: str, font) -> Tuple[int, int]:
    temp_img = Image.new("L", (1, 1), color=255)
    temp_draw = ImageDraw.Draw(temp_img)
    try:
        bbox = temp_draw.textbbox((0, 0), text, font=font)
        return bbox[2] - bbox[0], bbox[3] - bbox[1]
    except AttributeError:
        return temp_draw.textsize(text, font=font)


def _split_long_word(word: str, font, max_width: int) -> List[str]:
    if not word:
        return []
    if _measure_text(word, font)[0] <= max_width:
        return [word]
    parts: List[str] = []
    cur = ""
    for ch in word:
        test = cur + ch
        if _measure_text(test, font)[0] <= max_width:
            cur = test
        else:
            if cur:
                parts.append(cur)
            cur = ch
    if cur:
        parts.append(cur)
    return parts


def _wrap_text_by_width(text: str, font, max_width: int) -> List[str]:
    text = (text or "").strip()
    if not text:
        return []
    words = text.split()
    lines: List[str] = []
    cur = ""
    for word in words:
        if not cur:
            cur = word
            continue
        candidate = cur + " " + word
        if _measure_text(candidate, font)[0] <= max_width:
            cur = candidate
        else:
            lines.extend(_split_long_word(cur, font, max_width))
            cur = word
    if cur:
        lines.extend(_split_long_word(cur, font, max_width))
    return lines


def _print_text_as_bitmap(p, text: str, font_size: int, align: str = "left", max_width: int = 560, min_size: int = 20):
    """Print text sebagai bitmap dengan auto-fit lebar"""
    text = (text or "").strip()
    if not text:
        return

    size = font_size
    font = _get_font(size)

    # auto reduce size if too wide
    while size >= min_size:
        font = _get_font(size)
        temp_img = Image.new("L", (1, 1), color=255)
        temp_draw = ImageDraw.Draw(temp_img)
        try:
            bbox = temp_draw.textbbox((0, 0), text, font=font)
            tw = bbox[2] - bbox[0]
            th = bbox[3] - bbox[1]
        except AttributeError:
            tw, th = temp_draw.textsize(text, font=font)

        if tw <= max_width:
            break
        size -= 2

    # manual scale if default font too small
    if tw < 100:
        scale = max(3, size // 20)
        tw *= scale
        th *= scale
        use_manual_scale = True
    else:
        use_manual_scale = False

    img_width = 576
    img_height = th + 20
    img = Image.new("L", (img_width, img_height), color=255)
    draw = ImageDraw.Draw(img)

    if align == "center":
        x_pos = (img_width - tw) // 2
    else:
        x_pos = 10
    y_pos = 10

    if use_manual_scale:
        small_img = Image.new("L", (tw // scale, th // scale), color=255)
        small_draw = ImageDraw.Draw(small_img)
        for dx in range(0, 8, 2):
            for dy in range(0, 8, 2):
                small_draw.text((dx, dy), text, font=font, fill=0)
        big_text = small_img.resize((tw, th), Image.NEAREST)
        img.paste(big_text, (x_pos, y_pos))
    else:
        draw.text((x_pos, y_pos), text, font=font, fill=0)

    img = img.convert("1")
    _send_bitmap_escpos(p, img)


def _print_left_right_bitmap(p, left: str, right: str, font_size: int, max_width: int = 560, min_size: int = 20):
    left = (left or "").strip()
    right = (right or "").strip()
    if not left and not right:
        return

    size = font_size
    font = _get_font(size)
    while size >= min_size:
        font = _get_font(size)
        lw, lh = _measure_text(left, font) if left else (0, 0)
        rw, rh = _measure_text(right, font) if right else (0, 0)
        if lw + rw + 20 <= max_width:
            break
        size -= 2

    img_width = 576
    img_height = max(lh, rh) + 20
    img = Image.new("L", (img_width, img_height), color=255)
    draw = ImageDraw.Draw(img)
    y_pos = 10

    if left:
        draw.text((10, y_pos), left, font=font, fill=0)
    if right:
        right_x = img_width - 10 - rw
        draw.text((right_x, y_pos), right, font=font, fill=0)

    img = img.convert("1")
    _send_bitmap_escpos(p, img)


def _send_bitmap_escpos(p, img: Image.Image):
    """Kirim bitmap via ESC *"""
    width, height = img.size
    pixels = img.load()

    y = 0
    while y < height:
        nL = width & 0xFF
        nH = (width >> 8) & 0xFF
        p._raw(b'\x1b\x2a\x21' + bytes([nL, nH]))

        for x in range(width):
            for byte_idx in range(3):
                byte_val = 0
                for bit in range(8):
                    py = y + byte_idx * 8 + bit
                    if py < height:
                        if pixels[x, py] == 0:
                            byte_val |= (1 << (7 - bit))
                p._raw(bytes([byte_val]))

        p._raw(b'\n')
        y += 24


def _format_item_simple(p, item: Dict[str, Any]):
    name = item.get("nama") or item.get("name") or item.get("menu") or ""
    qty = item.get("qty") or item.get("quantity") or 1
    note = item.get("note") or item.get("notes") or item.get("catatan") or ""

    try:
        qty_int = int(qty)
    except Exception:
        qty_int = 1

    right = f"x{qty_int}"
    left = str("- " + name).strip()
    font_size = ITEM_BITMAP_FONT_SIZE
    font = _get_font(font_size)
    right_w, _ = _measure_text(right, font)
    left_max = max(20, 560 - right_w - 10)
    left_lines = _wrap_text_by_width(left, font, left_max)

    if left_lines:
        _print_left_right_bitmap(p, left_lines[0], right, font_size, min_size=font_size)
        for extra in left_lines[1:]:
            _print_text_as_bitmap(p, extra, font_size, align="left", min_size=font_size)
    else:
        _print_left_right_bitmap(p, "", right, font_size, min_size=font_size)

    if note:
        note_str = str(note).strip()
        if note_str:
            _print_text_as_bitmap(
                p,
                f"  Catatan: {note_str}",
                ITEM_NOTE_BITMAP_FONT_SIZE,
                align="left",
                min_size=ITEM_NOTE_BITMAP_FONT_SIZE,
            )


def _safe_int(value: Any, default: int = 1) -> int:
    try:
        n = int(value)
        return n if n > 0 else default
    except Exception:
        return default


def _print_grouped_items(p, items: List[Dict[str, Any]]):
    """
    Format output:
    Paket A (paket)     x2
      Nasi Putih        x1
      Ayam Goreng       x1
    Note: ...

    Ayam Penyet (single) x1
    Note: ...
    """
    # Preserve insertion order of groups and singles.
    grouped: Dict[str, Dict[str, Any]] = {}
    singles: List[Dict[str, Any]] = []
    order: List[Tuple[str, str]] = []  # (kind, key/index)

    for idx, it in enumerate(items):
        if not isinstance(it, dict):
            continue
        source_package = str(it.get("source_package") or "").strip()
        note = str(it.get("note") or it.get("notes") or it.get("catatan") or "").strip()

        if source_package:
            key = f"{source_package}||{note}"
            if key not in grouped:
                grouped[key] = {
                    "package_name": source_package,
                    "note": note,
                    "components": [],       # list preserve order
                    "components_map": {},   # name -> row
                    "qty_candidates": [],
                }
                order.append(("package", key))

            qty_total = _safe_int(it.get("qty") or it.get("quantity"), 1)
            grouped[key]["qty_candidates"].append(qty_total)

            comp_name = str(it.get("nama") or it.get("name") or it.get("menu") or "").strip() or "Menu"
            comp_map = grouped[key]["components_map"]
            if comp_name not in comp_map:
                row = {"name": comp_name, "qty_total": 0}
                comp_map[comp_name] = row
                grouped[key]["components"].append(row)
            comp_map[comp_name]["qty_total"] += qty_total
        else:
            row = {
                "name": str(it.get("nama") or it.get("name") or it.get("menu") or "").strip() or "Menu",
                "qty": _safe_int(it.get("qty") or it.get("quantity"), 1),
                "note": note,
            }
            singles.append(row)
            order.append(("single", str(len(singles) - 1)))

    for kind, ref in order:
        if kind == "package":
            group = grouped.get(ref)
            if not group:
                continue

            qty_candidates = [q for q in group.get("qty_candidates", []) if q > 0]
            if qty_candidates:
                package_qty = reduce(gcd, qty_candidates)
                if package_qty <= 0:
                    package_qty = 1
            else:
                package_qty = 1

            package_name = f'{group["package_name"]} (paket)'
            _print_left_right_bitmap(p, package_name, f"x{package_qty}", ITEM_BITMAP_FONT_SIZE, min_size=ITEM_BITMAP_FONT_SIZE)

            for comp in group.get("components", []):
                total_qty = _safe_int(comp.get("qty_total"), 1)
                _print_left_right_bitmap(
                    p,
                    f'   {comp.get("name", "Menu")}',
                    f"x{total_qty}",
                    ITEM_BITMAP_FONT_SIZE,
                    min_size=ITEM_BITMAP_FONT_SIZE,
                )

            note = str(group.get("note") or "").strip()
            if note:
                _print_text_as_bitmap(
                    p,
                    f"Note : {note}",
                    ITEM_NOTE_BITMAP_FONT_SIZE,
                    align="left",
                    min_size=ITEM_NOTE_BITMAP_FONT_SIZE,
                )
            p.text("\n")
        else:
            try:
                single = singles[int(ref)]
            except Exception:
                continue

            _print_left_right_bitmap(
                p,
                f'{single["name"]} (single)',
                f'x{single["qty"]}',
                ITEM_BITMAP_FONT_SIZE,
                min_size=ITEM_BITMAP_FONT_SIZE,
            )
            note = str(single.get("note") or "").strip()
            if note:
                _print_text_as_bitmap(
                    p,
                    f"Note : {note}",
                    ITEM_NOTE_BITMAP_FONT_SIZE,
                    align="left",
                    min_size=ITEM_NOTE_BITMAP_FONT_SIZE,
                )
            p.text("\n")


def escpos_print_receipt(
    printer_key: str,
    title: str,
    meja: Optional[str],
    waiter: Optional[str],
    stand: Optional[str],
    pelanggan: Optional[str],
    items: Optional[List[Dict[str, Any]]],
    lines: Optional[List[str]],
    order_type: Optional[str] = None,
):
    pcfg = get_printer_cfg(printer_key)
    ip = pcfg["ip"]
    port = int(pcfg.get("port", 9100))

    p = Network(ip, port=port, timeout=10)

    try:
        # === TYPE LABEL ===
        if not order_type:
            if meja:
                order_type = "dine_in"
            elif pelanggan:
                order_type = "take_away"

        type_label = None
        if order_type == "dine_in":
            type_label = "DINE IN"
        elif order_type == "take_away":
            type_label = "TAKE AWAY"
        elif order_type:
            type_label = str(order_type).replace("_", " ").upper()

        type_display = type_label.title() if type_label else None

        # === HEADER (type) ===
        if type_display:
            p.set(align="left", bold=True, width=1, height=1)
            p.text(type_display + "\n")
        else:
            header_title = (stand or title or "ORDER").upper()
            _print_text_as_bitmap(p, header_title, STAND_FONT_SIZE, align="center")

        # === META (meja/pelanggan + waiter sejajar) ===
        left_label = None
        if order_type == "dine_in" and meja:
            left_label = f"Meja: {meja}"
        elif order_type == "take_away" and pelanggan:
            left_label = f"Pelanggan: {pelanggan}"

        right_label = f"Waiter: {waiter}" if waiter else None

        if left_label or right_label:
            p.set(align="left", bold=True, width=1, height=1)
            _print_left_right_text(p, left_label, right_label, ITEM_CHARS_PER_LINE)
            printed_at = time.strftime("%d-%m-%Y %H:%M")
            p.text(printed_at + "\n")
            p.text("\n")

        p.text("-" * ITEM_CHARS_PER_LINE + "\n")

        # === ITEMS ===
        if items and len(items) > 0:
            _print_grouped_items(p, items)
        elif lines and len(lines) > 0:
            for l in lines:
                p.text(str(l) + "\n")
        else:
            p.text("(Tidak ada item)\n")

        p.text("\n")
        p.cut()

    finally:
        p.close()


def print_payload_with_stands(payload: Dict[str, Any]):
    order_type = _pick_first(payload, ["type", "order_type"])

    stands = payload.get("stands") or []
    if stands:
        root_lines = payload.get("lines") or []
        for st in stands:
            printer = _normalize_printer_key(st.get("printer") or st.get("stand"), payload)
            title = payload.get("title") or ("BAR" if printer == "bar" else "KITCHEN")

            items = st.get("items") or []
            lines = st.get("lines") or []

            meja = (
                _pick_first(payload, ["meja", "table", "table_name", "nomor_meja"])
                or _pick_first(st, ["meja", "table", "table_name", "nomor_meja"])
                or _pick_first_from_items(items, ["meja", "table", "table_name", "nomor_meja"])
                or _pick_from_lines(root_lines, ["meja", "table", "table name", "nomor meja"])
            )
            waiter = (
                _pick_first(payload, ["waiter", "nama_waiter", "server", "kasir"])
                or _pick_first(st, ["waiter", "nama_waiter", "server", "kasir"])
                or _pick_first_from_items(items, ["waiter", "nama_waiter", "server", "kasir"])
                or _pick_from_lines(root_lines, ["waiter", "nama waiter", "server", "kasir"])
            )
            stand = _pick_first(st, ["stand", "booth", "outlet"]) or _pick_first(payload, ["stand", "booth", "outlet"])
            pelanggan = (
                _pick_first(payload, ["pelanggan", "customer", "customer_name", "nama_pelanggan"])
                or _pick_first(st, ["pelanggan", "customer", "customer_name", "nama_pelanggan"])
                or _pick_first_from_items(items, ["pelanggan", "customer", "customer_name", "nama_pelanggan"])
                or _pick_from_lines(root_lines, ["pelanggan", "nama pelanggan", "customer", "customer name"])
            )

            escpos_print_receipt(
                printer_key=printer,
                title=title,
                meja=meja,
                waiter=waiter,
                stand=stand,
                pelanggan=pelanggan,
                items=items,
                lines=lines,
                order_type=order_type,
            )
            time.sleep(0.5)

        return True

    printer = _normalize_printer_key(payload.get("printer"), payload)
    title = payload.get("title") or ("BAR" if printer == "bar" else "KITCHEN")

    items = payload.get("items") or []
    lines = payload.get("lines") or []

    meja = _pick_first(payload, ["meja", "table", "table_name", "nomor_meja"]) or _pick_first_from_items(
        items, ["meja", "table", "table_name", "nomor_meja"]
    )
    waiter = _pick_first(payload, ["waiter", "nama_waiter", "server", "kasir"]) or _pick_first_from_items(
        items, ["waiter", "nama_waiter", "server", "kasir"]
    )
    stand = _pick_first(payload, ["stand", "booth", "outlet"])
    pelanggan = _pick_first(payload, ["pelanggan", "customer", "customer_name", "nama_pelanggan"]) or _pick_first_from_items(
        items, ["pelanggan", "customer", "customer_name", "nama_pelanggan"]
    )
    if not meja:
        meja = _pick_from_lines(lines, ["meja", "table", "table name", "nomor meja"])
    if not waiter:
        waiter = _pick_from_lines(lines, ["waiter", "nama waiter", "server", "kasir"])
    if not pelanggan:
        pelanggan = _pick_from_lines(lines, ["pelanggan", "nama pelanggan", "customer", "customer name"])

    escpos_print_receipt(
        printer_key=printer,
        title=title,
        meja=meja,
        waiter=waiter,
        stand=stand,
        pelanggan=pelanggan,
        items=items,
        lines=lines,
        order_type=order_type,
    )
    return True


def on_open(ws):
    print("[WS] Connected. Subscribing channel...")
    ws.send(json.dumps({
        "event": "pusher:subscribe",
        "data": {"channel": CHANNEL_NAME}
    }))


def _decode_compressed_payload(data: Dict[str, Any]) -> Dict[str, Any]:
    """
    Decode payload kompresi dari Laravel:
    {
      "_compressed": 1,
      "_encoding": "gzip+base64+json",
      "_payload": "<base64>"
    }
    """
    if not isinstance(data, dict):
        return data

    if not data.get("_compressed"):
        return data

    payload_b64 = data.get("_payload")
    if not isinstance(payload_b64, str) or not payload_b64:
        return data

    try:
        raw = base64.b64decode(payload_b64)
        unzipped = gzip.decompress(raw)
        decoded = json.loads(unzipped.decode("utf-8"))
        if isinstance(decoded, dict):
            return decoded
    except Exception as e:
        print("[WS] Decode compressed payload gagal:", e)

    return data


def on_message(ws, message):
    try:
        msg = json.loads(message)
    except Exception:
        print("[WS] Non-JSON:", message)
        return

    ev = msg.get("event")
    data = msg.get("data")

    if ev == "pusher:connection_established":
        info = json.loads(data)
        print(f"[WS] connection_established socket_id={info.get('socket_id')}")
        return

    if ev == "pusher_internal:subscription_succeeded":
        print(f"[WS] subscribed ok to {CHANNEL_NAME}")
        return

    if ev != EVENT_NAME:
        return

    if isinstance(data, str):
        try:
            data = json.loads(data)
        except Exception:
            pass

    if isinstance(data, dict):
        data = _decode_compressed_payload(data)

    required_token = get_agent_token()
    incoming_token = data.get("agent_token") or data.get("token")
    if required_token and incoming_token and incoming_token != required_token:
        print("[SECURITY] Token mismatch. Print ditolak.")
        return

    try:
        print_payload_with_stands(data)
        print("[PRINT] OK")
    except Exception as e:
        print("[PRINT] FAILED:", e)
        import traceback
        traceback.print_exc()


def on_error(ws, error):
    print("[WS] Error:", error)


def on_close(ws, code, msg):
    print(f"[WS] Closed: code={code} msg={msg}")


def build_ws_url():
    qs = urlencode({
        "protocol": "7",
        "client": "python",
        "version": "1.0",
        "flash": "false"
    })
    return f"wss://ws-{PUSHER_CLUSTER}.pusher.com/app/{PUSHER_APP_KEY}?{qs}"


def main():
    url = build_ws_url()
    print("[INIT] URL:", url)
    print("[INIT] Channel:", CHANNEL_NAME, "Event:", EVENT_NAME)
    print("[INIT] Config:", CONFIG_PATH)

    while True:
        try:
            ws = websocket.WebSocketApp(
                url,
                on_open=on_open,
                on_message=on_message,
                on_error=on_error,
                on_close=on_close,
            )
            ws.run_forever(sslopt={"cert_reqs": ssl.CERT_REQUIRED})
        except KeyboardInterrupt:
            print("Bye.")
            break
        except Exception as e:
            print("[WS] Crash, reconnect in 3s:", e)
            time.sleep(3)


if __name__ == "__main__":
    main()
