#!/usr/bin/env python3
"""ProfitProphet / Wisdom — desktop market-data uploader.

WoW addons can't reach the network, so the ProfitProphet addon writes its scans to a
SavedVariables Lua file on disk. This tool reads that file and uploads the latest realm/faction
price snapshot to the Wisdom `ingest` Edge Function, tied to your account via an upload key.

Zero third-party dependencies — standard library only (incl. tkinter for the setup window), so
it runs anywhere Python 3.8+ does, offline, and packages into a single .exe with PyInstaller.

Double-click (or run with no arguments) → a small setup window. Or use the CLI:
    pp_upload setup --token ppw_xxx --region US [--sv PATH] [--endpoint URL]
    pp_upload run                 # one upload, using saved config
    pp_upload watch [--every 30]  # upload every N minutes (foreground loop)
    pp_upload install [--every 30]# set up background auto-sync at login + start it
    pp_upload uninstall           # remove the background auto-sync
Config is saved to ~/.ppwisdom/config.json. The upload token is your only secret — treat it
like a password; revoke/rotate it at wowprofitprophet.com/account.html.
"""
from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request

DEFAULT_ENDPOINT = "https://kfjgazidjwrziotygcbw.supabase.co/functions/v1/ingest"
APP_NAME = "PPWisdomUploader"
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".ppwisdom")
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")

# --------------------------------------------------------------------------- #
# Config
# --------------------------------------------------------------------------- #

def load_config() -> dict:
    # utf-8-sig tolerates a UTF-8 BOM. Notepad (and PowerShell's Set-Content -Encoding utf8) add one
    # if a user hand-edits the file, and plain "utf-8" then fails to parse, silently blanking the key.
    try:
        with open(CONFIG_PATH, "r", encoding="utf-8-sig") as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return {}


def save_config(cfg: dict) -> None:
    os.makedirs(CONFIG_DIR, exist_ok=True)
    with open(CONFIG_PATH, "w", encoding="utf-8") as fh:
        json.dump(cfg, fh, indent=2)
    try:
        os.chmod(CONFIG_PATH, 0o600)  # token lives here; keep it user-only where supported
    except OSError:
        pass


# --------------------------------------------------------------------------- #
# Minimal Lua-table parser for the WoW SavedVariables subset (no deps).
# --------------------------------------------------------------------------- #

class _LuaParser:
    def __init__(self, text: str):
        self.s = text
        self.i = 0
        self.n = len(text)

    def parse_assignment(self, name: str):
        idx = self.s.find(name)
        while idx != -1:
            before = self.s[:idx].rstrip()
            if before == "" or before[-1] in "\n;":
                self.i = idx + len(name)
                self._ws()
                if self._peek() == "=":
                    self.i += 1
                    self._ws()
                    return self._value()
            idx = self.s.find(name, idx + 1)
        raise ValueError(f"{name} not found in SavedVariables")

    def _peek(self):
        return self.s[self.i] if self.i < self.n else ""

    def _ws(self):
        while self.i < self.n:
            c = self.s[self.i]
            if c in " \t\r\n":
                self.i += 1
            elif c == "-" and self.s[self.i:self.i + 2] == "--":
                nl = self.s.find("\n", self.i)
                self.i = self.n if nl == -1 else nl + 1
            else:
                break

    def _value(self):
        self._ws()
        c = self._peek()
        if c == "{":
            return self._table()
        if c in "\"'":
            return self._string()
        if c == "[" and self.s[self.i:self.i + 2] == "[[":
            return self._long_string()
        return self._scalar()

    def _table(self):
        self.i += 1
        result, arr = {}, []
        while True:
            self._ws()
            c = self._peek()
            if c == "":
                raise ValueError("unterminated table")
            if c == "}":
                self.i += 1
                break
            if c in ",;":
                self.i += 1
                continue
            if c == "[":
                self.i += 1
                self._ws()
                key = self._string() if self._peek() in "\"'" else self._scalar()
                self._ws()
                if self._peek() == "]":
                    self.i += 1
                self._ws()
                if self._peek() == "=":
                    self.i += 1
                    result[key] = self._value()
                continue
            j = self.i
            while j < self.n and (self.s[j].isalnum() or self.s[j] == "_"):
                j += 1
            k = j
            while k < self.n and self.s[k] in " \t":
                k += 1
            if j > self.i and k < self.n and self.s[k] == "=" and self.s[k + 1:k + 2] != "=":
                key = self.s[self.i:j]
                self.i = k + 1
                result[key] = self._value()
            else:
                arr.append(self._value())
        if arr and not result:
            return arr
        if arr:
            for idx, v in enumerate(arr, 1):
                result.setdefault(idx, v)
        return result

    def _string(self):
        quote = self._peek()
        self.i += 1
        out = []
        while self.i < self.n:
            c = self.s[self.i]
            if c == "\\":
                nxt = self.s[self.i + 1:self.i + 2]
                out.append({"n": "\n", "t": "\t", "r": "\r"}.get(nxt, nxt))
                self.i += 2
                continue
            if c == quote:
                self.i += 1
                break
            out.append(c)
            self.i += 1
        return "".join(out)

    def _long_string(self):
        self.i += 2
        end = self.s.find("]]", self.i)
        end = self.n if end == -1 else end
        val = self.s[self.i:end]
        self.i = end + 2
        return val

    def _scalar(self):
        start = self.i
        while self.i < self.n and self.s[self.i] not in ",;}=] \t\r\n":
            self.i += 1
        tok = self.s[start:self.i]
        if tok == "true":
            return True
        if tok == "false":
            return False
        if tok == "nil":
            return None
        try:
            return int(tok)
        except ValueError:
            try:
                return float(tok)
            except ValueError:
                return tok


def parse_saved_variables(path: str) -> dict:
    with open(path, "r", encoding="utf-8", errors="replace") as fh:
        text = fh.read()
    return _LuaParser(text).parse_assignment("PROFITPROPHET_DB")


