from __future__ import annotations

import html
import json as _json
import logging
import os
import re
import threading
import time as _time
from datetime import date, timedelta
from typing import Any

import requests

from .config import BotConfig

logger = logging.getLogger(__name__)

_TOKEN_LOCK = threading.Lock()
_TOKEN_CACHE_FILE = os.path.join(
    os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
    "storage", "app", "gpos_token.json",
)
_TOKEN_TTL_SECONDS = 12 * 3600  # 12 jam sesuai rekomendasi user


class GPOSApiClient:
    """Client GPOS berbasis request HTTP langsung.

    Flow login (terverifikasi dari HAR 26 April 2026):
    - GPOS web frontend memanggil DUA endpoint login secara berurutan:
        1. POST /api/v1/login          → untuk akun tertentu mengembalikan 4004 (normal)
        2. POST /api/v1/login-by-name  → mengembalikan bearer token (1529 bytes, sama dengan checklogin)
    - Bot mengikuti pola yang sama: coba login path yang dikonfigurasi, fallback ke /api/v1/login-by-name.
    - Bearer token ada di: data.bearer.token
    - Pencarian item: POST /api/v1/item/datatable (form-urlencoded DataTables)
    """

    def __init__(self, config: BotConfig) -> None:
        self._base_url = config.gpos_base_url
        self._sales_base_url = config.gpos_sales_base_url
        self._sales_report_path = config.gpos_sales_report_path
        self._purchase_report_path = config.gpos_purchase_report_path
        self._username = config.gpos_username
        self._password = config.gpos_password
        self._login_path = config.gpos_login_path
        self._stock_path = config.gpos_stock_path
        self._timeout = config.gpos_timeout_seconds
        self._token: str | None = None
        self._session = requests.Session()
        self._session.headers.update({
            "Accept": "application/json, text/plain, */*",
            "Origin": self._base_url,
            "Referer": f"{self._base_url}/item",
            "User-Agent": "DeltaWarasBot/1.0",
            "X-Requested-With": "XMLHttpRequest",
        })

    def is_configured(self) -> bool:
        return bool(self._base_url and self._username and self._password)

    def search_items(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
        result = self.search_items_page(query, limit=limit, start=0)
        return result["items"]

    def search_items_page(self, query: str, limit: int = 5, start: int = 0) -> dict[str, Any]:
        self._ensure_token()
        safe_limit = max(1, min(limit, 100))
        safe_start = max(0, start)
        return self._do_search(query.strip(), limit=safe_limit, start=safe_start)

    def search_stock(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
        return self.search_items(query, limit=limit)

    def search_price(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
        return self.search_items(query, limit=limit)

    def get_item_options(self, query: str) -> list[dict[str, Any]]:
        """Cari item via DataTables API (mini4.gpos.id).

        Endpoint /api/items/options di account.gpos.id memerlukan auth NextAuth
        yang tidak tersedia untuk bot headless. Gunakan DataTables sebagai gantinya.
        """
        if not query.strip():
            return []
        try:
            page = self.search_items_page(query.strip(), limit=10, start=0)
            return page.get("items") or []
        except Exception:
            logger.warning("GPOS get_item_options gagal untuk '%s'", query, exc_info=True)
            return []

    def search_items_quick_modal(
        self,
        query: str,
        location_id: int = 1801,
        limit: int = 10,
    ) -> list[dict[str, Any]]:
        """Cari item via quick-modal endpoint GPOS — TOLERAN tanda hubung.

        Endpoint ini menerima JSON body dan melakukan pencarian fuzzy termasuk
        melewati karakter '-' dalam nama produk.
        Contoh: "nature" → menemukan "NATUR-E", "dexam" → menemukan "DEXA-M".

        DEPRECATED per 3 Mei 2026: endpoint telah dihapus GPOS (Next.js redeploy).
        Gunakan search_items_page() sebagai gantinya.
        """
        self._ensure_token()
        url = f"{self._base_url}/api/v1/item/datatable-quick-modal-with-search-multi/{location_id}"
        payload = {
            "terms": query.strip(),
            "status": "1",
            "from_module": "sales",
        }
        resp = self._post_with_auth_retry(url, json=payload)
        data = resp.json()
        rows = data.get("data") or []
        results: list[dict[str, Any]] = []
        for row in rows:
            if not isinstance(row, list) or len(row) < 15:
                continue
            name = _strip_html(row[7]).strip()
            plu = _strip_html(row[4]).strip()
            barcode = _strip_html(row[6]).strip()
            category_name = _strip_html(row[19]).strip() if len(row) > 19 else ""
            rak = _strip_html(row[18]).strip() if len(row) > 18 else ""
            results.append({
                "item_id": _strip_html(row[3]).strip() if len(row) > 3 else "",
                "plu": plu,
                "barcode": barcode,
                "name": name,
                "sales_price": _parse_money(row[8]),
                "last_purchase_rate": _parse_money(row[9]),
                "last_purchase": _parse_money(row[10]),
                "profit_margin": _strip_html(row[11]).strip(),
                "stock": _strip_html(row[12]).strip(),
                "booked_stock": _strip_html(row[13]).strip(),
                "total_stock": _strip_html(row[14]).strip(),
                "unit_conversion": _strip_html(row[15]).strip() if len(row) > 15 else "",
                "rak": rak,
                "category_name": category_name,
                "status": _extract_status(row[20]) if len(row) > 20 else "",
            })

        logger.info(
            "GPOS quick-modal menemukan %d item untuk query '%s'",
            len(results),
            query,
        )
        return results[:limit]

    def get_purchase_history_by_item(
        self,
        item_id: str,
        item_name: str = "",
        location_id: int = 1801,
        limit: int = 1,
    ) -> list[dict[str, Any]]:
        self._ensure_token()
        today = date.today()
        url = f"{self._sales_base_url}{self._purchase_report_path}"
        payload = {
            "location_id": location_id,
            "vendor_id": "",
            "item_name": item_name or "",
            "item_id": item_id or 0,
            "start_date": (today - timedelta(days=365)).isoformat(),
            "end_date": today.isoformat(),
            "status": "2,3",
            "pay_status": "0",
            "limit": limit,
            "payment_type_id": "",
            "is_detail": "1",
            "flag": "",
            "page": 1,
        }
        resp = self._post_with_account_auth(url, json=payload)
        data = resp.json()
        return (data.get("data") or {}).get("items") or []

    def get_sales_history_by_item(
        self,
        item_id: str,
        item_name: str = "",
        location_id: int = 1801,
        limit: int = 1,
    ) -> list[dict[str, Any]]:
        self._ensure_token()
        today = date.today()
        url = f"{self._sales_base_url}{self._sales_report_path}"
        payload = {
            "location_id": location_id,
            "doctor_id": "",
            "customer_id": "",
            "customer_type_id": "",
            "cashier": "",
            "payment_type_id": "",
            "search_by": "transaction_no",
            "item_name": item_name or "",
            "item_id": item_id or 0,
            "start_date": (today - timedelta(days=365)).isoformat(),
            "end_date": today.isoformat(),
            "status": "2,3",
            "limit": limit,
            "is_detail": "1",
            "order_from": "ALL",
            "flag": "",
            "page": 1,
            "user_time": -420,
        }
        resp = self._post_with_account_auth(url, json=payload)
        data = resp.json()
        return (data.get("data") or {}).get("items") or []

    @classmethod
    def _read_cached_token(cls) -> tuple[str | None, float]:
        """Baca token dari file cache (shared antar Passenger workers).
        Return (token, expires_at_timestamp) atau (None, 0)."""
        try:
            if os.path.exists(_TOKEN_CACHE_FILE):
                with open(_TOKEN_CACHE_FILE, "r", encoding="utf-8") as f:
                    data = _json.load(f)
                token = data.get("token")
                expires = float(data.get("expires_at") or 0)
                if token and expires > _time.time():
                    return token, expires
        except Exception:
            logger.warning("Gagal membaca token cache GPOS", exc_info=True)
        return None, 0.0

    @staticmethod
    def _write_cached_token(token: str, ttl: int = _TOKEN_TTL_SECONDS) -> None:
        """Simpan token ke file cache (shared antar Passenger workers)."""
        try:
            os.makedirs(os.path.dirname(_TOKEN_CACHE_FILE), exist_ok=True)
            data = {"token": token, "expires_at": _time.time() + ttl}
            tmp_path = _TOKEN_CACHE_FILE + ".tmp"
            with open(tmp_path, "w", encoding="utf-8") as f:
                _json.dump(data, f)
            os.replace(tmp_path, _TOKEN_CACHE_FILE)
            logger.debug("Token GPOS disimpan ke cache, expires at %s", data["expires_at"])
        except Exception:
            logger.warning("Gagal menyimpan token cache GPOS", exc_info=True)

    def _ensure_token(self) -> None:
        # 1. Cek file cache dulu (shared antar Passenger workers)
        if not self._token:
            cached_token, _ = self._read_cached_token()
            if cached_token:
                self._token = cached_token
                self._session.headers.update({"Authorization": f"Bearer {cached_token}"})
                logger.debug("GPOS token dari file cache digunakan")

        # 2. Jika tetap tidak ada, login baru
        if not self._token:
            with _TOKEN_LOCK:
                if not self._token:
                    # Periksa ulang cache setelah mendapatkan lock
                    cached_token, _ = self._read_cached_token()
                    if cached_token:
                        self._token = cached_token
                        self._session.headers.update({"Authorization": f"Bearer {cached_token}"})
                        logger.debug("GPOS token dari file cache digunakan (setelah lock)")
                    else:
                        self._login()

    def _login(self) -> None:
        payload = {
            "email": self._username,
            "password": self._password,
            "remember": "1",
        }

        # GPOS web frontend selalu memanggil DUA endpoint login secara berurutan.
        # /api/v1/login bisa mengembalikan 4004 untuk akun tertentu, lalu frontend
        # otomatis fallback ke /api/v1/login-by-name yang memberikan bearer token.
        # Bot meniru pola yang sama agar robust terhadap perbedaan akun.
        paths_to_try: list[str] = [self._login_path]
        if self._login_path != "/api/v1/login-by-name":
            paths_to_try.append("/api/v1/login-by-name")

        last_error: str = "tidak ada endpoint yang dicoba"

        for path in paths_to_try:
            url = f"{self._base_url}{path}"
            try:
                response = self._session.post(url, json=payload, timeout=self._timeout)
                response.raise_for_status()
            except requests.HTTPError as exc:
                last_error = (
                    f"HTTP {exc.response.status_code} dari {path}: {exc.response.text[:200]}"
                )
                logger.debug("GPOS login via %s gagal HTTP: %s", path, last_error)
                continue
            except requests.RequestException as exc:
                raise RuntimeError(f"GPOS tidak dapat dihubungi: {exc}") from exc

            data = response.json()
            token = (
                (data.get("data") or {}).get("bearer", {}).get("token")
                or (data.get("bearer") or {}).get("token")
                or (data.get("data") or {}).get("token")
                or data.get("token")
            )

            if token:
                self._token = token
                self._session.headers.update({"Authorization": f"Bearer {token}"})
                logger.info("GPOS login berhasil via %s untuk user %s", path, self._username)
                self._write_cached_token(token)
                return

            # Endpoint mengembalikan 200 tapi tanpa token (mis. code 4004 untuk akun ini)
            code = data.get("code", "")
            last_error = f"code={code} dari {path}: {str(data)[:150]}"
            logger.debug("GPOS %s tidak memberikan token: %s", path, last_error)

        raise RuntimeError(f"Login GPOS gagal setelah semua endpoint dicoba. Terakhir: {last_error}")

    def _refresh_token(self) -> None:
        self._token = None
        self._session.headers.pop("Authorization", None)
        # Hapus file cache agar worker lain juga login ulang
        try:
            if os.path.exists(_TOKEN_CACHE_FILE):
                os.remove(_TOKEN_CACHE_FILE)
        except Exception:
            pass
        self._login()

    def _do_search(self, query: str, limit: int, start: int) -> dict[str, Any]:
        url = f"{self._base_url}{self._stock_path}"
        payload = self._datatable_payload(query, limit, start)
        response = self._post_with_auth_retry(url, data=payload, content_type="application/x-www-form-urlencoded; charset=UTF-8")
        return self._parse_datatable_response(response.json(), query, start, limit)

    def _post_with_account_auth(
        self,
        url: str,
        data: Any = None,
        json: Any = None,
        content_type: str = "application/json; charset=UTF-8",
    ) -> requests.Response:
        """POST ke account.gpos.id dengan auth fallback.

        account.gpos.id menggunakan NextAuth session (cookie-based), bukan Bearer token.
        Jika call gagal karena auth, kita coba dapatkan NextAuth session melalui
        mekanisme iframe token exchange dari mini4.
        """
        headers = {"Content-Type": content_type}

        def _is_html(resp: requests.Response) -> bool:
            ct = (resp.headers.get("Content-Type") or "").lower()
            return "text/html" in ct or (resp.text or "").lstrip().startswith("<!DOCTYPE")

        def _post() -> requests.Response:
            return self._session.post(
                url, data=data, json=json, headers=headers,
                timeout=self._timeout, allow_redirects=False,
            )

        # Coba dengan token mini4 yang sudah ada
        resp = _post()

        # Jika auth gagal (401/403 atau JSON {"code":"4000"}), coba refresh
        try:
            body = resp.json()
            if isinstance(body, dict) and body.get("code") == "4000":
                logger.warning("GPOS account auth gagal (code 4000), coba dapatkan NextAuth session...")
                self._acquire_account_session()
                resp = _post()
        except (ValueError, _json.JSONDecodeError):
            if resp.status_code in {401, 403, 301, 302, 303, 307, 308} or _is_html(resp):
                self._acquire_account_session()
                resp = _post()

        # Jika masih HTML setelah auth → error
        if _is_html(resp):
            raise RuntimeError(
                f"GPOS account mengembalikan HTML (status {resp.status_code}): {resp.text[:200]}"
            )

        try:
            resp.raise_for_status()
        except requests.HTTPError as exc:
            raise RuntimeError(
                f"GPOS account HTTP {exc.response.status_code} dari {url}: {exc.response.text[:200]}"
            ) from exc
        except requests.RequestException as exc:
            raise RuntimeError(f"GPOS account tidak dapat dihubungi ({url}): {exc}") from exc

        return resp

    def _acquire_account_session(self) -> None:
        """Dapatkan NextAuth session untuk account.gpos.id via iframe token exchange.

        Mekanisme: mini4 menghasilkan token khusus untuk cross-domain auth,
        lalu account.gpos.id/iframe memvalidasi token dan membuat NextAuth session.
        Token ini berbeda dengan Bearer token dari login-by-name.
        """
        try:
            # Coba dapatkan session via iframe endpoint dengan goapotik token
            login_url = f"{self._base_url}/api/v1/login-by-name"
            resp = self._session.post(
                login_url,
                json={"email": self._username, "password": self._password, "remember": "1"},
                timeout=self._timeout,
            )
            resp.raise_for_status()
            login_data = resp.json()

            goapotik_token = (
                (login_data.get("data") or {}).get("user") or {}
            ).get("goapotik_token") or ""

            if goapotik_token:
                # Panggil iframe endpoint untuk membuat NextAuth session
                iframe_url = (
                    f"{self._sales_base_url}/iframe"
                    f"?token={goapotik_token}"
                    f"&pathUrl=/sales/sales-invoice"
                )
                self._session.get(
                    iframe_url,
                    timeout=self._timeout,
                    allow_redirects=True,
                )
                logger.info("GPOS account session acquisition attempted via iframe")
        except Exception:
            logger.warning("Gagal mendapatkan GPOS account session", exc_info=True)

    def _post_with_auth_retry(
        self,
        url: str,
        data: Any = None,
        json: Any = None,
        content_type: str = "application/json; charset=UTF-8",
    ) -> requests.Response:
        """POST dengan retry auth: detect redirect/HTML sebelum raise_for_status."""
        headers = {"Content-Type": content_type}

        def _is_html(resp: requests.Response) -> bool:
            ct = (resp.headers.get("Content-Type") or "").lower()
            return "text/html" in ct or (resp.text or "").lstrip().startswith("<!DOCTYPE")

        def _post() -> requests.Response:
            return self._session.post(
                url, data=data, json=json, headers=headers,
                timeout=self._timeout, allow_redirects=False,
            )

        response = _post()

        # Auth failure via 401/403
        if response.status_code in {401, 403}:
            with _TOKEN_LOCK:
                self._refresh_token()
            response = _post()

        # Auth failure via redirect (302) atau HTML (Next.js 404 page)
        if response.status_code in {301, 302, 303, 307, 308} or _is_html(response):
            logger.warning("GPOS redirect/HTML terdeteksi (kemungkinan auth expired), refresh token...")
            with _TOKEN_LOCK:
                self._refresh_token()
            response = _post()

        # Jika masih HTML setelah refresh token → error
        if _is_html(response):
            raise RuntimeError(
                f"GPOS mengembalikan HTML (status {response.status_code}): {response.text[:200]}"
            )

        try:
            response.raise_for_status()
        except requests.HTTPError as exc:
            raise RuntimeError(
                f"GPOS HTTP {exc.response.status_code} dari {url}: {exc.response.text[:200]}"
            ) from exc
        except requests.RequestException as exc:
            raise RuntimeError(f"GPOS tidak dapat dihubungi ({url}): {exc}") from exc

        return response

    def _datatable_payload(self, query: str, limit: int, start: int) -> dict[str, Any]:
        columns = [
            "checkbox", "action", "status_sync", "id", "item_code", "item_type",
            "barcode", "item_name", "sales_price", "last_purchase_rate",
            "last_purchase", "profit_margin", "stock", "booked_stock",
            "total_stock", "unit_conversion", "min_max_stock", "is_consignment",
            "rak", "category_name", "status", "online_sku",
        ]
        payload: dict[str, Any] = {
            "draw": 1,
            "start": start,
            "length": limit,
            "search[value]": query,
            "search[regex]": "false",
        }
        for index, name in enumerate(columns):
            payload[f"columns[{index}][data]"] = index
            payload[f"columns[{index}][name]"] = name
            payload[f"columns[{index}][searchable]"] = "true"
            payload[f"columns[{index}][orderable]"] = "true" if name not in {"checkbox", "action", "status_sync"} else "false"
            payload[f"columns[{index}][search][value]"] = ""
            payload[f"columns[{index}][search][regex]"] = "false"
        return payload

    def _parse_datatable_response(
        self,
        payload: dict[str, Any],
        query: str,
        start: int,
        limit: int,
    ) -> dict[str, Any]:
        rows = payload.get("data") or []
        results: list[dict[str, Any]] = []

        for row in rows:
            if not isinstance(row, list) or len(row) < 15:
                continue

            name = _strip_html(row[7]).strip()
            plu = _strip_html(row[4]).strip()
            barcode = _strip_html(row[6]).strip()
            category_name = _strip_html(row[19]).strip() if len(row) > 19 else ""
            rak = _strip_html(row[18]).strip() if len(row) > 18 else ""
            results.append({
                "item_id": _strip_html(row[3]).strip() if len(row) > 3 else "",
                "plu": plu,
                "barcode": barcode,
                "name": name,
                "sales_price": _parse_money(row[8]),
                "last_purchase_rate": _parse_money(row[9]),
                "last_purchase": _parse_money(row[10]),
                "profit_margin": _strip_html(row[11]).strip(),
                "stock": _strip_html(row[12]).strip(),
                "booked_stock": _strip_html(row[13]).strip(),
                "total_stock": _strip_html(row[14]).strip(),
                "unit_conversion": _strip_html(row[15]).strip() if len(row) > 15 else "",
                "rak": rak,
                "category_name": category_name,
                "status": _extract_status(row[20]) if len(row) > 20 else "",
            })

        logger.info("GPOS menemukan %d item untuk query '%s'", len(results), query)
        return {
            "items": results,
            "records_total": int(payload.get("recordsTotal") or len(results)),
            "records_filtered": int(payload.get("recordsFiltered") or len(results)),
            "start": start,
            "limit": limit,
            "query": query,
        }


def _strip_html(value: Any) -> str:
    text = html.unescape(str(value or ""))
    text = re.sub(r"<br\s*/?>", " ", text, flags=re.IGNORECASE)
    text = re.sub(r"<div[^>]*>", " ", text, flags=re.IGNORECASE)
    text = re.sub(r"</div>", " ", text, flags=re.IGNORECASE)
    text = re.sub(r"<[^>]+>", "", text)
    return re.sub(r"\s+", " ", text).strip()


def _parse_money(value: Any) -> int:
    cleaned = re.sub(r"[^\d]", "", _strip_html(value))
    return int(cleaned) if cleaned else 0


def _extract_status(value: Any) -> str:
    if isinstance(value, dict):
        return str(value.get("status") or "")
    return _strip_html(value)
