"""
birdseye_tvfeed.py — Bird's Eye receiver: TradingView alerts in, quotes.json + roster.json out. No IBKR, no Claude.

  TradingView (Bird's Eye Feed indicator, one alert per batch)  --webhook-->  https://<tunnel>/hook/<token>
        -> this script (listens on http://localhost:8765)  -> quotes.json  -> birdseye-dashboard-v1.12.html

Run:      python birdseye_tvfeed.py                (serves the folder on :8765 and accepts webhooks at /hook/<token>)
          python birdseye_tvfeed.py --port 8765 --xlsx "..\\Bernie_s Shortlist\\core_shortlist_log.xlsx"
          python birdseye_tvfeed.py --test         (posts a sample payload to itself and exits — checks the pipeline without TradingView)
Token:    printed at startup; stored in tv_token.txt next to this script. Put it in every alert's webhook URL.
Tunnel:   TradingView must reach this PC over https — run a tunnel to localhost:8765 (see the backlog: Tailscale Funnel /
          Cloudflare Tunnel / ngrok) and use its public hostname in the webhook URL.

Payload contract (any Pine script can post to the same URL):
  {"kind":"<plugin>",      # who sent it. Missing/"birdseye" = the Bird's Eye Feed indicator. Anything else = one of Neo's plugins.
   "b":"stocks-1",         # batch / instance name, free text (log only)
   "t":"10:04",            # bar time, HH:MM ET
   "d":[{"s":"HPE", ...fields...}, ...]}
Bird's Eye Feed fields per symbol: l o p h lo v (daily last/open/prior close/high/low/volume), av (20-session avg volume), a (ADR%),
  a14 (ADR14 abs), c2 (2-min close), m2 [sma20,sma200] 2-min, m15 [sma20,sma200] 15-min, x [dir,"HH:MM",confirmed] or null.
Any other kind: every field except "s" is passed through untouched into quotes[SYM]["ext"][kind] (plus "t"), so a plugin can ship
  levels, scores, signal states or text and the dashboard reads them on its next refresh — no receiver change per plugin.
  A plugin may also include the Bird's Eye fields above; they are processed the same way. Keep one alert under ~4 KB.
Geometry (coil / band / alignment / play / trend) is computed HERE with bernie_scan.py's thresholds — one place, not Pine.
"""
import argparse, json, os, re, secrets, sys, threading, time, urllib.request
from datetime import datetime, timezone, date
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "quotes.json")
ROSTER_OUT = os.path.join(HERE, "roster.json")
TOKEN_FILE = os.path.join(HERE, "tv_token.txt")
XLSX_DEFAULT = os.path.join(os.path.dirname(HERE), "Bernie_s Shortlist", "core_shortlist_log.xlsx")
SHORTLIST_DEFAULT = os.path.join(os.path.dirname(XLSX_DEFAULT), "shortlist.json")  # preferred Log source

# ---------- thresholds: copied from bernie_scan.py (param_v 4). Change them there first; these mirror it. ----------
COIL_PINCHED, COIL_TIGHT, COIL_COILED = 0.25, 0.50, 1.00     # % of price: spread between the 2-min averages
POS_PRIME, POS_OK, POS_EXTENDED = 0.25, 0.75, 2.00           # distance from the near band edge, in ADR(14D)

QUOTES = {}          # sym -> quote dict as the dashboard expects it
LOCK = threading.Lock()
STATS = {"posts": 0, "last": None, "batches": {}}

def num(x):
    try:
        x = float(x)
        return None if x != x else x
    except Exception:
        return None

