"""Regenerate the dashboard and publish it to GitHub as index.html.

Runs on the hour and half hour, 06:00-23:00 (SoccerPublishDashboard). Nothing
here trades. The trigger is deliberately aligned to :00/:30 rather than an
arbitrary offset so a reader knows when to expect a fresh page -- note the RUN
starts then, and the published page lands 2-4 minutes later once the resolve
steps and the render have finished.

WHY THE SECRET SCAN IS A HARD GATE. When a human ran this by hand, someone
eyeballed the file before it went public. Unattended hourly publishing removes
that check, so it is replaced with an automated one that ABORTS the push rather
than warning: the API-Football key is hardcoded in at least five source files
and .env holds Kalshi/Poly-US/pinnapi credentials, so a future dashboard change
that happens to echo a config value would otherwise publish it silently. A
missed publish costs an hour of staleness; a leaked key costs a rotation and
lives forever in the git history.

TOKEN HANDLING. GITHUB_TOKEN is read from .env at run time and used only as a
one-shot push URL with credential.helper disabled, so it is never written into
the clone's git config or the OS credential store. It is redacted from all
output. GIT_TERMINAL_PROMPT=0 makes auth failures fail fast instead of blocking
on a prompt that cannot render in a scheduled task (that hang cost 5 minutes on
2026-08-09).

ENCRYPTED AT REST (2026-08-13). The Pages site and the repo behind it are both
world-readable, so the page is AES-256-GCM encrypted at publish time and
decrypted in the browser from a passphrase (DASH_PASSWORD in .env). See
encrypt_page for what that does and does not protect -- in short, it defends
against a passer-by reading the URL, not against a determined offline attacker
with a weak passphrase, and it cannot un-publish the snapshots already pushed
in the clear.

NO-OP WHEN UNCHANGED. If the regenerated dashboard's PLAINTEXT is identical to
what is already published, nothing is committed -- otherwise the repo would
collect 18 empty-diff commits a day. The comparison is on a plaintext hash
embedded in the published page, because a fresh salt and IV make the ciphertext
differ on every single run.

Usage:  python main\\publish_dashboard.py [--dry]
"""
import base64
import hashlib
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path

from cryptography.hazmat.primitives.ciphers.aead import AESGCM

ROOT = Path(r'E:\soccer')
DASH = ROOT / 'dashboard.html'
ENV = ROOT / '.env'
CLONE = ROOT / '.publish' / 'football-stuff'
REPO = 'github.com/lsmyrtaj/football-stuff'
BRANCH = 'main'

# Patterns that must NEVER reach a public page. Deliberately broad: a false
# positive costs one skipped hourly publish, a false negative costs a key.
#
# NOTE: no literal secret appears here on purpose. An earlier draft hardcoded
# the API-Football key as a detection pattern, which quietly made this file a
# SIXTH place that key lives. Real secrets are instead loaded from .env at run
# time (see literal_secrets) -- that catches every credential we hold, including
# ones added later, without this file ever containing one.
FORBIDDEN = {
    'long hex secret': r'\b[0-9a-f]{32,}\b',
    'github token':    r'gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}',
    'private key':     r'-----BEGIN [A-Z ]*PRIVATE KEY-----',
    'email address':   r'[\w.\-]+@[\w\-]+\.\w{2,}',
    'windows path':    r'[A-Za-z]:' + re.escape('\\'),
    'assignment':      r'(?i)\b(secret|token|password|passwd|api[_-]?key)\b\s*[:=]\s*\S',
}


def literal_secrets():
    """Every value in .env, so the scan blocks OUR actual credentials verbatim.

    Short values are skipped -- a 6-character setting would match half the page
    and block every publish.
    """
    out = []
    try:
        for ln in ENV.read_text(encoding='utf-8').splitlines():
            ln = ln.strip()
            if not ln or ln.startswith('#') or '=' not in ln:
                continue
            k, v = ln.split('=', 1)
            v = v.strip().strip('"').strip("'")
            if len(v) >= 12:
                out.append((k.strip(), v))
    except OSError:
        pass
    return out


def env(key):
    try:
        for ln in ENV.read_text(encoding='utf-8').splitlines():
            ln = ln.strip()
            if ln.startswith(f'{key}='):
                return ln.split('=', 1)[1].strip()
    except OSError:
        pass
    return None


ALERT_MARK = ROOT / '.publish_err_last_email'
ALERT_EVERY_H = 12


