"""catalyst_build.py — builds catalysts.json for the Bird's Eye dashboard.

Sources, in tier order (same order as Bernie's catalyst check):
  1. Bernie Wire (--wire)          -> Neo's own drops since the last session close; always listed first
  2. Alpha Vantage NEWS_SENTIMENT  -> headlines tagged to a CORE50 name, with AV's per-ticker sentiment
  +  Alpha Vantage EARNINGS_CALENDAR -> earnings due within EARN_DAYS calendar days
AV calls are made one after the other (free tier: 25/day, 1/sec, never in parallel).

Output (beside quotes.json, served by the receiver as a static file — no receiver change):
  {"asof", "date", "window_from", "names": {SYM: {"cat": bool, "tone": "bull"|"bear"|"neutral",
     "kinds": [...], "earnings": {...}|null, "items": [...]}}}

`cat` is true only for a real catalyst: earnings in the window, or a headline that matches a catalyst
kind (rating change, earnings/guidance, FDA/trial, deal/contract, financing, big mover). Plain mentions
and holdings/insider filings are kept in `items` for the detail sheet but do not light the icon.

Usage
  python catalyst_build.py --apikey KEY            # fetch from Alpha Vantage and write catalysts.json
  python catalyst_build.py --earnings e.csv --news n.json --wire wire.json   # from saved responses (no calls)
  wire.json = the Bernie Wire page's <script id="bernie-feed"> JSON ({"entries":[{ts,ticker,headline,note,tone?}]});
  an entry's optional "tone" (bull/bear/neutral) overrides the keyword read.
  python catalyst_build.py --selftest
"""
import argparse, csv, io, json, os, re, sys, time, urllib.request
from datetime import datetime, timedelta, timezone

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "catalysts.json")
CORE50 = ("AXTI MRNA AEHR AAOI TEM SMTC HUT MXL TWST DOCN CRCL RBRK SEI P TTMI FROG MSTR MP TXG INTC "
          "WING CRWV EIX AFRM DKS ASTS PBF VSXY AVAV HOOD HPE HNGE SYRE ANF DAR RKLB VSAT NOW SWKS KGS "
          "CAVA CVNA FLUT ESTC MCHP SFM FLR CART CTSH FCX").split()
EARN_DAYS = 7        # earnings within this many calendar days count as a catalyst
MIN_REL = 0.5        # AV relevance below this = the article is not about the name
HARD_REL = 0.8       # a catalyst kind only counts when the article is mainly about the name;
                     # below this a sector piece ("Rocket Lab jumps..." tagged to HPE) is a mention
TONE_CUT = 0.15      # AV's own boundary between Neutral and Somewhat-Bullish/Bearish
MAX_ITEMS = 4

# Order matters: first match wins. "soft" kinds are shown but never light the icon.
KINDS = [
    ("mention",  r"transcript|to present at|to participate|conference|investor alert|fiduciar|law firm|class action|investigat|stocks? to watch|final trades"),
    ("rating",   r"upgrad|downgrad|price target|initiat|reiterat|outperform|underperform|overweight|underweight|strong-buy|\bbuy rating|\bsell rating"),
    ("earnings", r"earnings|quarterly results|\bq[1-4]\b|\beps\b|guidance|outlook|forecast|preannounc"),
    ("fda",      r"\bfda\b|approval|clinical|trial|phase [123i]"),
    ("deal",     r"acqui|merger|buyout|takeover|to buy |contract|partnership|awarded|\bdeal\b"),
    ("finance",  r"offering|dilut|convertible|buyback|repurchase|stock split|debt"),
    ("mover",    r"soar|surg|jump|plung|tumbl|sink|rall|spike|shares (are )?(up|down)|why .* (stock|shares)|what's (going on|happening)"),
]
# holdings must be tested before "mover" so "Sell Alert: X Cashes Out $2M" is not read as a price move
KINDS.insert(6, ("holdings", r"insider|sell alert|cashes out|sells [\d,]+ shares|buys [\d,]+ shares|invests \$|stake in|position in|13f|stock purchase"))
SOFT = {"holdings", "mention"}


def kind_of(title):
    t = title.lower()
    for k, rx in KINDS:
        if re.search(rx, t):
            return k
    return "mention"