def geometry_t2(price, m20, m200, adr14, asof):
    if price is None or m20 is None or m200 is None: return None
    lo, hi = min(m20, m200), max(m20, m200)
    struct = "BULL" if m20 > m200 else "BEAR"
    spread = (hi - lo) / price * 100
    state = "PINCHED" if spread <= COIL_PINCHED else "TIGHT" if spread <= COIL_TIGHT else "COILED" if spread <= COIL_COILED else "WIDE"
    if lo <= price <= hi:
        side, band, x = "INSIDE", "INSIDE", 0.0
    else:
        side = "ABOVE" if price > hi else "BELOW"
        dist = (price - hi) if side == "ABOVE" else (lo - price)
        x = dist / adr14 if adr14 else None
        band = None if x is None else "PRIME" if x <= POS_PRIME else "OK" if x <= POS_OK else "EXTENDED" if x <= POS_EXTENDED else "RUNAWAY"
    align = "UNCONFIRMED" if side == "INSIDE" else ("ALIGNED" if (struct == "BULL") == (side == "ABOVE") else "CONTESTED")
    play = ("NO-TRADE" if side == "INSIDE" else None if band is None else
            "CONTINUATION" if band in ("PRIME", "OK") else "FADE" if band == "EXTENDED" else "RUNAWAY")
    direction = None if play in (None, "NO-TRADE") else ("LONG" if side == "ABOVE" else "SHORT")
    return {"ma20": round(m20, 4), "ma200": round(m200, 4), "spread": round(spread, 3), "state": state,
            "band": band, "dist": None if x is None else round(x, 3), "side": side, "align": align,
            "struct": struct, "play": play, "dir": direction, "asof": asof, "src": "TV"}

def geometry_t15(m20, m200, x, asof):
    if m20 is None or m200 is None: return None
    cross = None
    if isinstance(x, list) and len(x) >= 3 and x[0] in (1, -1):
        cross = {"dir": "BULL" if x[0] == 1 else "BEAR", "time": str(x[1]), "confirmed": bool(x[2])}
    return {"ma20": round(m20, 4), "ma200": round(m200, 4), "struct": "BULL" if m20 > m200 else "BEAR", "cross": cross, "asof": asof, "src": "TV"}

FEED_FIELDS = {"s", "l", "o", "p", "h", "lo", "v", "av", "a", "a14", "c2", "m2", "m15", "x"}

# Symbol slots as configured in each Bird's Eye Feed instance on the chart (2026-09-10), in slot order.
# Used when a payload item carries an empty "s" — the v1.0 script's syminfo.ticker() returns "" for a bare ticker,
# and alerts keep the script version they were created with, so the fix lives here rather than forcing 7 re-creations.
BATCH_SLOTS = {
    "stocks-1": ["AAOI", "AEHR", "AFRM", "ANF", "ASTS", "AVAV", "AXTI", "CART", "CAVA", "CRCL"],
    "stocks-2": ["CRWV", "CTSH", "CVNA", "DAR", "DKS", "DOCN", "EIX", "ESTC", "FCX", "FLR"],
    "stocks-3": ["FLUT", "FROG", "HNGE", "HOOD", "HPE", "HUT", "INTC", "KGS", "MCHP", "MP"],
    "stocks-4": ["MRNA", "MSTR", "MXL", "NOW", "P", "PBF", "RBRK", "RKLB", "SEI", "SFM"],
    "stocks-5": ["SMTC", "SWKS", "SYRE", "TEM", "TTMI", "TWST", "TXG", "VSAT", "VSXY", "WING"],
    "etf-1":    ["SPY", "QQQ", "IWM", "DIA", "XLK", "XLF", "XLE", "XLV", "XLY"],
    "etf-2":    ["XLP", "XLI", "XLU", "XLB", "XLRE", "XLC", "SOXX", "IGV", "XME"],
}