def alert_stale(subject, body_html):
    """Email that the published page has gone stale, at most every ALERT_EVERY_H.

    Rate limited by a marker file keyed on the subject: this runs every 30
    minutes, and an alert that arrives 48 times a day is one that gets filtered,
    which would leave us exactly where we started.
    """
    try:
        prev = ALERT_MARK.read_text(encoding='utf-8').split('\n', 1)
        last_ts, last_sub = float(prev[0]), (prev[1] if len(prev) > 1 else '')
        import time as _t
        if last_sub == subject and _t.time() - last_ts < ALERT_EVERY_H * 3600:
            return False
    except (OSError, ValueError, IndexError):
        pass
    try:
        sys.path.insert(0, str(ROOT / 'main'))
        import smtplib
        from email.mime.text import MIMEText
        from daily_signals import (EMAIL_ADDRESS, EMAIL_RECIPIENT,
                                   EMAIL_APP_PASSWORD)
        if not EMAIL_ADDRESS or not EMAIL_APP_PASSWORD:
            return False
        msg = MIMEText(body_html, 'html')
        msg['Subject'] = f'[soccer] dashboard NOT publishing -- {subject}'
        msg['From'], msg['To'] = EMAIL_ADDRESS, EMAIL_RECIPIENT
        with smtplib.SMTP_SSL('smtp.gmail.com', 465) as srv:
            srv.login(EMAIL_ADDRESS, EMAIL_APP_PASSWORD)
            srv.sendmail(EMAIL_ADDRESS, EMAIL_RECIPIENT, msg.as_string())
        import time as _t
        ALERT_MARK.write_text(f'{_t.time()}\n{subject}', encoding='utf-8')
        return True
    except Exception as e:                      # never let alerting break publish
        print(f'  (could not send stale-dashboard alert: '
              f'{type(e).__name__}: {e})')
        return False


def scan(text, include_env=True):
    """Return list of (label, sample) hits. Empty list = safe to publish."""
    hits = []
    for label, pat in FORBIDDEN.items():
        m = re.findall(pat, text)
        if m:
            s = m[0] if isinstance(m[0], str) else str(m[0])
            hits.append((label, s[:24]))
    if include_env:
        for name, val in literal_secrets():
            if val in text:
                # never echo the value itself, only which key matched
                hits.append((f'.env value {name}', '<redacted>'))
    return hits


# ── CLIENT-SIDE ENCRYPTION ───────────────────────────────────────────────────
# GitHub Pages serves static files with no server-side auth, so a real login is
# not available here. Instead the whole document is AES-256-GCM encrypted at
# publish time and decrypted in the reader's browser from a typed passphrase.
#
# WHAT THIS BUYS AND WHAT IT DOES NOT. The ciphertext is world-downloadable and
# stays that way permanently -- every hourly snapshot lands in a public git
# history that is not being rewritten. So the only thing between a stranger and
# the contents is the passphrase's resistance to an OFFLINE attack: there is no
# rate limiting, no lockout, and no revocation. Rotating DASH_PASSWORD protects
# future snapshots only; every snapshot already pushed stays decryptable by the
# old one forever. PBKDF2 at PBKDF2_ITERS makes each guess cost real work, which
# is what makes a long random passphrase worth something and a memorable one
# worth very little.
#
# AES-GCM is authenticated, so a wrong passphrase fails as a clean decrypt error
# rather than rendering garbage.
PBKDF2_ITERS = 310_000

