"""
shop_scanner.py — ShopScanner mode (hot-reloaded by control.run_session).

Walks/roams a city, reads OPEN private-shop windows via OCR, and accumulates a
price database (shop_db.json) of item -> observed prices over time.

Design notes
------------
* The shop WINDOW has a FIXED SIZE in Flyff and opens at a stable (remembered)
  screen position, so we read it from a calibrated absolute-pixel box, SHOP_REGION.
  Calibrate it once in the ShopScanner panel (open any shop, line the box up).
* All grid geometry below is measured RELATIVE to SHOP_REGION's top-left, so once
  the box is aligned the cell layout follows automatically.
* The scan loop PASSIVELY records any shop you open (robust). Optional auto-roam
  tries to find [Vendor] tags, click them open, record, and move on — best-effort,
  needs tuning on your resolution.

Validated against a real shop screenshot: equipment names + prices read reliably;
small/stylised consumable text can misread, so the DB filters implausible rows and
aggregates ranges so outliers wash out.
"""
import os
import re
import json
import time
import cv2
import numpy as np
import grind_local as gl                       # reuse grab/viewport/roam + tesseract availability

# ── shop-window geometry ──────────────────────────────────────────────────────────
# Flyff's UI is FIXED-PIXEL (it does NOT scale with the browser window — proven: the HP bar is
# 24px wide at both 1917px and 935px viewports). The shop window is therefore a constant pixel
# size, but it OPENS CENTERED so its screen POSITION moves with the window size. So we LOCATE the
# window every frame by template-matching a fixed UI element (the 'Sale List | Chat | Message'
# row), then apply these fixed offsets relative to the window's top-left. This is independent of
# the browser window size.
WIN_W, WIN_H = 436, 472                         # shop-window crop size (px), from the window top-left
ANCHOR_OFF = (5, 54)                            # the anchor template's top-left, relative to window TL
ANCHOR_THRESH = 0.58                            # min match score to consider a shop "open"
TITLE_BOX = (95, 3, 345, 22)                    # "<Seller>'s Private Shop" — the dedupe key
SHOPNAME_BOX = (143, 28, 362, 50)               # the seller's custom shop name (free text)
ICON_BANDS = [(6, 40), (224, 258)]              # x-range of the item icon, per column
TEXT_BANDS = [(48, 208), (266, 426)]            # x-range of the name+price text, per column
GRID_Y0 = 86                                    # grid starts below the Sale-List header
ROW_MIN_H = 10                                  # min icon height to count as a cell

_ANCHOR = cv2.imread(os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                  "templates", "shop_anchor.png"))

# ── tesseract (shared availability flag with grind_local) ─────────────────────────
OCR_OK = gl.OCR_OK
try:
    import pytesseract as _pt
except Exception:
    _pt = None
    OCR_OK = False

DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shop_db.json")


# ── locate the shop window (template match — size/position independent) ────────────
def locate_window(img):
    """Find the open shop window by matching the fixed 'Sale List | Chat | Message' anchor.
    Returns (crop, x0, y0, confidence) or None if no shop window is visible."""
    if _ANCHOR is None or img is None:
        return None
    th, tw = _ANCHOR.shape[:2]
    if img.shape[0] < th or img.shape[1] < tw:
        return None
    res = cv2.matchTemplate(img, _ANCHOR, cv2.TM_CCOEFF_NORMED)
    _, maxv, _, maxloc = cv2.minMaxLoc(res)
    if maxv < ANCHOR_THRESH:
        return None
    h, w = img.shape[:2]
    x0 = max(0, maxloc[0] - ANCHOR_OFF[0])
    y0 = max(0, maxloc[1] - ANCHOR_OFF[1])
    crop = img[y0:min(h, y0 + WIN_H), x0:min(w, x0 + WIN_W)]
    return crop, x0, y0, float(maxv)