def ingest(doc):
    """One webhook payload -> QUOTES. Returns (kind, batch, count)."""
    kind = str(doc.get("kind") or "birdseye").strip().lower()
    batch, mode, t = doc.get("b", "?"), doc.get("mode", "stocks"), doc.get("t")
    n = 0
    slots = BATCH_SLOTS.get(batch, [])
    with LOCK:
        for i, it in enumerate(doc.get("d", [])):
            if not isinstance(it, dict): continue
            sym = str(it.get("s", "")).upper().strip()
            if ":" in sym: sym = sym.split(":")[-1]
            if not sym and i < len(slots): sym = slots[i]          # blank ticker -> slot map (see BATCH_SLOTS)
            if not sym: continue
            q = QUOTES.get(sym, {})
            has_quote = any(k in it for k in ("l", "o", "p", "h", "lo"))
            if kind == "birdseye" or has_quote:
                # price block — a plugin that carries prices refreshes them too
                for src, dst in (("l", "last"), ("o", "open"), ("p", "prev"), ("h", "high"), ("lo", "low"), ("v", "vol")):
                    if src in it: q[dst] = num(it.get(src))
                q["src"] = "TV"; q["t"] = t
                if mode != "etfs" and ("m2" in it or "m15" in it):
                    q["avgvol"] = num(it.get("av")); q["adr"] = num(it.get("a")); q["adr14"] = num(it.get("a14"))
                    m2, m15 = it.get("m2") or [None, None], it.get("m15") or [None, None]
                    q["t2"] = geometry_t2(q.get("last"), num(m2[0]), num(m2[1]), q.get("adr14"), t)
                    q["t15"] = geometry_t15(num(m15[0]), num(m15[1]), it.get("x"), t)
            if kind != "birdseye":
                # plugin fields: pass through untouched, keyed by plugin, timestamped
                extra = {k: v for k, v in it.items() if k not in FEED_FIELDS}
                ext = q.setdefault("ext", {})
                ext[kind] = {**extra, "t": t}
            QUOTES[sym] = q; n += 1
        STATS["posts"] += 1; STATS["last"] = datetime.now().strftime("%H:%M:%S"); STATS["batches"][f"{kind}/{batch}"] = t
    return kind, batch, n

def write_quotes():
    with LOCK:
        doc = {"asof": datetime.now(timezone.utc).isoformat(timespec="seconds"), "source": "tradingview", "quotes": QUOTES}
        tmp = OUT + ".tmp"
        with open(tmp, "w") as f: json.dump(doc, f, separators=(",", ":"))
        os.replace(tmp, OUT)

# ---------- Daily Log -> roster.json ----------
#
# v2 (2026-09-11): shortlist.json is the PREFERRED source; the workbook is the fallback.
#
# Why: parsing core_shortlist_log.xlsx made the receiver depend on openpyxl, on fourteen
# hard-coded column names, and on a 182 KB binary file another program owns and Excel may
# have open. Any of those failing meant no roster.json at all, reported only to a console
# window. shortlist.json is written by the producer (bernie_scan) from rows it already holds,
# and is read here with nothing but the stdlib.
#
# Three accepted shapes, so the Log can be simplified as far as a bare list of tickers:
#   1. full   {"log_date","log_time","universe","names":{"AFRM":{"priority":1,"play":...}}}
#   2. tickers{"log_date":"2026-09-11","tickers":["AFRM","AAOI",...]}   priority = list order
#   3. bare   ["AFRM","AAOI",...]                                       priority = list order
# Shapes 2 and 3 carry no play/coil/band, so those names get no pre-open lane — the shortlist
# and the ranking still work, and live 2-min geometry takes over at 09:32 as usual.
LOG_MTIME = None          # mtime of whichever source last produced a roster.json
LOG_SOURCE = None         # path of that source, so a switch between them forces a re-read
LAST_ERROR = None         # last error surfaced into roster.json, to avoid rewriting it every cycle
LANE_KEYS = ["Date", "Time (ET)", "Rank (RVOL)", "Ticker", "% Chg", "RVOL", "Zone Position", "Trade Priority",
             "Play", "Coil", "Structure", "Alignment", "Universe", "MA Spread %"]


def _blank(sym, priority):
    return {"priority": priority, "play": None, "coil": None, "structure": None, "alignment": None,
            "dir": None, "rvol_rank": None, "zone": "", "spread": None, "band": None}