_LOCK_PAGE = """<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dashboard</title>
<!-- plain-sha256-b64: __PLAINHASH__ -->
<style>
 :root{color-scheme:light dark}
 body{margin:0;min-height:100vh;display:grid;place-items:center;
      font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
      background:#fbfbfc;color:#16181d}
 @media (prefers-color-scheme:dark){body{background:#0f1115;color:#e7e9ee}}
 form{width:min(92vw,320px);display:flex;flex-direction:column;gap:10px}
 h1{font-size:15px;font-weight:600;margin:0 0 2px;letter-spacing:.01em}
 input,button{font:inherit;padding:10px 12px;border-radius:8px;
      border:1px solid #c7ccd6;background:#fff;color:inherit}
 @media (prefers-color-scheme:dark){input,button{background:#181b21;border-color:#333a46}}
 button{cursor:pointer;font-weight:600;border-color:transparent;background:#2f6feb;color:#fff}
 button:disabled{opacity:.6;cursor:progress}
 #e{min-height:1.2em;font-size:13px;color:#b0304a}
 .h{opacity:.6;font-size:12.5px}
</style>
</head><body>
<form id="f" autocomplete="on">
 <h1>Soccer dashboard</h1>
 <input id="p" type="password" autocomplete="current-password" placeholder="Passphrase" autofocus>
 <button id="b" type="submit">Unlock</button>
 <div id="e"></div>
 <div class="h">Decrypted in your browser. Nothing is sent anywhere.</div>
</form>
<script>
var SALT="__SALT__",IV="__IV__",CT="__CT__",ITERS=__ITERS__;
function b64(s){return Uint8Array.from(atob(s),function(c){return c.charCodeAt(0)})}
function unlock(pw){
  return crypto.subtle.importKey("raw",new TextEncoder().encode(pw),"PBKDF2",false,["deriveKey"])
   .then(function(km){return crypto.subtle.deriveKey(
      {name:"PBKDF2",salt:b64(SALT),iterations:ITERS,hash:"SHA-256"},
      km,{name:"AES-GCM",length:256},false,["decrypt"])})
   .then(function(key){return crypto.subtle.decrypt({name:"AES-GCM",iv:b64(IV)},key,b64(CT))})
   .then(function(pt){return new TextDecoder().decode(pt)});
}
function show(html){document.open();document.write(html);document.close()}
document.getElementById("f").addEventListener("submit",function(ev){
  ev.preventDefault();
  var pw=document.getElementById("p").value,e=document.getElementById("e"),b=document.getElementById("b");
  e.textContent="";b.disabled=true;b.textContent="Decrypting\\u2026";
  unlock(pw).then(function(html){
    try{sessionStorage.setItem("dashpw",pw)}catch(_){}
    show(html);
  }).catch(function(){
    e.textContent="Wrong passphrase.";b.disabled=false;b.textContent="Unlock";
  });
});
// Re-unlock silently within the same tab so an hourly refresh does not re-prompt.
// sessionStorage dies with the tab; it is never written to disk by us.
(function(){
  var s=null;try{s=sessionStorage.getItem("dashpw")}catch(_){}
  if(!s)return;
  unlock(s).then(show).catch(function(){try{sessionStorage.removeItem("dashpw")}catch(_){}});
})();
// SELF-REFRESH. The page republishes every 30 minutes, but a tab left open --
// which on a phone is every tab -- shows whatever it decrypted whenever it was
// opened, forever. That is indistinguishable from "the dashboard stopped
// updating", and it is the thing that actually gets noticed.
//
// GitHub Pages serves this with Cache-Control: max-age=600 and lets us set no
// headers of our own, so a plain reload can be answered from cache. The
// cache-busting query param is what forces a real refetch. Reloading is safe
// because the passphrase is in sessionStorage: the tab re-unlocks silently.
// Skipped when the page is hidden, so a backgrounded tab does not refetch all
// night, and re-checked on wake, which is the moment a stale page is looked at.
(function(){
  var PERIOD=15*60*1000, last=Date.now();
  function refresh(){
    var u=location.pathname+"?t="+Date.now();
    location.replace(u);
  }
  function tick(){
    if(document.hidden)return;
    if(Date.now()-last>=PERIOD)refresh();
  }
  setInterval(tick,60*1000);
  document.addEventListener("visibilitychange",function(){
    if(!document.hidden)tick();
  });
})();
</script>
</body></html>
"""


def encrypt_page(html, password, plain_hash):
    """Wrap `html` as an AES-256-GCM payload the browser decrypts on unlock.

    Salt and IV are fresh per publish, so the ciphertext differs every run even
    when the content does not -- which is exactly why the no-op check compares
    the PLAINTEXT hash instead of the published bytes.
    """
    salt, iv = os.urandom(16), os.urandom(12)
    key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt,
                              PBKDF2_ITERS, dklen=32)
    ct = AESGCM(key).encrypt(iv, html.encode('utf-8'), None)
    b64 = lambda raw: base64.b64encode(raw).decode('ascii')
    return (_LOCK_PAGE
            .replace('__PLAINHASH__', plain_hash)
            .replace('__SALT__', b64(salt))
            .replace('__IV__', b64(iv))
            .replace('__CT__', b64(ct))
            .replace('__ITERS__', str(PBKDF2_ITERS)))