# Keyword tone for Wire headlines (AV supplies its own sentiment). First rule that fires wins:
# a price-target move outranks the rating it is attached to ("Maintains Outperform, Lowers PT" = bearish).
TONE_RULES = [
    ("bull", r"raises price target|raises (its |the )?(q\d |fy|full-year |annual |\d{4} )?(adjusted )?(ebitda |revenue |sales )?(guidance|outlook)|upgrad"),
    ("bear", r"lowers price target|cuts price target|lowers (its |the )?.{0,20}(guidance|outlook)|downgrad|cuts? guidance"),
    ("bear", r"offering|convertible|dilut|warns|miss(es|ed)?\b|slides|slid\b|falls|fell\b|drops|plung|tumbl|sinks|trading lower|sell alert|cashes out|investor alert|investigat"),
    ("bull", r"initiat\w* .{0,40}(buy|overweight|outperform)|beat|met (its )?primary endpoint|repurchase|buyback|surg|soar|jump|gain|trading higher|rall|pops|record|awarded|wins\b|approval"),
]


def wire_tone(text):
    t = text.lower()
    for tone, rx in TONE_RULES:
        if re.search(rx, t):
            return tone
    return "neutral"


def et_offset(dt_utc):
    """US Eastern offset for a UTC datetime, without zoneinfo (Windows Python has no tz database without tzdata)."""
    y = dt_utc.year
    mar1 = datetime(y, 3, 1); start = mar1 + timedelta(days=(6 - mar1.weekday()) % 7 + 7)   # 2nd Sunday of March
    nov1 = datetime(y, 11, 1); end = nov1 + timedelta(days=(6 - nov1.weekday()) % 7)         # 1st Sunday of November
    naive = dt_utc.replace(tzinfo=None)
    return timedelta(hours=-4) if start + timedelta(hours=7) <= naive < end + timedelta(hours=6) else timedelta(hours=-5)


def last_session_close(now_utc):
    """UTC time of the most recent weekday 16:00 ET at or before now — the Wire's freshness window start.
    Weekends roll back to Friday. Market holidays are not modelled: the window then starts at the holiday's
    16:00, which only drops Wire entries dropped before 16:00 on the holiday itself."""
    off = et_offset(now_utc)
    et = now_utc.replace(tzinfo=None) + off
    d = et.date()
    if et.hour < 16:
        d -= timedelta(days=1)
    while d.weekday() >= 5:
        d -= timedelta(days=1)
    close_et = datetime(d.year, d.month, d.day, 16, 0)
    return (close_et - et_offset(datetime(d.year, d.month, d.day, 20))).replace(tzinfo=timezone.utc)


def tone_of(score):
    return "bull" if score >= TONE_CUT else "bear" if score <= -TONE_CUT else "neutral"


def parse_av_time(s):
    # AV: 20260918T160031
    try:
        return datetime.strptime(s, "%Y%m%dT%H%M%S")
    except Exception:
        return None