def read_shortlist(path):
    """stdlib-only reader for shortlist.json. Returns the same doc shape as read_log()."""
    with open(path, encoding="utf-8") as f:
        raw = json.load(f)

    if isinstance(raw, list):                      # shape 3
        raw = {"tickers": raw}
    if not isinstance(raw, dict):
        raise ValueError("shortlist.json must be an object or an array of tickers")

    names = raw.get("names")
    if isinstance(names, dict) and names:          # shape 1
        out = {}
        for sym, v in names.items():
            sym = str(sym).strip().upper()
            if not sym:
                continue
            d = _blank(sym, None)
            if isinstance(v, dict):
                d.update({k: v.get(k, d[k]) for k in d})
                pr = v.get("priority")
                d["priority"] = int(pr) if isinstance(pr, (int, float)) else None
                if "top15" in v:
                    d["top15"] = bool(v["top15"])
            out[sym] = d
    else:                                          # shapes 2 and 3
        tickers = raw.get("tickers") or []
        if not isinstance(tickers, list):
            raise ValueError("'tickers' must be a list")
        out, seen = {}, set()
        for i, t in enumerate(tickers):
            sym = str(t).strip().upper()
            if not sym or sym in seen:
                continue
            seen.add(sym)
            out[sym] = _blank(sym, i + 1)

    if not out:
        raise ValueError("no tickers in shortlist.json")

    # How many of them carry the shortlist star.
    # v2.2 (2026-09-11): for shortlist.json the DEFAULT is every name in the file — the file IS the
    # selection, so a curated list of 20 gets 20 stars. (The workbook keeps its cut at 15 because it
    # carries all 50 rows and top15 genuinely picks a subset.) Override with an explicit "top": n,
    # or by setting "top15" per name, either of which wins over the default.
    if not any("top15" in v for v in out.values()):
        top_n = raw.get("top")
        try:
            top_n = int(top_n) if top_n is not None else None
        except (TypeError, ValueError):
            top_n = None
        ranked = sorted((v["priority"], s) for s, v in out.items() if v["priority"] is not None)
        for i, (_, s) in enumerate(ranked):
            out[s]["top15"] = True if top_n is None else i < top_n
        for s, v in out.items():
            v.setdefault("top15", top_n is None)

    return {"log_date": str(raw.get("log_date") or date.today().isoformat()),
            "log_time": str(raw["log_time"]) if raw.get("log_time") else None,
            "universe": raw.get("universe"), "count": len(out), "names": out}

def read_log(xlsx):
    try:
        import openpyxl
    except ImportError:
        print("  openpyxl not installed — run: pip install openpyxl   (roster.json will not be written)"); return None
    wb = openpyxl.load_workbook(xlsx, read_only=True, data_only=True)
    if "Log" not in wb.sheetnames: print("  workbook has no 'Log' sheet"); return None
    rows = wb["Log"].iter_rows(values_only=True)
    header = next(rows)
    col = {h: i for i, h in enumerate(header) if h in LANE_KEYS}
    missing = [k for k in ("Date", "Ticker", "Trade Priority", "Play", "Coil", "Structure") if k not in col]
    if missing: print(f"  Log sheet is missing columns {missing} — roster.json not written"); return None
    latest, picked = None, []
    for r in rows:
        d = r[col["Date"]]
        if not isinstance(d, (datetime, date)): continue
        d = d.date() if isinstance(d, datetime) else d
        if latest is None or d > latest: latest, picked = d, []
        if d == latest: picked.append(r)
    wb.close()
    if not picked: return None
    names = {}
    for r in picked:
        g = lambda k: r[col[k]] if k in col else None
        sym = str(g("Ticker")).strip().upper()
        zone = str(g("Zone Position") or "")
        m = re.search(r"\b(LONG|SHORT)\b", zone); bm = re.search(r"\((PRIME|OK)\)|\bINSIDE\b", zone)
        sp, pr = g("MA Spread %"), g("Trade Priority")
        names[sym] = {"priority": int(pr) if isinstance(pr, (int, float)) else None,
                      "play": g("Play"), "coil": g("Coil"), "structure": g("Structure"), "alignment": g("Alignment"),
                      "dir": m.group(1) if m else None, "rvol_rank": g("Rank (RVOL)"), "zone": zone,
                      "spread": round(float(sp), 3) if isinstance(sp, (int, float)) else None,
                      "band": (bm.group(1) or "INSIDE") if bm else None}
    ranked = sorted((v["priority"], s) for s, v in names.items() if v["priority"] is not None)
    for i, (_, s) in enumerate(ranked): names[s]["top15"] = i < 15
    t = picked[0][col["Time (ET)"]] if "Time (ET)" in col else None
    return {"log_date": latest.isoformat(), "log_time": str(t) if t else None,
            "universe": picked[0][col["Universe"]] if "Universe" in col else None, "count": len(names), "names": names}

def _write_json(path, doc):
    tmp = path + ".tmp"
    with open(tmp, "w") as f: json.dump(doc, f, separators=(",", ":"))
    os.replace(tmp, path)