def plain_marker(html):
    """Fingerprint of the plaintext, embedded in the published page to give the
    no-op check something stable to compare against.

    BASE64, NOT HEX, deliberately. A 64-char hex digest matches the scan's
    'long hex secret' pattern, so a hex marker makes the published page look
    like it carries a leaked key the moment anyone points the scan at the
    payload rather than the plaintext -- which is a reasonable-looking
    hardening that would then abort every publish forever with a baffling
    error. Standard base64, not base64url, so the alphabet has no '-' and the
    marker can never accidentally close the HTML comment.
    """
    return base64.b64encode(
        hashlib.sha256(html.encode('utf-8')).digest()).decode('ascii')


def published_plain_hash(target):
    """Marker of the plaintext behind the currently published page, or None."""
    if not target.exists():
        return None
    m = re.search(r'<!-- plain-sha256-b64: ([A-Za-z0-9+/]{43}=) -->',
                  target.read_text(encoding='utf-8'))
    return m.group(1) if m else None


def run(args, cwd=None, token=None):
    # SAFE.DIRECTORY. The scheduled task runs as SYSTEM, but this clone is owned
    # by the interactive user. Git 2.35+ refuses to touch a repository owned by
    # a different account ("detected dubious ownership") and exits non-zero, so
    # every publish under SYSTEM failed with a bare exit code 1 and no output --
    # the task had no logging, so it looked like nothing happened at all.
    # Injected per-invocation rather than written into a gitconfig: SYSTEM's
    # global config lives in a profile nobody maintains, and a --system write
    # needs an elevated shell this script must not assume it has.
    if args and args[0] == 'git':
        safe = str(CLONE).replace('\\', '/')
        args = [args[0], '-c', f'safe.directory={safe}'] + list(args[1:])
    # ENCODING MUST BE EXPLICIT. text=True alone decodes with the Windows ANSI
    # codepage (cp1252), which throws UnicodeDecodeError inside subprocess's
    # reader THREAD the moment git prints a non-ASCII byte -- a team name in a
    # commit message is enough. The traceback surfaces on stderr but the call
    # still returns, so the failure looks like empty git output rather than an
    # error: a push could fail and be reported as success. Seen 2026-08-20.
    p = subprocess.run(args, cwd=cwd, capture_output=True, text=True,
                       encoding='utf-8', errors='replace',
                       env={**__import__('os').environ, 'GIT_TERMINAL_PROMPT': '0'},
                       timeout=180)
    out = (p.stdout or '') + (p.stderr or '')
    if token:
        out = out.replace(token, '<redacted>')
    return p.returncode, out.strip()