# ── OCR helpers ───────────────────────────────────────────────────────────────────
# Names are WHITE; prices are COLOURED. Isolating WHITE-ONLY for names removes the colourful icon
# bleed that used to produce leading junk ("i Kanarin Boots"). Tesseract drops inter-word spaces at
# this small size, so names come out concatenated ("KanarinBoots") — that's fine and CONSISTENT as a
# DB key. We crop to the text's vertical extent, pad, and upscale 5x.
_NAME_WL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 +()"
_NAME_CFG = f"--psm 7 -c preserve_interword_spaces=1 -c tessedit_char_whitelist={_NAME_WL}"
_PRICE_CFG = "--psm 7 -c tessedit_char_whitelist=0123456789,"


def _prep(region, white, scale=5):
    """Binarise text on black, crop to its vertical extent, pad + upscale. white=True isolates
    WHITE text (item names); white=False isolates any BRIGHT text (coloured prices)."""
    if region is None or region.size == 0:
        return None
    hsv = cv2.cvtColor(region, cv2.COLOR_BGR2HSV)
    s, v = hsv[:, :, 1], hsv[:, :, 2]
    m = (((v >= 150) & (s <= 85)) if white else (v >= 128)).astype(np.uint8) * 255
    rows = np.where(m.sum(axis=1) > 0)[0]
    if len(rows) < 2:
        return None
    m = m[rows.min():rows.max() + 1, :]
    m = cv2.copyMakeBorder(m, 12, 12, 14, 14, cv2.BORDER_CONSTANT, value=0)
    return cv2.bitwise_not(cv2.resize(m, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC))


def _ocr(region, cfg, white):
    if not OCR_OK or _pt is None:
        return ""
    p = _prep(region, white)
    if p is None:
        return ""
    try:
        return _pt.image_to_string(p, config=cfg).strip()
    except Exception:
        return ""


def _clean_name(s):
    s = s.replace("|", "").strip()
    s = re.sub(r"^[^A-Za-z]+", "", s)                  # strip any leading junk
    s = re.sub(r"[^A-Za-z0-9)+]+$", "", s)             # strip trailing junk
    return re.sub(r"\s+", " ", s).strip()


def _parse_price(s):
    d = re.sub(r"[^0-9]", "", s or "")
    return int(d) if d else None


def _plausible(name, price):
    """Filter obvious OCR garbage so it never poisons the DB."""
    if price is None or price <= 0 or price > 2_000_000_000:
        return False
    if not name or len(name) < 3:
        return False
    letters = sum(c.isalpha() for c in name)
    if letters < 3 or letters < len(name) * 0.5:       # mostly letters
        return False
    if not re.search(r"[aeiouAEIOU]", name):           # a real name has a vowel
        return False
    if not re.search(r"[a-z]", name):                  # Flyff item names are Title Case; all-caps
        return False                                   # reads ('OUGDENY') are OCR garbage
    return True


# ── shop window read ──────────────────────────────────────────────────────────────
def shop_open(img):
    """True if a private-shop window is visible anywhere on screen."""
    return locate_window(img) is not None


def _seller_name(crop):
    tx0, ty0, tx1, ty1 = TITLE_BOX
    t = _ocr(crop[ty0:ty1, tx0:tx1], _NAME_CFG, white=True)
    t = re.sub(r"s?Private\s*Shop.*$", "", t, flags=re.I).strip()   # "<Seller>'s Private Shop"
    m = re.match(r"\s*([A-Za-z0-9]+)", t)
    return m.group(1) if m else (t or None)