def surface_error(msg):
    """Make a Log failure VISIBLE on the dashboard instead of only in this console.

    Never destroys a good roster.json: if one exists, the error is stamped onto it and the
    last known names are kept, so pre-open lanes survive a morning where the Log did not run.
    Only when there is no roster.json at all is an empty one written, which v1.13.1 of the
    dashboard treats as asserting nothing."""
    global LAST_ERROR
    if msg == LAST_ERROR: return          # already surfaced; don't rewrite every cycle
    LAST_ERROR = msg
    doc = None
    if os.path.exists(ROSTER_OUT):
        try:
            with open(ROSTER_OUT, encoding="utf-8") as f: doc = json.load(f)
        except Exception: doc = None
    if not isinstance(doc, dict):
        doc = {"log_date": None, "log_time": None, "universe": None, "count": 0, "names": {}}
    doc["error"] = msg
    doc["error_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
    try:
        _write_json(ROSTER_OUT, doc)
        kept = len(doc.get("names") or {})
        print(f"  roster: {msg}" + (f"  (kept the last {kept} names)" if kept else "  (no previous roster.json)"))
    except Exception as e:
        print(f"  roster: {msg}  — and could not write roster.json: {e}")


def write_roster(xlsx, shortlist=None, force=False):
    """The NEWEST Log source wins (2026-09-11).

    Rules, in order:
      1. Of the sources that exist, use whichever was modified most recently. Plain mtime —
         not a judgement about content. A fresh workbook beats a day-old shortlist.json and
         vice versa, so neither file has to be cleaned up to stop shadowing the other.
      2. shortlist.json wins an exact mtime tie: it is the cheaper read, and when bernie_scan
         eventually writes both within the same second they carry the same rows anyway.
      3. If the newest source fails to read, fall back to the next-newest rather than giving up.
         An error is surfaced only when every source fails.
      4. The change-gate watches ALL sources, so repairing either one is picked up next cycle.
    """
    global LOG_MTIME, LOG_SOURCE, LAST_ERROR

    cands = []
    if shortlist and os.path.exists(shortlist):
        cands.append((os.path.getmtime(shortlist), 1, shortlist, read_shortlist))
    if os.path.exists(xlsx):
        cands.append((os.path.getmtime(xlsx), 0, xlsx, read_log))

    if not cands:
        surface_error(f"no Daily Log source — neither {os.path.basename(shortlist) if shortlist else 'shortlist.json'} "
                      f"nor {os.path.basename(xlsx)} was found")
        return

    sig = tuple(sorted((p, m) for m, _, p, _ in cands))
    if not force and sig == LOG_MTIME: return
    LOG_MTIME = sig

    cands.sort(key=lambda c: (c[0], c[1]), reverse=True)     # newest first; shortlist.json breaks a tie
    doc = src = None
    why = []
    for mt, _, path, reader in cands:
        name = os.path.basename(path)
        try:
            d = reader(path)
        except Exception as e:
            why.append(f"{name}: {e}")
            continue
        if not d or not d.get("names"):
            why.append(f"{name}: produced no names")
            continue
        doc, src = d, path
        if why:      # we fell back past something newer — say so, it is not a silent substitution
            print(f"  roster: using {name} after {'; '.join(why)}")
        break

    if doc is None:
        surface_error("could not read any Daily Log source — " + "; ".join(why))
        return

    LOG_SOURCE = src
    LAST_ERROR = None
    doc["asof"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
    doc["source"] = os.path.basename(src)
    doc.pop("error", None); doc.pop("error_at", None)
    _write_json(ROSTER_OUT, doc)
    top = [s for s, v in doc["names"].items() if v.get("top15")]
    print(f"{datetime.now():%H:%M:%S}  wrote roster.json — {doc['source']} {doc['log_date']} {doc['log_time'] or ''}, "
          f"{doc['count']} names, top15: {', '.join(sorted(top, key=lambda s: doc['names'][s]['priority'] or 99))}")

# ---------- HTTP: static files + webhook ----------
TOKEN = None

class Handler(SimpleHTTPRequestHandler):
    def log_message(self, *a): pass
    def end_headers(self):
        self.send_header("Cache-Control", "no-store"); super().end_headers()
    def do_GET(self):
        if self.path.startswith("/status"):
            body = json.dumps({"posts": STATS["posts"], "last_post": STATS["last"], "batches": STATS["batches"], "names": len(QUOTES)}).encode()
            self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(body); return
        super().do_GET()
    def do_POST(self):
        if self.path.rstrip("/") != f"/hook/{TOKEN}":
            self.send_response(403); self.end_headers(); return
        n = int(self.headers.get("Content-Length") or 0)
        raw = self.rfile.read(n)
        try:
            doc = json.loads(raw.decode("utf-8", "replace"))
            kind, batch, count = ingest(doc)
            write_quotes()
            print(f"{datetime.now():%H:%M:%S}  {kind:<10} {batch:<10} {count:>2} names  (bar {doc.get('t')})")
            self.send_response(200); self.end_headers(); self.wfile.write(b"ok")
        except Exception as e:
            print(f"{datetime.now():%H:%M:%S}  bad payload: {e}  {raw[:200]!r}")
            self.send_response(400); self.end_headers()

def load_token(arg):
    if arg: return arg
    if os.path.exists(TOKEN_FILE): return open(TOKEN_FILE).read().strip()
    t = secrets.token_hex(12)
    with open(TOKEN_FILE, "w") as f: f.write(t)
    return t

SAMPLE = {"b": "test", "mode": "stocks", "t": "10:04", "d": [
    {"s": "HPE", "l": 56.14, "o": 57.8, "p": 58.9, "h": 57.93, "lo": 56.08, "v": 3178420, "av": 13590658, "a": 5.19, "a14": 2.8536,
     "c2": 56.14, "m2": [57.3275, 57.5453], "m15": [57.711, 52.7548], "x": None},
    {"s": "RKLB", "l": 63.71, "o": 61.65, "p": 63.07, "h": 64.77, "lo": 61.53, "v": 2828868, "av": 10196937, "a": 4.64, "a14": 2.7686,
     "c2": 63.71, "m2": [63.507, 63.2654], "m15": [63.8975, 63.7862], "x": [1, "09:45", 1]}]}

PLUGIN_SAMPLE = {"kind": "levelmaker", "b": "demo", "t": "10:06", "d": [
    {"s": "HPE", "level": 57.25, "state": "TESTING", "note": "prior-day VWAP"}]}

def main():
    global TOKEN
    ap = argparse.ArgumentParser()
    ap.add_argument("--port", type=int, default=8765)
    ap.add_argument("--xlsx", default=XLSX_DEFAULT, help="fallback Daily Log source: core_shortlist_log.xlsx (needs openpyxl)")
    ap.add_argument("--shortlist", default=SHORTLIST_DEFAULT, help="preferred Daily Log source: shortlist.json (stdlib only)")
    ap.add_argument("--token", default=None, help="webhook token (default: tv_token.txt, generated on first run)")
    ap.add_argument("--roster-only", action="store_true", help="write roster.json from the Log source and exit")
    ap.add_argument("--test", action="store_true", help="post a sample payload to a running receiver and exit")
    a = ap.parse_args()
    TOKEN = load_token(a.token)
    if a.roster_only: write_roster(a.xlsx, a.shortlist, force=True); return
    if a.test:
        for payload in (SAMPLE, PLUGIN_SAMPLE):
            req = urllib.request.Request(f"http://127.0.0.1:{a.port}/hook/{TOKEN}", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"})
            print(payload.get("kind", "birdseye"), "->", urllib.request.urlopen(req, timeout=5).read().decode())
        return
    os.chdir(HERE)
    srv = ThreadingHTTPServer(("0.0.0.0", a.port), Handler)   # 0.0.0.0 so the tunnel client can reach it; the token gates POSTs
    print(f"dashboard:  http://localhost:{a.port}/birdseye-dashboard-v1.13.html")
    print(f"webhook:    http://localhost:{a.port}/hook/{TOKEN}   <- expose via your tunnel; TradingView alerts POST here")
    print(f"status:     http://localhost:{a.port}/status")
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    while True:
        try: write_roster(a.xlsx, a.shortlist)
        except Exception as e: print(f"  roster: {e}")
        time.sleep(30)

if __name__ == "__main__":
    main()