# WoW install folder → Wisdom flavor slug. The file's location tells us the game version,
# so the uploader auto-detects it (no user input needed).
FLAVOR_BY_FOLDER = {
    "_retail_": "retail",
    "_anniversary_": "anniversary",
    "_classic_era_": "era",
    "_classic_ptr_": "wrath",
    "_classic_": "wrath",
    "_ptr_": "retail",
}


def detect_flavor(sv_path: str | None) -> str:
    p = (sv_path or "").lower().replace("\\", "/")
    for folder, fl in FLAVOR_BY_FOLDER.items():
        if folder in p:
            return fl
    return "anniversary"


def select_realm_bucket(db: dict) -> tuple[dict, str, str]:
    """Return (bucket, realm, faction) for the realm whose market data we should upload.

    The addon scopes market data per realm (PP_Scope.lua, `scopeV: 2`):
    `D.realms["<Realm>|<Faction>"] = { latest = { items, t }, history, ... }`, with the active
    bucket named by `D.meta.boundRealmKey`. Older SavedVariables kept a single flat `D.latest`
    with realm/faction on `D.meta`. Read the scoped layout first and fall back to the flat one,
    so the uploader works against both.
    """
    realms = db.get("realms")
    meta = db.get("meta") or {}
    if isinstance(realms, dict) and realms:
        key = str(meta.get("boundRealmKey") or "").strip()
        if key not in realms:
            # Not bound (or bound to a stale key) — take the bucket with the most scanned items.
            key = max(
                realms,
                key=lambda k: len((((realms.get(k) or {}).get("latest") or {}).get("items") or {}))
                if isinstance(realms.get(k), dict) else -1,
            )
        bucket = realms.get(key) or {}
        realm, _, faction = str(key).partition("|")
        # A bucket's own label wins over the key when present (the key is an internal id).
        label = bucket.get("label") if isinstance(bucket.get("label"), dict) else {}
        realm = str(label.get("realm") or meta.get("boundRealmName") or realm or "").strip()
        faction = str(label.get("faction") or faction or meta.get("faction") or "").strip()
        return bucket, realm, faction
    # Legacy flat layout.
    return db, str(meta.get("realm") or "").strip(), str(meta.get("faction") or "").strip()


def collect_char_ledger(db: dict, realm: str) -> dict | None:
    """Union sales/buys across this realm's characters (`D.chars["<Name>-<Realm>"]`)."""
    chars = db.get("chars")
    if not isinstance(chars, dict) or not chars:
        return None
    suffix = ("-" + realm).lower()
    sales: list = []
    buys: list = []
    for key, ch in chars.items():
        if not isinstance(ch, dict):
            continue
        if realm and not str(key).lower().endswith(suffix):
            continue  # another realm's character — its ledger belongs to that realm's upload
        if isinstance(ch.get("sales"), list):
            sales.extend(ch["sales"])
        if isinstance(ch.get("buys"), list):
            buys.extend(ch["buys"])
    if not sales and not buys:
        return None
    sales.sort(key=lambda e: e.get("t") or 0 if isinstance(e, dict) else 0)
    buys.sort(key=lambda e: e.get("t") or 0 if isinstance(e, dict) else 0)
    return {"sales": sales, "buys": buys}


def build_payload(db: dict, region: str, token: str, flavor: str = "anniversary") -> dict:
    bucket, realm, faction = select_realm_bucket(db)
    meta = db.get("meta") or {}
    latest = bucket.get("latest") or {}
    items_tbl = latest.get("items") or {}
    if faction not in ("Alliance", "Horde", "Neutral"):
        faction = "Neutral"
    if not realm:
        raise ValueError("SavedVariables has no realm yet — do a scan in-game first")
    if not items_tbl:
        raise ValueError(f"no scanned items for {realm} yet — run a full AH scan in-game first")
    items = []
    for name, entry in items_tbl.items():
        if not isinstance(entry, dict):
            continue
        name = str(name).strip()
        if not name:
            continue  # the addon keeps an unnamed roll-up row; it isn't a real item
        price = entry.get("p")
        if not isinstance(price, (int, float)) or price <= 0:
            continue
        # qty = total units listed. Per ProfitProphet.lua: "q = total units available
        # (Σ stack counts); nl = number of listings". `n` is a scan counter, NOT a quantity —
        # sending it made every item look like depth-1 and broke the liquidity signal.
        qty = entry.get("q")
        if not isinstance(qty, (int, float)) or qty < 0:
            qty = entry.get("nl") if isinstance(entry.get("nl"), (int, float)) else 0
        items.append({"item": name, "price": int(price), "qty": int(qty or 0)})
    # Private ledger (your own sales/buys) — sent to your account only, never crowd-shared.
    def _ledger(entries, fields):
        out = []
        for e in (entries or [])[-1000:]:
            if isinstance(e, dict) and e.get("item") and e.get("t"):
                row = {"t": e.get("t"), "item": str(e.get("item"))}
                for f in fields:
                    row[f] = e.get(f)
                out.append(row)
        return out
    # Ledger is scoped per CHARACTER (D.chars[charKey]), not per realm — see PP_Scope.lua.
    # Union every character on this realm so the web ledger matches the in-game one; fall back
    # to the legacy flat D.sales/D.buys for pre-scoping SavedVariables.
    ledger_src = collect_char_ledger(db, realm) or {"sales": db.get("sales"), "buys": db.get("buys")}
    sales = _ledger(ledger_src.get("sales"), ("gross", "net", "qty"))
    buys = _ledger(ledger_src.get("buys"), ("price", "qty"))

    payload = {
        "token": token,
        "region": region,
        "flavor": flavor,
        "realm": realm,
        "faction": faction,
        "scannedAt": int(latest.get("t") or meta.get("lastScan") or time.time()),
        "items": items,
    }
    if sales or buys:
        payload["ledger"] = {"sales": sales, "buys": buys}
    return payload