def build(earn_csv, news_doc, today, universe=CORE50, wire_doc=None, since_utc=None):
    uni = set(universe)
    names = {}

    def slot(sym):
        return names.setdefault(sym, {"cat": False, "tone": "neutral", "kinds": [], "earnings": None, "items": []})

    # --- earnings
    for r in csv.DictReader(io.StringIO(earn_csv or "")):
        sym = (r.get("symbol") or "").strip()
        if sym not in uni:
            continue
        try:
            d = datetime.strptime(r["reportDate"], "%Y-%m-%d").date()
        except Exception:
            continue
        days = (d - today).days
        if 0 <= days <= EARN_DAYS:
            n = slot(sym)
            when = (r.get("timeOfTheDay") or "").strip()
            label = "today" if days == 0 else "tomorrow" if days == 1 else d.strftime("%a %b %d").replace(" 0", " ")
            n["earnings"] = {"date": d.isoformat(), "days": days, "time": when or None,
                             "estimate": r.get("estimate") or None,
                             "label": f"Earnings {label}{' ' + when if when else ''}"}
            n["cat"] = True
            n["kinds"].append("earnings")

    # --- news
    per = {}
    for a in (news_doc or {}).get("feed", []):
        title = a.get("title") or ""
        for ts in a.get("ticker_sentiment", []):
            sym = ts.get("ticker")
            if sym not in uni:
                continue
            try:
                rel = float(ts.get("relevance_score", 0)); sc = float(ts.get("ticker_sentiment_score", 0))
            except ValueError:
                continue
            if rel < MIN_REL:
                continue
            per.setdefault(sym, []).append({
                "kind": kind_of(title) if rel >= HARD_REL else "mention", "title": title, "url": a.get("url"), "source": a.get("source"),
                "time": a.get("time_published"), "tone": tone_of(sc), "score": round(sc, 3), "rel": round(rel, 3)})

    # --- Bernie Wire (tier 1): his own drops since the last session close
    for e in (wire_doc or {}).get("entries", []):
        sym = (e.get("ticker") or "").strip().upper()
        if sym not in uni:
            continue
        try:
            ts = datetime.fromisoformat(e["ts"].replace("Z", "+00:00"))
        except Exception:
            continue
        if since_utc and ts < since_utc:
            continue
        head = e.get("headline") or ""
        text = head + " " + (e.get("note") or "")
        tone = e.get("tone") if e.get("tone") in ("bull", "bear", "neutral") else wire_tone(text)
        per.setdefault(sym, []).append({
            "kind": kind_of(head), "title": head, "url": None, "source": "Bernie Wire", "wire": True,
            "time": ts.strftime("%Y%m%dT%H%M%S"), "tone": tone,
            "score": {"bull": 0.5, "bear": -0.5, "neutral": 0.0}[tone], "rel": 1.0})

    for sym, items in per.items():
        # hard catalysts first, then most relevant, then newest
        items.sort(key=lambda i: i["time"] or "", reverse=True)          # newest first ...
        items.sort(key=lambda i: (i["kind"] in SOFT, not i.get("wire"), -i["rel"]))  # hard first, Wire first, most relevant
        seen, uniq = set(), []
        for i in items:
            key = i["title"].lower()[:60]
            if key in seen:
                continue
            seen.add(key); uniq.append(i)
        n = slot(sym)
        n["items"] = uniq[:MAX_ITEMS]
        hard = [i for i in uniq if i["kind"] not in SOFT]
        if hard:
            n["cat"] = True
            for i in hard:
                if i["kind"] not in n["kinds"]:
                    n["kinds"].append(i["kind"])
            wt = lambda i: i["rel"] * (2 if i.get("wire") else 1)   # tier 1 counts double
            w = sum(wt(i) for i in hard)
            n["score"] = round(sum(i["score"] * wt(i) for i in hard) / w, 3) if w else 0
            n["wire"] = sum(1 for i in hard if i.get("wire"))
            n["tone"] = tone_of(n["score"])

    return names


def av_get(params, key):
    q = "&".join(f"{k}={v}" for k, v in {**params, "apikey": key}.items())
    with urllib.request.urlopen("https://www.alphavantage.co/query?" + q, timeout=60) as r:
        return r.read().decode("utf-8")


def write(names, today, window_from, sources):
    doc = {"asof": datetime.now(timezone.utc).isoformat(timespec="seconds"), "date": today.isoformat(),
           "window_from": window_from, "source": " + ".join(sources),
           "count": sum(1 for n in names.values() if n["cat"]), "names": names}
    tmp = OUT + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(doc, f, indent=1)
    os.replace(tmp, OUT)  # atomic: the dashboard may fetch mid-write
    return doc