def main():
    dry = '--dry' in sys.argv
    stamp = datetime.now().strftime('%Y-%m-%d %H:%M')

    # Settle first, so the page shows results that finished in the last hour
    # rather than carrying them as "open" until midnight.
    #
    # resolve_pending_bets(), NOT resolve_bets.main() -- main() also emails, and
    # 18 nightly-results emails a day is not a feature. The nightly job stays the
    # only sender: it reports every bet with emailed_nightly unset, so anything
    # settled here is still reported there exactly once.
    #
    # Cheap now that settlement reads Kalshi first (free) and falls through to
    # API-Football only as a last resort -- ~95% of bets never touch the metered
    # source. Failure is non-fatal: a resolver problem must not stop publishing.
    try:
        sys.path.insert(0, str(ROOT / 'main'))
        from daily_signals import resolve_pending_bets
        resolve_pending_bets()
    except Exception as e:
        print(f'[{stamp}] resolve step failed (continuing): {str(e)[:200]}')

    # Stamp `ftr` onto finished fixtures in live_data. SEPARATE from the bet
    # resolver above and just as necessary: the FORWARD TEST reads live_data's
    # ftr column, not bet_history, so without this the whole "Forward by league"
    # table lags up to 24h behind the rest of the page. Found 2026-08-09 when
    # SC0 showed n=2 while four Scottish matches had finished that day with
    # scores present and ftr blank -- one of them a winner (Rangers 1-2
    # Hibernian, our bet at 0.17). Cheap: it derives the result from goals
    # already in the file rather than calling any API.
    rc, out = run([sys.executable, str(ROOT / 'main' / 'resolve_live_data.py')])
    if rc != 0:
        print(f'[{stamp}] resolve_live_data failed (continuing): {out[:200]}')

    rc, out = run([sys.executable, str(ROOT / 'main' / 'dashboard.py'), '--quiet'])
    if rc != 0:
        print(f'[{stamp}] dashboard.py failed rc={rc}: {out[:300]}')
        alert_stale('dashboard.py failed',
                    f'The dashboard build itself failed with rc={rc}. The '
                    f'published page is stale.<br><br><pre>{out[:1500]}</pre>')
        return 1
    if not DASH.exists():
        print(f'[{stamp}] no dashboard.html produced')
        alert_stale('no dashboard produced',
                    'dashboard.py exited cleanly but wrote no dashboard.html.')
        return 1

    html = DASH.read_text(encoding='utf-8')
    hits = scan(html)
    if hits:
        # Refuse rather than warn -- see module docstring.
        print(f'[{stamp}] ABORT: dashboard contains material that must not be public:')
        for label, sample in hits:
            print(f'    {label}: {sample!r}')
        # ...and SAY SO. Refusing quietly is how a stale page passes for a live
        # one: a leaked local path blocked every publish for three days while the
        # scheduled task went on reporting nothing but an exit code to a log
        # nobody reads. The scan doing its job must not look like nothing
        # happening.
        alert_stale(f'publish blocked: {hits[0][0]}',
                    'The dashboard was rebuilt but NOT published, because it '
                    'contains something that must not go public:<br><br>'
                    + '<br>'.join(f'<code>{label}</code>: {sample!r}'
                                  for label, sample in hits)
                    + '<br><br>The published page is now stale. Fix the source '
                      'of that content in dashboard.py and the next run will '
                      'publish normally.')
        return 2

    # ENCRYPT AFTER THE SCAN, NEVER BEFORE. The scan has to read the real
    # content: run it on the ciphertext and it would pass every time while
    # happily publishing a leaked key that anyone with the passphrase can read.
    password = env('DASH_PASSWORD')
    if not password:
        # Fail closed. Publishing in the clear because a config value is missing
        # is the one outcome this must never have -- the page would look exactly
        # as it always did while being readable by anyone.
        print(f'[{stamp}] no DASH_PASSWORD in .env -- refusing to publish in the clear')
        alert_stale('no DASH_PASSWORD',
                    'DASH_PASSWORD is missing from .env, so publishing was '
                    'refused rather than push the page unencrypted. The '
                    'published page is stale until that is set.')
        return 1
    plain_hash = plain_marker(html)
    payload = encrypt_page(html, password, plain_hash)

    token = env('GITHUB_TOKEN')
    if not token:
        print(f'[{stamp}] no GITHUB_TOKEN in .env -- cannot publish')
        return 1
    url = f'https://x-access-token:{token}@{REPO}'

    if not (CLONE / '.git').exists():
        CLONE.parent.mkdir(parents=True, exist_ok=True)
        rc, out = run(['git', 'clone', f'https://{REPO}', str(CLONE)], token=token)
        if rc != 0:
            print(f'[{stamp}] clone failed: {out[:300]}')
            return 1
    else:
        run(['git', 'fetch', 'origin', BRANCH], cwd=CLONE, token=token)
        run(['git', 'reset', '--hard', f'origin/{BRANCH}'], cwd=CLONE, token=token)

    target = CLONE / 'index.html'
    # Compare PLAINTEXT hashes, not published bytes: salt and IV are fresh every
    # run, so the ciphertext always differs and a byte comparison would commit
    # 18 identical-content snapshots a day.
    if published_plain_hash(target) == plain_hash:
        print(f'[{stamp}] unchanged -- nothing to publish')
        return 0
    target.write_text(payload, encoding='utf-8')

    if dry:
        print(f'[{stamp}] DRY RUN -- would publish {len(payload)} bytes '
              f'({len(html)} plaintext, encrypted)')
        return 0

    run(['git', 'add', 'index.html'], cwd=CLONE)
    rc, out = run(['git', '-c', 'user.name=lirse', '-c', 'user.email=lirsenm@gmail.com',
                   'commit', '-m', f'Dashboard snapshot {stamp}'], cwd=CLONE, token=token)
    if rc != 0 and 'nothing to commit' not in out:
        print(f'[{stamp}] commit failed: {out[:300]}')
        return 1
    rc, out = run(['git', '-c', 'credential.helper=', 'push', url, BRANCH],
                  cwd=CLONE, token=token)
    if rc != 0:
        print(f'[{stamp}] push failed: {out[:300]}')
        return 1
    print(f'[{stamp}] published {len(payload)} bytes encrypted '
          f'({len(html)} plaintext) -> {REPO}')
    return 0


if __name__ == '__main__':
    sys.exit(main())