def upload(endpoint: str, payload: dict, attempts: int = 4) -> dict:
    """POST one batch, retrying transient network failures.

    A scan is a multi-MB body, and a mid-send TLS drop is common in the wild (flaky wifi,
    VPNs, and notably any link with a smaller MTU than the sender assumes). Those surface as
    ssl.SSLEOFError / ConnectionResetError — NEITHER of which is a urllib.error.URLError, so
    they used to escape this function and kill the run with a raw Python traceback and no
    retry. For a background auto-sync that meant uploads silently stopped. Retry the transient
    ones with backoff; never retry a 4xx (that's our payload being wrong, and retrying it just
    hammers the server).
    """
    body = json.dumps(payload).encode("utf-8")
    last_err: Exception | None = None
    for attempt in range(attempts):
        req = urllib.request.Request(endpoint, data=body, method="POST",
                                     headers={"Content-Type": "application/json"})
        wait_hint = 0.0
        try:
            with urllib.request.urlopen(req, timeout=90) as resp:
                return json.loads(resp.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            detail = e.read().decode("utf-8", "replace")
            # 4xx (except 429) is a real rejection — surface it immediately, don't retry.
            if e.code < 500 and e.code != 429:
                raise RuntimeError(f"upload rejected ({e.code}): {detail}")
            # The server throttles per upload key and TELLS us how long to wait. Honour it —
            # a blind exponential backoff is shorter than the real window, so every retry gets
            # 429'd again and we give up while the server was only ever asking us to pause.
            try:
                wait_hint = float(json.loads(detail).get("retry_after") or 0) + 1.0
            except Exception:
                wait_hint = 0.0
            last_err = RuntimeError(f"server busy ({e.code}): {detail}")
        except Exception as e:  # ssl.SSLEOFError, ConnectionReset, timeout, DNS, URLError...
            last_err = RuntimeError(f"network dropped mid-upload ({type(e).__name__}: {e})")
        if attempt < attempts - 1:
            time.sleep(max(wait_hint, 2 ** attempt))  # honour retry_after, else 1s/2s/4s
    raise RuntimeError(f"{last_err} — gave up after {attempts} attempts")


def wow_install_roots() -> list[str]:
    """Every plausible 'World of Warcraft' install root on this machine.

    Discovery used to be two hardcoded paths under C:\\Program Files — so anyone who installed
    WoW on a second drive or in a custom folder (very common; the Battle.net installer lets you
    pick, and big games usually land on a games/SSD drive) got "No ProfitProphet.lua found",
    which then wrongly blamed the addon for a path problem. Probe every fixed drive against the
    handful of parents installers actually use. This is a bounded list of stat() calls — no
    recursive drive walk — so it stays fast even with several drives.

    Battle.net does know the real path, but only inside the binary product.db protobuf
    (Battle.net.config carries no install paths — verified), so parsing it is not worth the
    fragility. If a user's layout is still missed, the Settings tab's Browse button is the
    escape hatch, and the empty state now says so.
    """
    roots: list[str] = []

    def add(p: str) -> None:
        if os.path.isdir(p) and p not in roots:
            roots.append(p)

    if sys.platform.startswith("win"):
        drives = []
        for letter in "CDEFGHIJKLMNOPQRSTUVWXYZAB":
            d = f"{letter}:\\"
            if os.path.isdir(d):
                drives.append(d)
        parents = ("", "Program Files", "Program Files (x86)", "Games", "Blizzard",
                   "Battle.net", os.path.join("Program Files", "Battle.net"),
                   os.path.join("Games", "Battle.net"))
        for d in drives:
            for parent in parents:
                add(os.path.join(d, parent, "World of Warcraft") if parent
                    else os.path.join(d, "World of Warcraft"))
    else:
        home = os.path.expanduser("~")
        for p in ("Applications/World of Warcraft", "Games/World of Warcraft",
                  "World of Warcraft",
                  ".wine/drive_c/Program Files (x86)/World of Warcraft"):
            add(os.path.join(home, p))
        add("/Applications/World of Warcraft")
    return roots


def default_sv_path() -> str | None:
    candidates = []
    if sys.platform.startswith("win"):
        for base in wow_install_roots():
            for flavour in FLAVOR_BY_FOLDER:
                candidates.append(os.path.join(base, flavour, "WTF", "Account"))
    else:
        home = os.path.expanduser("~")
        for p in (".wine", "Games", "Applications", "Library/Application Support"):
            candidates.append(os.path.join(home, p))
    for root in candidates:
        if not os.path.isdir(root):
            continue
        for dirpath, _dirs, files in os.walk(root):
            if "ProfitProphet.lua" in files and "SavedVariables" in dirpath:
                return os.path.join(dirpath, "ProfitProphet.lua")
    return None


# --------------------------------------------------------------------------- #
# One upload from saved config
# --------------------------------------------------------------------------- #

# Server-side guards in the ingest function (keep these in sync with it):
#   MAX_ITEMS_PER_REQUEST = 5000, MIN_SECONDS_BETWEEN_UPLOADS = 20 per key.
# A full scan on a busy realm exceeds 5000 items, so send it in batches and wait out the
# per-key rate limit between them rather than letting the whole upload 413.
BATCH_ITEMS = 4500
BATCH_PAUSE_SECONDS = 21
# Mirrors `day >= current_date - 8` in the market_board / market_signals RPCs.
BOARD_WINDOW_DAYS = 8


def discover_sv_paths(configured: str | None = None) -> list[str]:
    """Every ProfitProphet.lua on this machine — one per WoW flavor AND per account.

    A player with Retail and Classic installed has a SEPARATE SavedVariables file per flavor,
    each holding different realms. The config only ever stored ONE path, so the other flavor's
    scans were silently stranded on disk forever — the most likely cause of "I scanned but my
    realm never shows up". Always upload every file we can find; the configured one first.
    """
    found: list[str] = []
    if configured and os.path.isfile(configured):
        found.append(os.path.abspath(configured))
    # Every install root on any drive — not just the two C:\ Program Files paths this used to
    # check. That narrow list is why "the uploader cannot find my .lua file" was reported.
    bases: list[str] = wow_install_roots()
    for base in bases:
        for flavour in FLAVOR_BY_FOLDER:
            acct_root = os.path.join(base, flavour, "WTF", "Account")
            if not os.path.isdir(acct_root):
                continue
            try:
                accounts = os.listdir(acct_root)
            except OSError:
                continue
            for account in accounts:
                p = os.path.join(acct_root, account, "SavedVariables", "ProfitProphet.lua")
                if os.path.isfile(p):
                    ap = os.path.abspath(p)
                    if ap not in found:
                        found.append(ap)
    return found


def do_upload(cfg: dict, progress=None) -> str:
    """Upload every flavor we can find, so Retail + Classic both reach the server."""
    paths = discover_sv_paths(cfg.get("sv"))
    if not paths:
        raise RuntimeError("could not find ProfitProphet.lua — set the SavedVariables path")
    results = []
    for n, sv in enumerate(paths):
        if n > 0:
            # The server throttles per upload key, so going straight from one flavor into the
            # next guarantees a 429. Pause between flavors the same way we do between batches.
            if progress:
                progress(f"waiting {BATCH_PAUSE_SECONDS}s before the next game version…")
            time.sleep(BATCH_PAUSE_SECONDS)
        try:
            results.append(do_upload_one(cfg, sv, progress))
        except Exception as e:
            # One flavor failing must never stop the others from syncing.
            results.append(f"{detect_flavor(sv)}: {e}")
    return " · ".join(results)


def do_upload_one(cfg: dict, sv: str, progress=None) -> str:
    token = cfg.get("token")
    endpoint = cfg.get("endpoint") or DEFAULT_ENDPOINT
    region = cfg.get("region") or "US"
    if not token:
        raise RuntimeError("no upload key configured — run setup first")
    if not sv or not os.path.isfile(sv):
        raise RuntimeError("could not find ProfitProphet.lua — set the SavedVariables path")
    db = parse_saved_variables(sv)
    payload = build_payload(db, region, token, detect_flavor(sv))
    items = payload["items"]
    if not items:
        return "nothing to upload yet (no priced items in the latest scan)"

    batches = [items[i:i + BATCH_ITEMS] for i in range(0, len(items), BATCH_ITEMS)]
    accepted = dropped = 0
    for n, batch in enumerate(batches, 1):
        body = dict(payload)
        body["items"] = batch
        # The ledger is per-account, not per-item — send it once, with the first batch only.
        if n > 1:
            body.pop("ledger", None)
        if n > 1:
            if progress:
                progress(f"waiting {BATCH_PAUSE_SECONDS}s for the rate limit…")
            time.sleep(BATCH_PAUSE_SECONDS)
        if progress and len(batches) > 1:
            progress(f"uploading batch {n}/{len(batches)} ({len(batch)} items)…")
        result = upload(endpoint, body)
        accepted += int(result.get("accepted") or 0)
        dropped += int(result.get("dropped") or 0)

    where = f"{payload['realm']}-{payload['faction']}"
    extra = f" in {len(batches)} batches" if len(batches) > 1 else ""
    msg = f"uploaded {accepted} items for {where}{extra} (dropped {dropped})"

    # The market board only shows scans from the last BOARD_WINDOW_DAYS, because it reports the
    # market *now*. Data is dated when it was SCANNED, not when it was uploaded (re-dating it
    # would misrepresent stale prices as current), so an old scan uploads fine and then doesn't
    # appear. Say so here — otherwise a first-time contributor sees a success message and an
    # empty board with nothing connecting the two.
    age_days = int((time.time() - payload["scannedAt"]) // 86400)
    if age_days > BOARD_WINDOW_DAYS:
        msg += (f"\n  NOTE: this scan is {age_days} days old, so it won't show on the market board"
                f" (which shows the last {BOARD_WINDOW_DAYS} days). It's stored and counts toward"
                f" price history. Run a fresh AH scan in-game and upload again to populate the board.")
    return msg



def resource_path(name: str) -> str:
    """Locate a bundled asset — PyInstaller unpacks to _MEIPASS at runtime."""
    base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
    return os.path.join(base, name)


def _log(msg: str) -> None:
    """Append to a log file so the (windowless) background sync leaves a trace."""
    line = time.strftime("%Y-%m-%d %H:%M") + " - " + msg
    print(line)
    try:
        os.makedirs(CONFIG_DIR, exist_ok=True)
        with open(os.path.join(CONFIG_DIR, "uploader.log"), "a", encoding="utf-8") as fh:
            fh.write(line + "\n")
    except OSError:
        pass


def watch(cfg: dict, every_min: int) -> None:
    _log(f"auto-sync started — every {every_min} min")
    while True:
        try:
            _log(do_upload(cfg))
        except Exception as e:  # keep the loop alive across transient errors
            _log("skipped: " + str(e))
        time.sleep(max(60, every_min * 60))


# --------------------------------------------------------------------------- #
# Autostart (cross-platform) — launches `watch` in the background at login.
# --------------------------------------------------------------------------- #

def _self_cmd(extra: list[str]) -> list[str]:
    """The command that re-launches this program with the given args."""
    if getattr(sys, "frozen", False):          # packaged .exe / app bundle
        return [sys.executable] + extra
    return [sys.executable, os.path.abspath(__file__)] + extra


def install_autostart(every_min: int) -> str:
    args = ["watch", "--every", str(every_min)]
    if sys.platform.startswith("win"):
        # Quote EVERY path, not just ones with spaces: schtasks re-parses this string, and an
        # unquoted "C:\Program Files\..." (or any user folder with a space) splits into a bogus
        # program + args. Quoting unconditionally is always safe here.
        # Register a PERIODIC task, not an ONLOGON one. A logon-triggered task needs elevation
        # ("ERROR: Access is denied." for a normal user), which is why auto-sync silently never
        # installed. /SC MINUTE needs no admin. It's also more robust: each tick is a fresh
        # one-shot `run`, so a crashed or killed sync self-heals on the next tick instead of
        # leaving a dead long-lived `watch` process behind.
        one_shot = _self_cmd(["run"])
        cmd = " ".join(f'"{a}"' if os.path.sep in a or " " in a else a for a in one_shot)
        r = subprocess.run(["schtasks", "/Create", "/TN", APP_NAME, "/TR", cmd,
                            "/SC", "MINUTE", "/MO", str(max(5, every_min)), "/F"],
                           capture_output=True, text=True,
                           creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
        if r.returncode == 0:
            return f'auto-sync ON — uploads every {max(5, every_min)} minutes'

        # Fallback: a Startup-folder shortcut can always be written by the user themselves.
        err = (r.stderr or r.stdout or "").strip() or f"schtasks exited {r.returncode}"
        try:
            startup = os.path.join(os.environ["APPDATA"], "Microsoft", "Windows",
                                   "Start Menu", "Programs", "Startup")
            os.makedirs(startup, exist_ok=True)
            lnk = os.path.join(startup, "ProfitProphet Wisdom Sync.lnk")
            target = one_shot[0]
            rest = " ".join(one_shot[1:-1] + ["watch", "--every", str(every_min)]) \
                if len(one_shot) > 1 else f"watch --every {every_min}"
            ps = ("$w=New-Object -ComObject WScript.Shell;"
                  f"$s=$w.CreateShortcut('{lnk}');$s.TargetPath='{target}';"
                  f"$s.Arguments='{rest}';$s.WindowStyle=7;"
                  f"$s.WorkingDirectory='{os.path.dirname(target)}';$s.Save()")
            subprocess.run(["powershell", "-NoProfile", "-Command", ps], check=True,
                           capture_output=True,
                           creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
            return (f"auto-sync ON via Startup folder (Task Scheduler was blocked: {err}). "
                    "It starts syncing when you next sign in to Windows.")
        except Exception as e2:
            raise RuntimeError(f"could not set up auto-sync: {err} / startup fallback: {e2}")
    elif sys.platform == "darwin":
        plist_dir = os.path.expanduser("~/Library/LaunchAgents")
        os.makedirs(plist_dir, exist_ok=True)
        plist = os.path.join(plist_dir, "com.profitprophet.wisdom.plist")
        progargs = "".join(f"<string>{a}</string>" for a in _self_cmd(args))
        with open(plist, "w", encoding="utf-8") as fh:
            fh.write(f'<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>'
                     f'<key>Label</key><string>com.profitprophet.wisdom</string>'
                     f'<key>ProgramArguments</key><array>{progargs}</array>'
                     f'<key>RunAtLoad</key><true/><key>KeepAlive</key><true/></dict></plist>')
        subprocess.run(["launchctl", "load", plist], capture_output=True, text=True)
        return f"installed macOS LaunchAgent ({plist})"
    else:  # Linux — autostart .desktop launches the continuous watch loop
        ad = os.path.expanduser("~/.config/autostart")
        os.makedirs(ad, exist_ok=True)
        path = os.path.join(ad, "ppwisdom.desktop")
        exec_cmd = " ".join(_self_cmd(args))
        with open(path, "w", encoding="utf-8") as fh:
            fh.write("[Desktop Entry]\nType=Application\nName=ProfitProphet Wisdom Uploader\n"
                     f"Exec={exec_cmd}\nX-GNOME-Autostart-enabled=true\nNoDisplay=true\n")
        return f"installed Linux autostart entry ({path})"


def uninstall_autostart() -> str:
    if sys.platform.startswith("win"):
        subprocess.run(["schtasks", "/Delete", "/TN", APP_NAME, "/F"], capture_output=True, text=True,
                       creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
        # Remove the Startup fallback too, or "off" would leave it still syncing at next login.
        try:
            os.remove(os.path.join(os.environ["APPDATA"], "Microsoft", "Windows", "Start Menu",
                                   "Programs", "Startup", "ProfitProphet Wisdom Sync.lnk"))
        except OSError:
            pass
        return "auto-sync OFF"
    elif sys.platform == "darwin":
        plist = os.path.expanduser("~/Library/LaunchAgents/com.profitprophet.wisdom.plist")
        subprocess.run(["launchctl", "unload", plist], capture_output=True, text=True)
        try:
            os.remove(plist)
        except OSError:
            pass
        return "removed the macOS LaunchAgent"
    else:
        try:
            os.remove(os.path.expanduser("~/.config/autostart/ppwisdom.desktop"))
        except OSError:
            pass
        return "removed the Linux autostart entry"


# --------------------------------------------------------------------------- #
# Setup GUI (tkinter — stdlib). Shown when double-clicked / run with no args.
# --------------------------------------------------------------------------- #

def install_shortcuts() -> str:
    """Put the app in the Start Menu so users can find it by searching.

    A downloaded .exe that lives in ~/Downloads is effectively lost the moment the window is
    closed — users can't find it again and assume it stopped working. Per-user Start Menu, so
    no admin prompt.
    """
    if not sys.platform.startswith("win"):
        return "Start Menu shortcut is Windows-only."
    try:
        target = os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__)
        start_dir = os.path.join(os.environ.get("APPDATA", ""), "Microsoft", "Windows",
                                 "Start Menu", "Programs")
        os.makedirs(start_dir, exist_ok=True)
        lnk = os.path.join(start_dir, "ProfitProphet Wisdom.lnk")
        ps = (
            "$w=New-Object -ComObject WScript.Shell;"
            f"$s=$w.CreateShortcut('{lnk}');"
            f"$s.TargetPath='{target}';"
            f"$s.WorkingDirectory='{os.path.dirname(target)}';"
            f"$s.IconLocation='{target},0';"
            "$s.Description='ProfitProphet Wisdom - market data uploader';$s.Save()"
        )
        subprocess.run(["powershell", "-NoProfile", "-Command", ps], check=True,
                       capture_output=True,
                       creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
        return "Added to the Start Menu — search “ProfitProphet”."
    except Exception as e:
        return f"Couldn't create the Start Menu shortcut: {e}"


def shortcut_installed() -> bool:
    try:
        return os.path.isfile(os.path.join(
            os.environ.get("APPDATA", ""), "Microsoft", "Windows", "Start Menu",
            "Programs", "ProfitProphet Wisdom.lnk"))
    except Exception:
        return False


def autostart_installed() -> bool:
    """Is background auto-sync actually registered? (Don't claim 'on' if it isn't.)"""
    try:
        if sys.platform.startswith("win"):
            r = subprocess.run(["schtasks", "/query", "/tn", APP_NAME],
                               capture_output=True, text=True,
                               creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
            if r.returncode == 0:
                return True
            # Also count the Startup-folder fallback, or we'd report OFF while it's syncing.
            return os.path.isfile(os.path.join(
                os.environ.get("APPDATA", ""), "Microsoft", "Windows", "Start Menu",
                "Programs", "Startup", "ProfitProphet Wisdom Sync.lnk"))
        if sys.platform == "darwin":
            return os.path.isfile(os.path.expanduser(
                f"~/Library/LaunchAgents/com.{APP_NAME.lower()}.plist"))
        return os.path.isfile(os.path.expanduser(f"~/.config/autostart/{APP_NAME}.desktop"))
    except Exception:
        return False


def verify_with_server(cfg: dict) -> dict:
    """Ask the server what it actually holds for this key.

    'Upload succeeded' is a claim about what we SENT. This is the only thing that proves the
    data is really there — the server reports back its own view, per realm.
    """
    endpoint = cfg.get("endpoint") or DEFAULT_ENDPOINT
    return upload(endpoint, {"token": cfg.get("token"), "verify": True}, attempts=2)


def run_diagnostics(cfg: dict) -> list[tuple[str, bool, str]]:
    """Test every link in the chain and say which one is broken. Returns (step, ok, detail)."""
    out: list[tuple[str, bool, str]] = []

    tok = (cfg.get("token") or "").strip()
    out.append(("Upload key saved", tok.startswith("ppw_"),
                "looks valid" if tok.startswith("ppw_") else "missing or malformed — paste it in Settings"))

    paths = discover_sv_paths(cfg.get("sv"))
    if paths:
        newest = max(os.path.getmtime(p) for p in paths)
        age_h = (time.time() - newest) / 3600
        vers = ", ".join(sorted({detect_flavor(p) for p in paths}))
        out.append((f"Scan files found ({len(paths)})", True, f"{vers} · newest saved {age_h:.1f}h ago"))
        # A scan WoW hasn't flushed yet is the most common "why isn't my data there" cause.
        out.append(("Scan is recent", age_h < 48,
                    "fresh" if age_h < 48 else
                    "newest file is >48h old — log out or /reload in WoW so it saves the scan"))
    else:
        out.append(("Scan files found", False, "no ProfitProphet.lua — is the addon installed?"))

    try:
        info = verify_with_server(cfg)
        out.append(("Server reachable", True, "connected"))
        out.append(("Key accepted by server", bool(info.get("account_ok")), "authenticated"))
        realms = info.get("realms") or []
        last = str(info.get("last_upload_at") or "")[:16].replace("T", " ")
        if realms:
            for r in realms[:8]:
                out.append((f"Server has {r['realm']} ({r['flavor']})", True,
                            f"data stored · last received {last or 'recently'}"))
        else:
            out.append(("Server has your data", False,
                        "authenticated, but no data stored yet — run Upload now"))
    except Exception as e:
        msg = str(e)
        out.append(("Server reachable", False, msg))
        if "401" in msg or "invalid or revoked" in msg:
            out.append(("Key accepted by server", False, "key rejected — generate a new one in your portal"))

    out.append(("Auto-sync scheduled", autostart_installed(),
                "ON — runs at login, every 30 min" if autostart_installed()
                else "OFF — turn it on from the Status tab"))
    if sys.platform.startswith("win"):
        out.append(("Findable in Start Menu", shortcut_installed(),
                    "search “ProfitProphet”" if shortcut_installed()
                    else "not installed — use “Add to Start Menu” on the Status tab"))
    return out


def read_log_tail(lines: int = 200) -> str:
    try:
        with open(os.path.join(CONFIG_DIR, "uploader.log"), "r", encoding="utf-8") as fh:
            return "".join(fh.readlines()[-lines:])
    except Exception:
        return "No activity yet. Uploads you run will be recorded here."


def run_gui() -> int:
    try:
        import tkinter as tk
        from tkinter import ttk, filedialog, messagebox
    except Exception:
        print("Setup window unavailable on this system. Use the CLI: pp_upload setup --help")
        return 1
    import threading

    cfg = load_config()
    root = tk.Tk()
    root.title("ProfitProphet Wisdom — Uploader")
    root.geometry("760x620")
    try:
        root.iconphoto(True, tk.PhotoImage(file=resource_path("icon.png")))
    except Exception:
        pass

    nb = ttk.Notebook(root)
    nb.pack(fill="both", expand=True, padx=10, pady=10)
    tab_status = ttk.Frame(nb, padding=16)
    tab_files = ttk.Frame(nb, padding=16)
    tab_settings = ttk.Frame(nb, padding=16)
    tab_log = ttk.Frame(nb, padding=16)
    tab_check = ttk.Frame(nb, padding=16)
    nb.add(tab_status, text="  Status  ")
    nb.add(tab_check, text="  Check my setup  ")
    nb.add(tab_files, text="  My files  ")
    nb.add(tab_settings, text="  Settings  ")
    nb.add(tab_log, text="  Activity  ")

    # ---------------- Status: what is it doing, right now -------------------
    ttk.Label(tab_status, text="Wisdom market-data uploader",
              font=("Segoe UI", 14, "bold")).pack(anchor="w")
    sync_lbl = ttk.Label(tab_status, text="", font=("Segoe UI", 10))
    sync_lbl.pack(anchor="w", pady=(6, 2))
    last_lbl = ttk.Label(tab_status, text="", foreground="#555")
    last_lbl.pack(anchor="w")
    status = ttk.Label(tab_status, text="", foreground="#0a7", wraplength=680, justify="left")
    status.pack(anchor="w", pady=(12, 0))

    def refresh_status():
        on = autostart_installed()
        sync_lbl.config(text=("Auto-sync: ON — uploads every 30 minutes in the background"
                              if on else "Auto-sync: OFF — nothing is uploading automatically"),
                        foreground=("#0a7" if on else "#c33"))
        last = load_config().get("last_upload_at")
        last_lbl.config(text=f"Last successful upload: {last}" if last
                        else "Last successful upload: never (run one below)")

    def do_now():
        status.config(text="Uploading… this can take a minute on a big Retail scan.", foreground="#555")
        root.update_idletasks()

        def work():
            try:
                msg = do_upload(load_config())
                c = load_config(); c["last_upload_at"] = time.strftime("%Y-%m-%d %H:%M"); save_config(c)
                _log(msg)
                root.after(0, lambda: (status.config(text=msg, foreground="#0a7"),
                                       refresh_status(), refresh_files(), refresh_log()))
            except Exception as e:
                _log(f"upload failed: {e}")
                root.after(0, lambda: (status.config(text=str(e), foreground="#c33"), refresh_log()))
        threading.Thread(target=work, daemon=True).start()

    # First run: put ourselves in the Start Menu unprompted. A .exe sitting in ~/Downloads is
    # effectively lost once the window closes — this is why users think the app "disappeared".
    if sys.platform.startswith("win") and not shortcut_installed():
        try:
            _log(install_shortcuts())
        except Exception:
            pass

    row1 = ttk.Frame(tab_status); row1.pack(anchor="w", pady=16)
    ttk.Button(row1, text="Upload now", command=do_now).pack(side="left")
    ttk.Button(row1, text="Turn auto-sync ON",
               command=lambda: (install_autostart(30), refresh_status(),
                                status.config(text="Auto-sync enabled — runs at login, every 30 min.",
                                              foreground="#0a7"))).pack(side="left", padx=8)
    ttk.Button(row1, text="Turn OFF",
               command=lambda: (uninstall_autostart(), refresh_status(),
                                status.config(text="Auto-sync disabled.", foreground="#555"))).pack(side="left")
    ttk.Button(row1, text="Add to Start Menu",
               command=lambda: status.config(text=install_shortcuts(), foreground="#0a7")).pack(side="left", padx=8)

    # ---------------- Check my setup: prove it works, end to end -------------
    ttk.Label(tab_check, text="Is everything actually working?",
              font=("Segoe UI", 12, "bold")).pack(anchor="w")
    ttk.Label(tab_check, text="Tests every step and asks the server what it really has for you — "
                              "so you're not trusting a local 'success' message.",
              foreground="#555", wraplength=680, justify="left").pack(anchor="w", pady=(2, 10))
    chk_cols = ("res", "step", "detail")
    chk = ttk.Treeview(tab_check, columns=chk_cols, show="headings", height=11)
    for c, w in zip(chk_cols, (60, 230, 420)):
        chk.heading(c, text={"res": "", "step": "Check", "detail": "Result"}[c])
        chk.column(c, width=w, anchor="w")
    chk.tag_configure("ok", foreground="#0a7")
    chk.tag_configure("bad", foreground="#c33")
    chk.pack(fill="both", expand=True)
    chk_sum = ttk.Label(tab_check, text="", wraplength=680, justify="left")
    chk_sum.pack(anchor="w", pady=(10, 0))

    def run_checks():
        for i in chk.get_children():
            chk.delete(i)
        chk.insert("", "end", values=("…", "Running checks", "contacting the server…"))
        chk_sum.config(text="")

        def work():
            results = run_diagnostics(load_config())
            def paint():
                for i in chk.get_children():
                    chk.delete(i)
                for step, ok, detail in results:
                    chk.insert("", "end", values=("PASS" if ok else "FAIL", step, detail),
                               tags=("ok" if ok else "bad",))
                bad = [s for s, ok, _ in results if not ok]
                if bad:
                    chk_sum.config(text="Not working yet — fix: " + "; ".join(bad[:3]), foreground="#c33")
                else:
                    chk_sum.config(text="✓ Everything checks out — your scans are reaching Wisdom.",
                                   foreground="#0a7")
            root.after(0, paint)
        threading.Thread(target=work, daemon=True).start()

    ttk.Button(tab_check, text="Run checks", command=run_checks).pack(anchor="w", pady=(10, 0))

    # ---------------- My files: exactly what it reads, no mystery ------------
    ttk.Label(tab_files, text="Scan files found on this PC",
              font=("Segoe UI", 12, "bold")).pack(anchor="w")
    ttk.Label(tab_files, text="Every game version below is uploaded. WoW only writes these when you "
                              "log out or /reload — if a time looks old, that scan isn't saved yet.",
              foreground="#555", wraplength=680, justify="left").pack(anchor="w", pady=(2, 10))
    cols = ("version", "modified", "size", "path")
    tree = ttk.Treeview(tab_files, columns=cols, show="headings", height=7)
    for c, w in zip(cols, (110, 150, 80, 380)):
        tree.heading(c, text={"version": "Game version", "modified": "Scan saved",
                              "size": "Size", "path": "File"}[c])
        tree.column(c, width=w, anchor="w")
    tree.pack(fill="both", expand=True)

    def refresh_files():
        for i in tree.get_children():
            tree.delete(i)
        paths = discover_sv_paths(load_config().get("sv"))
        if not paths:
            # Don't blame the addon: by far the likeliest cause is that WoW lives somewhere this
            # scan didn't look (a second drive / custom folder), and the second is that the game
            # hasn't written the file yet. Say both, and point at the Browse button that fixes it.
            tree.insert("", "end", values=(
                "—", "—", "—",
                "No ProfitProphet.lua found. If WoW is on another drive or a custom folder, "
                "use Settings → Browse… to point at it directly. "
                "Otherwise log out or /reload once — WoW only writes the file then."))
        for p in paths:
            try:
                st = os.stat(p)
                tree.insert("", "end", values=(
                    detect_flavor(p),
                    time.strftime("%Y-%m-%d %H:%M", time.localtime(st.st_mtime)),
                    f"{st.st_size // 1048576} MB", p))
            except OSError:
                tree.insert("", "end", values=(detect_flavor(p), "unreadable", "—", p))

    ttk.Button(tab_files, text="Refresh", command=lambda: refresh_files()).pack(anchor="w", pady=(10, 0))
    ttk.Button(tab_files, text="Open folder",
               command=lambda: _open_path(CONFIG_DIR)).pack(anchor="w", pady=(6, 0))

    # ---------------- Settings ----------------------------------------------
    def row(parent, label, default=""):
        ttk.Label(parent, text=label).pack(anchor="w")
        var = tk.StringVar(value=default)
        ttk.Entry(parent, textvariable=var, width=76).pack(anchor="w", pady=(0, 8))
        return var

    token_var = row(tab_settings, "Upload key (from wowprofitprophet.com → your portal)", cfg.get("token", ""))
    region_var = row(tab_settings, "Region (US / EU / KR / TW)", cfg.get("region", "US"))
    sv_var = row(tab_settings, "Preferred scan file (optional — every version is uploaded anyway)",
                 cfg.get("sv") or (default_sv_path() or ""))

    def browse():
        p = filedialog.askopenfilename(title="Select ProfitProphet.lua",
                                       filetypes=[("Lua", "*.lua"), ("All", "*.*")])
        if p:
            sv_var.set(p)
    ttk.Button(tab_settings, text="Browse…", command=browse).pack(anchor="w")
    set_status = ttk.Label(tab_settings, text="", foreground="#0a7", wraplength=680)
    set_status.pack(anchor="w", pady=(12, 0))

    def save_settings():
        tok = token_var.get().strip()
        if not tok.startswith("ppw_"):
            messagebox.showerror("Missing key", "Paste your ppw_ upload key from your portal.")
            return
        c = load_config()
        c.update({"token": tok, "region": region_var.get().strip() or "US",
                  "sv": sv_var.get().strip(), "endpoint": c.get("endpoint") or DEFAULT_ENDPOINT})
        save_config(c)
        set_status.config(text=f"Saved to {os.path.join(CONFIG_DIR, 'config.json')}")
        refresh_files()
    ttk.Button(tab_settings, text="Save settings", command=save_settings).pack(anchor="w", pady=(10, 0))

    # ---------------- Activity log ------------------------------------------
    ttk.Label(tab_log, text="Everything this app has done", font=("Segoe UI", 12, "bold")).pack(anchor="w")
    log_txt = tk.Text(tab_log, height=18, wrap="word")
    log_txt.pack(fill="both", expand=True, pady=(8, 0))

    def refresh_log():
        log_txt.delete("1.0", "end")
        log_txt.insert("1.0", read_log_tail())
        log_txt.see("end")
    ttk.Button(tab_log, text="Refresh", command=lambda: refresh_log()).pack(anchor="w", pady=(8, 0))

    refresh_status(); refresh_files(); refresh_log()
    _start_tray(root, do_now)
    root.mainloop()
    return 0


def _open_path(path: str) -> None:
    try:
        if sys.platform.startswith("win"):
            os.startfile(path)  # noqa: S606
        elif sys.platform == "darwin":
            subprocess.run(["open", path], check=False)
        else:
            subprocess.run(["xdg-open", path], check=False)
    except Exception:
        pass


def _start_tray(root, upload_now) -> None:
    """Keep the app in the system tray so closing the window doesn't kill auto-sync."""
    try:
        import pystray
        from PIL import Image
    except Exception:
        return  # no tray available — window-only is still fine
    try:
        img = Image.open(resource_path("icon.png"))
    except Exception:
        from PIL import Image as _I
        img = _I.new("RGB", (64, 64), (212, 162, 76))

    def show(icon=None, item=None):
        root.after(0, lambda: (root.deiconify(), root.lift()))

    def quit_all(icon=None, item=None):
        try:
            icon.stop()
        except Exception:
            pass
        root.after(0, root.destroy)

    icon = pystray.Icon(
        APP_NAME, img, "ProfitProphet Wisdom uploader",
        menu=pystray.Menu(
            pystray.MenuItem("Open", show, default=True),
            pystray.MenuItem("Upload now", lambda i, it: root.after(0, upload_now)),
            pystray.MenuItem("Quit", quit_all),
        ))
    threading.Thread(target=icon.run, daemon=True).start()
    # Closing the window hides to tray instead of exiting, so background sync survives.
    root.protocol("WM_DELETE_WINDOW", lambda: root.withdraw())


# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #

def main() -> None:
    if len(sys.argv) == 1:            # double-click / no args → setup window
        sys.exit(run_gui())

    ap = argparse.ArgumentParser(description="ProfitProphet Wisdom uploader")
    sub = ap.add_subparsers(dest="cmd")

    s = sub.add_parser("setup", help="save your upload key + settings")
    s.add_argument("--token", required=True)
    s.add_argument("--region", default="US")
    s.add_argument("--sv", default=None)
    s.add_argument("--endpoint", default=DEFAULT_ENDPOINT)

    sub.add_parser("run", help="one upload using saved config")
    w = sub.add_parser("watch", help="upload every N minutes (foreground)")
    w.add_argument("--every", type=int, default=30)
    i = sub.add_parser("install", help="background auto-sync at login")
    i.add_argument("--every", type=int, default=30)
    sub.add_parser("uninstall", help="remove background auto-sync")
    sub.add_parser("gui", help="open the setup window")

    args = ap.parse_args()
    if args.cmd == "setup":
        cfg = load_config()
        cfg.update({"token": args.token, "region": args.region, "endpoint": args.endpoint})
        if args.sv:
            cfg["sv"] = args.sv
        save_config(cfg)
        print("saved config to", CONFIG_PATH)
    elif args.cmd == "run":
        # ALWAYS log: this is the command the scheduled task runs, and a task has nowhere to
        # print. Without this every background sync is invisible — the Activity tab stays empty
        # and a user has no way to tell "it's working quietly" from "it never ran".
        try:
            msg = do_upload(load_config())
            _log(msg)
            cfg = load_config(); cfg["last_upload_at"] = time.strftime("%Y-%m-%d %H:%M")
            save_config(cfg)
            print(msg)
        except Exception as e:
            _log(f"upload failed: {e}")
            print(f"upload failed: {e}", file=sys.stderr)
            raise SystemExit(1)
    elif args.cmd == "watch":
        watch(load_config(), args.every)
    elif args.cmd == "install":
        cfg = load_config()
        print(do_upload(cfg))
        print(install_autostart(args.every))
    elif args.cmd == "uninstall":
        print(uninstall_autostart())
    elif args.cmd == "gui":
        sys.exit(run_gui())
    else:
        ap.print_help()


if __name__ == "__main__":
    main()