def selftest():
    ok = 0
    def check(c, msg):
        nonlocal ok
        if not c:
            print("FAIL:", msg); sys.exit(1)
        ok += 1
    today = datetime(2026, 9, 18).date()
    csvt = ("symbol,name,reportDate,fiscalDateEnding,estimate,currency,timeOfTheDay\n"
            "INTC,Intel,2026-09-18,,0.2,USD,post-market\nHPE,HPE,2026-09-24,,,USD,\n"
            "AEHR,Aehr,2026-10-05,,,USD,\nZZZZ,Other,2026-09-18,,,USD,\n")
    news = {"feed": [
        {"title": "Hut 8 Upgraded by Wells Fargo to Strong-Buy", "url": "u1", "time_published": "20260918T120000",
         "ticker_sentiment": [{"ticker": "HUT", "relevance_score": "1", "ticker_sentiment_score": "0.4"}]},
        {"title": "Morgan Stanley Lowers Price Target on Edison", "url": "u2", "time_published": "20260918T110000",
         "ticker_sentiment": [{"ticker": "EIX", "relevance_score": "0.9", "ticker_sentiment_score": "-0.3"}]},
        {"title": "Bank of America Invests $129M in Darling", "url": "u3", "time_published": "20260918T100000",
         "ticker_sentiment": [{"ticker": "DAR", "relevance_score": "1", "ticker_sentiment_score": "0.5"}]},
        {"title": "Why Are Moderna Shares Soaring Today", "url": "u4", "time_published": "20260918T090000",
         "ticker_sentiment": [{"ticker": "MRNA", "relevance_score": "1", "ticker_sentiment_score": "0.5"},
                              {"ticker": "RKLB", "relevance_score": "0.2", "ticker_sentiment_score": "0.9"}]},
        {"title": "Is AeroVironment Becoming a Platform?", "url": "u5", "time_published": "20260918T080000",
         "ticker_sentiment": [{"ticker": "AVAV", "relevance_score": "1", "ticker_sentiment_score": "0.5"}]},
    ]}
    n = build(csvt, news, today)
    check(n["INTC"]["cat"] and n["INTC"]["earnings"]["days"] == 0, "earnings today flags")
    check("today" in n["INTC"]["earnings"]["label"] and "post-market" in n["INTC"]["earnings"]["label"], "earnings label")
    check(n["HPE"]["cat"] and n["HPE"]["earnings"]["days"] == 6, "earnings within window flags")
    check("AEHR" not in n, "earnings beyond window ignored")
    check("ZZZZ" not in n, "non-universe ignored")
    check(n["HUT"]["cat"] and n["HUT"]["tone"] == "bull" and n["HUT"]["kinds"] == ["rating"], "upgrade = bullish rating")
    check(n["EIX"]["cat"] and n["EIX"]["tone"] == "bear", "PT cut = bearish")
    check(not n["DAR"]["cat"] and n["DAR"]["items"][0]["kind"] == "holdings", "holdings shown, no icon")
    check(n["MRNA"]["cat"] and n["MRNA"]["kinds"] == ["mover"], "mover flags")
    check("RKLB" not in n, "low relevance ignored")
    check(not n["AVAV"]["cat"] and n["AVAV"]["items"][0]["kind"] == "mention", "plain mention, no icon")
    n2 = build("", {"feed": [{"title": "Rocket Lab Jumps 6% on Deal", "time_published": "x",
        "ticker_sentiment": [{"ticker": "HPE", "relevance_score": "0.6", "ticker_sentiment_score": "0.3"}]}]}, today)
    check(not n2["HPE"]["cat"] and n2["HPE"]["items"][0]["kind"] == "mention", "side-mention never lights the icon")
    check(kind_of("Freeport Shares Up 2.3% on Analyst Upgrade") == "rating", "rating beats mover")
    # Wire
    since = datetime(2026, 9, 17, 20, 0, tzinfo=timezone.utc)
    wire = {"entries": [
        {"ts": "2026-09-18T11:00:00.000Z", "ticker": "CRWV", "headline": "CoreWeave Prices Upsized $3.7 Billion Convertible Senior Notes Offering"},
        {"ts": "2026-09-18T11:01:00.000Z", "ticker": "CAVA", "headline": "CAVA Group Board Approves $100M Share Repurchase Program"},
        {"ts": "2026-09-18T11:59:00.000Z", "ticker": "WING", "headline": "RBC Capital Maintains Outperform on Wingstop, Lowers Price Target to $150"},
        {"ts": "2026-09-18T11:59:00.000Z", "ticker": "AFRM", "headline": "Sell Alert: Siphelele Jiyane Cashes Out $1.95M In Affirm Holdings Stock"},
        {"ts": "2026-09-18T11:59:00.000Z", "ticker": "CRCL", "headline": "Full Transcript: Circle Internet Group Q2 2026 Earnings Call"},
        {"ts": "2026-09-17T12:00:00.000Z", "ticker": "SEI", "headline": "Solaris Raises Q3 Adjusted EBITDA Guidance"},
        {"ts": "2026-09-18T11:59:00.000Z", "ticker": "EIX", "headline": "Some Wire Upgrade", "tone": "bull"},
        {"ts": "2026-09-18T11:59:00.000Z", "ticker": "AAPL", "headline": "Upgrade"},
    ]}
    w = build("", news, today, wire_doc=wire, since_utc=since)
    check(w["CRWV"]["cat"] and w["CRWV"]["tone"] == "bear" and w["CRWV"]["kinds"] == ["finance"], "convertible = bearish financing")
    check(w["CAVA"]["cat"] and w["CAVA"]["tone"] == "bull", "buyback = bullish")
    check(w["WING"]["tone"] == "bear" and w["WING"]["kinds"] == ["rating"], "PT cut outranks the Outperform")
    check(not w["AFRM"]["cat"] and w["AFRM"]["items"][0]["kind"] == "holdings", "insider sale shown, no icon")
    check(not w["CRCL"]["cat"], "call transcript is not a catalyst")
    check("SEI" not in w, "Wire entry before the last close is not fresh")
    check("AAPL" not in w, "Wire entry outside CORE50 ignored")
    check(w["EIX"]["items"][0]["source"] == "Bernie Wire", "Wire listed first, ahead of AV")
    check(w["EIX"]["tone"] == "bull", "Wire (x2, tone override) outweighs a bearish AV rating")
    check(wire_tone("Moderna shares are trading lower after Rothschild downgraded") == "bear", "downgrade bearish")
    check(wire_tone("Cantor initiated coverage with an Overweight rating") == "bull", "initiation bullish")
    # session-close window
    fri = last_session_close(datetime(2026, 9, 21, 12, 35, tzinfo=timezone.utc))   # Mon 08:35 EDT
    check(fri == datetime(2026, 9, 18, 20, 0, tzinfo=timezone.utc), "Monday morning window starts Friday 16:00 ET")
    thu = last_session_close(datetime(2026, 9, 18, 12, 35, tzinfo=timezone.utc))
    check(thu == datetime(2026, 9, 17, 20, 0, tzinfo=timezone.utc), "Friday morning window starts Thursday 16:00 ET")
    est = last_session_close(datetime(2026, 11, 3, 13, 35, tzinfo=timezone.utc))   # Tue 08:35 EST
    check(est == datetime(2026, 11, 2, 21, 0, tzinfo=timezone.utc), "EST close is 21:00 UTC")
    print(f"selftest: {ok} assertions passed")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--apikey", default=os.environ.get("ALPHAVANTAGE_API_KEY"))
    ap.add_argument("--earnings"); ap.add_argument("--news"); ap.add_argument("--wire")
    ap.add_argument("--hours", type=int, default=18, help="news look-back (default 18h: prior close to now)")
    ap.add_argument("--selftest", action="store_true")
    a = ap.parse_args()
    if a.selftest:
        return selftest()
    now = datetime.now(timezone.utc)
    today = (now.replace(tzinfo=None) + et_offset(now)).date()          # ET date — what the dashboard compares
    close = last_session_close(now)
    since = close.strftime("%Y%m%dT%H%M")                                # AV news from the last close too
    wire = None
    if a.wire:
        raw = open(a.wire, encoding="utf-8").read()
        m = re.search(r'<script type="application/json" id="bernie-feed">(.*?)</script>', raw, re.S)
        wire = json.loads(m.group(1) if m else raw)   # accepts the page HTML or the bare JSON
    if a.earnings and a.news:
        earn = open(a.earnings, encoding="utf-8").read()
        if earn.lstrip().startswith("{"):
            earn = json.loads(earn).get("result", "")
        news = json.load(open(a.news, encoding="utf-8"))
    elif a.apikey:
        earn = av_get({"function": "EARNINGS_CALENDAR", "horizon": "3month"}, a.apikey)
        time.sleep(2)  # free tier: 1 request/second, sequential only
        news = json.loads(av_get({"function": "NEWS_SENTIMENT", "time_from": since, "limit": 1000, "sort": "LATEST"}, a.apikey))
        if "feed" not in news:
            sys.exit("Alpha Vantage refused the news call: " + json.dumps(news)[:300])
    else:
        sys.exit("Give --apikey (or ALPHAVANTAGE_API_KEY), or --earnings and --news files.")
    names = build(earn, news, today, wire_doc=wire, since_utc=close)
    srcs = (["Bernie Wire"] if wire else []) + ["Alpha Vantage EARNINGS_CALENDAR + NEWS_SENTIMENT"]
    doc = write(names, today, close.isoformat(), srcs)
    flagged = {s: n for s, n in names.items() if n["cat"]}
    print(f"catalysts.json: {len(flagged)} flagged of {len(names)} with news/earnings")
    for s, n in sorted(flagged.items()):
        print(f"  {s:5} {n['tone']:7} {','.join(n['kinds']):22} {'Wire x' + str(n.get('wire')) if n.get('wire') else ''}")


if __name__ == "__main__":
    main()