def parse_shop(img):
    """Read an open shop. Returns (items, seller, shop_name) where items = [(name, price), ...].
    Returns ([], None, None) when no shop window is detected."""
    loc = locate_window(img)
    if loc is None:
        return [], None, None
    crop = loc[0]
    hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
    S, V = hsv[:, :, 1], hsv[:, :, 2]
    H = crop.shape[0]

    # detect cell rows by the colourful item icons (robust to row drift / empty cells)
    band = np.zeros(H, np.float32)
    for (x0, x1) in ICON_BANDS:
        band += ((S[:, x0:x1] > 80) & (V[:, x0:x1] > 60)).sum(axis=1)
    band[:GRID_Y0] = 0
    on = band > 6
    centers = []
    y = GRID_Y0
    while y < H:
        if on[y]:
            y0 = y
            while y < H and on[y]:
                y += 1
            if y - y0 >= ROW_MIN_H:
                centers.append((y0 + y) // 2)
        else:
            y += 1
    merged = []                                         # merge a split icon (two bands) into one cell
    for c in centers:
        if merged and c - merged[-1] < 18:
            merged[-1] = (merged[-1] + c) // 2
        else:
            merged.append(c)

    items = []
    for cy in merged:
        for ci, (tx0, tx1) in enumerate(TEXT_BANDS):
            ix0, ix1 = ICON_BANDS[ci]
            if ((S[max(0, cy-12):cy+12, ix0:ix1] > 80) & (V[max(0, cy-12):cy+12, ix0:ix1] > 60)).sum() < 40:
                continue                                # no icon in this column here
            name = _clean_name(_ocr(crop[cy-18:cy+2, tx0:tx1], _NAME_CFG, white=True))
            price = _parse_price(_ocr(crop[cy:cy+18, tx0:tx1], _PRICE_CFG, white=False))
            if _plausible(name, price):
                items.append((name, price))
    seller = _seller_name(crop)
    sx0, sy0, sx1, sy1 = SHOPNAME_BOX
    shop_name = _ocr(crop[sy0:sy1, sx0:sx1], _NAME_CFG, white=True).strip() or None
    return items, seller, shop_name


# ── price database ────────────────────────────────────────────────────────────────
def _today():
    return time.strftime("%Y-%m-%d")


def load_db():
    try:
        with open(DB_PATH) as f:
            return json.load(f)
    except Exception:
        return {"items": {}, "updated": None}


def save_db(db):
    db["updated"] = _today()
    tmp = DB_PATH + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(db, f, indent=1, ensure_ascii=False)
    os.replace(tmp, DB_PATH)


def db_rows():
    """Sorted rows for the panel table: [name, min, median, max, count, last_seen]."""
    db = load_db()
    rows = [[name, e.get("min"), e.get("median"), e.get("max"), e.get("count"), e.get("last_seen")]
            for name, e in db.get("items", {}).items()]
    rows.sort(key=lambda r: r[0].lower())
    return rows


def item_history(name, limit=200):
    """One item's observations, newest first: {obs:[[price,date,seller],...], min, median, max, count}."""
    e = load_db().get("items", {}).get(name)
    if not e:
        return None
    obs = sorted(e.get("obs", [])[-limit:], key=lambda o: o[1], reverse=True)
    return {"obs": obs, "min": e.get("min"), "median": e.get("median"),
            "max": e.get("max"), "count": e.get("count")}


def db_stats():
    """(item_count, total_observations, updated_date)."""
    items = load_db().get("items", {})
    total = sum(e.get("count", 0) for e in items.values())
    return len(items), total, load_db().get("updated")


def export_csv(path=None):
    """Write the whole DB to a CSV file. Returns the path."""
    import csv
    path = path or os.path.join(os.path.dirname(os.path.abspath(__file__)), "shop_db.csv")
    db = load_db()
    with open(path, "w", newline="", encoding="utf-8") as f:
        wr = csv.writer(f)
        wr.writerow(["item", "min", "median", "max", "count", "last_seen"])
        for name, e in sorted(db.get("items", {}).items(), key=lambda kv: kv[0].lower()):
            wr.writerow([name, e.get("min"), e.get("median"), e.get("max"), e.get("count"), e.get("last_seen")])
    return path


def record_shop(items, seller, shop_name):
    """Append observations to the DB and recompute per-item ranges. Returns items recorded."""
    db = load_db()
    items_db = db.setdefault("items", {})
    day = _today()
    seller = seller or "?"
    n = 0
    for name, price in items:
        e = items_db.setdefault(name, {"obs": [], "min": None, "max": None, "median": None,
                                       "count": 0, "last_seen": None})
        e["obs"].append([int(price), day, seller])
        e["obs"] = e["obs"][-300:]                      # cap history per item
        prices = sorted(o[0] for o in e["obs"])
        e["min"], e["max"] = prices[0], prices[-1]
        e["median"] = prices[len(prices) // 2]
        e["count"] = len(prices)
        e["last_seen"] = day
        n += 1
    save_db(db)
    return n


# ── vendor detection (auto-roam, best-effort) ─────────────────────────────────────
# "[Vendor]/[Händler] Name" tags are light BLUE (OpenCV hue ~110-140, NOT cyan). We detect them and
# click just below to walk to + open the shop. (Some blue player names also match — harmless: a
# non-vendor click just won't open a shop and we move on.)
VENDOR_H = (106, 140)    # light-blue hue range (OpenCV 0-180)
VENDOR_SMIN = 80
VENDOR_VMIN = 140
VENDOR_DROP = 26         # px below the tag to click (the vendor character)
VENDOR_MIN_W = 40        # ignore tiny/far tags — only approach close-enough vendors (less wandering)


def find_vendor_tags(img):
    """Click points (x, y, width) just below blue vendor name tags, NEAREST-first (biggest = closest).
    Only tags wider than VENDOR_MIN_W (i.e. close enough to approach without a long walk)."""
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    H, S, V = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]
    mask = ((H >= VENDOR_H[0]) & (H <= VENDOR_H[1]) & (S >= VENDOR_SMIN) & (V >= VENDOR_VMIN)).astype(np.uint8) * 255
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((3, 15), np.uint8))
    # keep the central play area (skip HUD / minimap / our own UI)
    mask[:, :int(0.04*w)] = 0; mask[:, int(0.78*w):] = 0
    mask[:int(0.04*h), :] = 0; mask[int(0.88*h):, :] = 0
    cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    out = []
    for c in cnts:
        x, y, ww, hh = cv2.boundingRect(c)
        if ww < VENDOR_MIN_W or hh < 6 or ww > 340:
            continue
        out.append((x + ww // 2, y + hh + VENDOR_DROP, ww))
    out.sort(key=lambda t: -t[2])                      # widest (nearest) first
    return out


def dump_region(img, outdir):
    """Save a preview of the auto-DETECTED shop window (box + confidence) for the panel. Returns
    (path, shop_is_open, item_count, seller, confidence)."""
    os.makedirs(outdir, exist_ok=True)
    loc = locate_window(img)
    path = os.path.join(outdir, "shop_region.png")
    if loc is None:                                    # no shop — show a downscaled full frame
        small = cv2.resize(img, None, fx=0.4, fy=0.4, interpolation=cv2.INTER_AREA)
        cv2.imwrite(path, small)
        return path, False, 0, None, 0.0
    _, x0, y0, conf = loc
    h, w = img.shape[:2]
    cx0, cy0 = max(0, x0 - 8), max(0, y0 - 8)
    cx1, cy1 = min(w, x0 + WIN_W + 8), min(h, y0 + WIN_H + 8)
    vis = img[cy0:cy1, cx0:cx1].copy()
    cv2.rectangle(vis, (x0 - cx0, y0 - cy0), (x0 - cx0 + WIN_W, y0 - cy0 + WIN_H), (0, 255, 255), 2)
    items, seller, _ = parse_shop(img)
    cv2.imwrite(path, vis)
    return path, True, len(items), seller, conf


def close_shop(page):
    """Close an open shop window. Escape does NOT close private shops — a short tap of 'w'
    (a step forward) dismisses the shop reliably (and nudges us toward fresh ground)."""
    try:
        page.keyboard.down("w")
        time.sleep(0.08)
        page.keyboard.up("w")
        time.sleep(0.2)
    except Exception:
        pass


def _step_forward(page, secs=0.45):
    """A small step forward (W) to explore when nothing's in view."""
    try:
        page.keyboard.down("w"); time.sleep(secs); page.keyboard.up("w")
    except Exception:
        pass


def _wait_for_shop(page, timeout, should_stop):
    """Poll for a shop window to appear (covers the walk to a clicked vendor). True if it opened."""
    t0 = time.time()
    while time.time() - t0 < timeout:
        if should_stop and should_stop():
            return False
        if shop_open(gl.grab(page)):
            return True
        time.sleep(0.4)
    return False


# ── auto-roam tuning ──────────────────────────────────────────────────────────────
CALIB_EVERY = 120.0       # re-run calibrate_view this often (consistent zoom-out + top-down view)
OPEN_WAIT = 3.2           # seconds to wait for a clicked vendor's shop to open (covers walking there)
TURNS_PER_SWEEP = 4       # camera turns (≈ full circle) with nothing found before a small step


# ── main loop ─────────────────────────────────────────────────────────────────────
def scan(page, w, h, should_stop=None, report=None, auto_roam=False):
    """Catalog open shops into shop_db.json.
    PASSIVE (auto_roam=False): records any shop YOU open.
    AUTO-ROAM: resets the camera (calibrate_view), looks around (rotate_view), walks to the NEAREST
    detected vendor tag and opens it. It only walks toward DETECTED vendors (never blind-clicks
    ground), so it stays among the shops instead of wandering off."""
    seen = set()                                   # sellers recorded this session (dedupe)
    recorded = 0
    items_total = 0
    last_calib = 0.0
    idle_turns = 0

    def status(msg=None):
        if report:
            report(msg or f"scanning · shops {recorded} · items {items_total} · DB {len(load_db().get('items', {}))}")

    if not OCR_OK:
        status("OCR (Tesseract) not available — ShopScanner needs it. Run install.bat.")
        return 0
    if auto_roam:
        status("calibrating view…")
        try:
            gl.calibrate_view(page)
        except Exception:
            pass
        last_calib = time.time()
    status()
    while True:
        if should_stop and should_stop():
            return recorded
        img = gl.grab(page)

        # 1) a shop is open → record it (if new), then move on
        items, seller, shop_name = parse_shop(img)
        if items and seller:
            if seller not in seen:
                n = record_shop(items, seller, shop_name)
                seen.add(seller)
                recorded += 1
                items_total += n
                status(f"recorded {n} from {seller} ({shop_name or '—'}) · shops {recorded}")
                print(f"[shop ] {seller}: {n} items (\"{shop_name}\")")
            if auto_roam:
                close_shop(page)               # leave (also when already-seen)
                gl.rotate_view(page, w, h)     # turn away so we don't re-open the same one
                idle_turns = 0
            else:
                time.sleep(1.0)                # let the user move to the next shop
            continue

        if not auto_roam:
            time.sleep(0.6)
            continue

        # 2) AUTO-ROAM — find + approach vendors
        if time.time() - last_calib > CALIB_EVERY:     # periodic consistent view
            status("recalibrating view…")
            try:
                gl.calibrate_view(page)
            except Exception:
                pass
            last_calib = time.time(); idle_turns = 0
            continue

        tags = find_vendor_tags(img)                   # nearest-first (x,y,width)
        if tags:
            vx, vy, _ = tags[0]
            status(f"approaching a shop… · shops {recorded}")
            page.mouse.click(vx, vy)                    # walk to + open
            if _wait_for_shop(page, OPEN_WAIT, should_stop):
                idle_turns = 0
                continue                                # parsed at the top next loop
            close_shop(page)                            # didn't open → cancel, look elsewhere
            gl.rotate_view(page, w, h)
            idle_turns = 0
        else:
            status(f"looking for shops… · shops {recorded}")
            gl.rotate_view(page, w, h)                  # no vendor in view → turn to look around
            idle_turns += 1
            if idle_turns >= TURNS_PER_SWEEP:           # full sweep, nothing → small step + re-look
                idle_turns = 0
                _step_forward(page)
        time.sleep(0.2)
