"""Serve the dashboard on the local network. No publishing, no upload.

WHY. The GitHub Pages route republished a 9MB encrypted page every 30 minutes,
which accumulated to a 2.2GB repository and got the account suspended for
excessive bandwidth. The fix is not a better host -- it is to stop uploading.
The page already exists on this machine; a phone can just fetch it.

WHAT IT DOES NOT SERVE. A bare `python -m http.server` over E:\\soccer would
expose .env (Kalshi keys, Poly-US keys, Gmail password, GitHub token) to
anything on the LAN. This serves an EXPLICIT ALLOW-LIST of files and nothing
else -- no directory listing, no path traversal, no wildcards.

PLAINTEXT ON PURPOSE. dashboard.html on disk is unencrypted; only the published
copy was ever AES-wrapped, and that was solely because Pages is world-readable.
On your own network there is nothing to hide it from, which also means the page
compresses normally instead of being an incompressible blob.

Usage:
    python main\\serve_dashboard.py               -> http://<this-box>:8000/
    python main\\serve_dashboard.py --port 9000
    python main\\serve_dashboard.py --localhost   -> 127.0.0.1 only

Reachable from the phone on the same wifi at http://<box-ip>:8000/.
Away from home, put Tailscale on both and use the box's Tailscale name.
"""
import base64
import hashlib
import hmac
import json
import os
import secrets
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

ROOT = r'E:\soccer'

# path -> (file on disk, content type). Nothing outside this map is reachable.
ALLOW = {
    '/': ('dashboard.html', 'text/html; charset=utf-8'),
    '/dashboard.html': ('dashboard.html', 'text/html; charset=utf-8'),
    '/book.csv': (r'research\mm_book_dataset.csv', 'text/csv'),
    '/fills.csv': (r'research\mm_live_realfills.csv', 'text/csv'),
}


# --- Authentication -----------------------------------------------------------
#
# WHY THIS EXISTS NOW. On the tailnet the page needed no password: reaching it
# already required being a device on the tailnet, which is a stronger check than
# any password. `tailscale funnel` removes exactly that property -- it publishes
# the page on the OPEN INTERNET at a guessable *.ts.net name, reachable by
# anyone, with no client and no login.
#
# The allow-list below stops the server handing out .env. It does nothing to stop
# a stranger reading live positions, bankroll and P&L, which is what this page is
# made of. So Funnel and auth ship together, and the auth check runs before the
# allow-list, before /refresh, before anything.
AUTH_FILE = 'E:/soccer/data/dashboard_auth.json'
REALM = 'soccer dashboard'


def load_auth():
    """Read the credentials, creating strong ones on first run.

    Kept out of .env deliberately: .env holds exchange keys, and this file is
    something you will want to open and read off to type into a browser.

    `key` is a THIRD credential, for a browser that cannot be typed into at all.
    A managed work machine suppressed the basic-auth dialog AND blocked text
    input to the login form (2026-08-31), leaving no way to present a password.
    The key travels in the URL instead and is swapped for a cookie on arrival.
    It is deliberately separate from the password so it can be rotated on its
    own after being pasted somewhere it should not have been.
    """
    try:
        with open(AUTH_FILE, encoding='utf-8') as fh:
            a = json.load(fh)
        if a.get('user') and a.get('password'):
            if not a.get('key'):
                a['key'] = secrets.token_urlsafe(24)
                try:
                    with open(AUTH_FILE, 'w', encoding='utf-8') as fh:
                        json.dump(a, fh, indent=2)
                except OSError:
                    pass
            globals()['ACCESS_KEY'] = a['key']
            return a['user'], a['password']
    except (OSError, ValueError):
        pass
    a = {'user': 'soccer', 'password': secrets.token_urlsafe(18),
         'key': secrets.token_urlsafe(24)}
    globals()['ACCESS_KEY'] = a['key']
    try:
        with open(AUTH_FILE, 'w', encoding='utf-8') as fh:
            json.dump(a, fh, indent=2)
        print(f'wrote new credentials to {AUTH_FILE}')
    except OSError as e:
        print(f'could not write {AUTH_FILE}: {e}')
    return a['user'], a['password']


ACCESS_KEY = ''
USER, PASSWORD = load_auth()


def check_auth(header):
    """Constant-time check of an Authorization header. False if absent/wrong."""
    if not header or not header.startswith('Basic '):
        return False
    try:
        raw = base64.b64decode(header[6:]).decode('utf-8')
    except Exception:
        return False
    user, _, pw = raw.partition(':')
    # compare BOTH, always, so timing does not reveal whether the user matched
    ok_u = hmac.compare_digest(user, USER)
    ok_p = hmac.compare_digest(pw, PASSWORD)
    return ok_u and ok_p


# --- Form login -------------------------------------------------------------
#
# WHY NOT JUST BASIC AUTH. The 401 + WWW-Authenticate handshake relies on the
# BROWSER to render a login box, and a managed work machine can suppress that --
# observed 2026-08-31: the server sent the header correctly and the browser
# never prompted, so the page was simply unreachable. Embedding credentials in
# the URL did not help either, and it leaks the password into history and proxy
# logs.
#
# A form is just a POST with two fields. Nothing to suppress, nothing special to
# support. Basic auth is KEPT alongside it so the phone's saved credentials and
# any existing bookmark carry on working.
COOKIE = 'dash'
SESSION_HOURS = 720          # 30 days; this guards a dashboard, not a bank


def _secret():
    return (USER + ':' + PASSWORD).encode('utf-8')


def make_token(hours=SESSION_HOURS):
    exp = str(int(time.time() + hours * 3600))
    mac = hmac.new(_secret(), exp.encode(), hashlib.sha256).hexdigest()[:32]
    return exp + '.' + mac


def token_ok(tok):
    """Valid, unexpired, and signed by the current password.

    Signing with the password means rotating it invalidates every session, which
    is what you want from a rotation.
    """
    try:
        exp, mac = (tok or '').split('.', 1)
        want = hmac.new(_secret(), exp.encode(), hashlib.sha256).hexdigest()[:32]
        return hmac.compare_digest(mac, want) and int(exp) > time.time()
    except Exception:
        return False


LOGIN_PAGE = """<!doctype html><meta name=viewport content="width=device-width,initial-scale=1">
<title>soccer dashboard</title>
<style>
 :root{color-scheme:light dark}
 body{font:16px/1.5 system-ui,-apple-system,sans-serif;display:flex;min-height:90vh;
      align-items:center;justify-content:center;margin:0}
 form{width:min(320px,86vw);display:flex;flex-direction:column;gap:10px}
 h1{font-size:17px;margin:0 0 6px;font-weight:600}
 input{font:16px system-ui;padding:11px 12px;border-radius:9px;
       border:1px solid rgba(127,127,127,.45);background:transparent;color:inherit}
 button{font:600 16px system-ui;padding:12px;border:0;border-radius:9px;
        background:#1f6feb;color:#fff;cursor:pointer}
 .err{color:#e5534b;font-size:13px;min-height:18px}
</style>
<form method=post action="/login">
  <h1>soccer dashboard</h1>
  <input name=u placeholder="username" autocomplete="username" autocapitalize=off autocorrect=off>
  <input name=p type=password placeholder="password" autocomplete="current-password">
  <button type=submit>Sign in</button>
  <div class=err>__ERR__</div>
</form>"""


def _form_fields(body):
    from urllib.parse import parse_qs
    q = parse_qs(body.decode('utf-8', 'replace'))
    return (q.get('u', [''])[0], q.get('p', [''])[0])


# --- Refresh -----------------------------------------------------------------
#
# The page is a static file. Something has to rewrite it, and until 2026-08-29
# that was publish_dashboard.py, which rebuilt AND pushed to GitHub Pages. The
# push is what got the account suspended, so that task is off -- and turning it
# off silently stopped the rebuild too, freezing the served page.
#
# So the button shells out to build_dashboard.py, the rebuild half on its own.
# A scheduled task runs the same script, so manual and periodic refreshes cannot
# drift apart.
#
# ONE AT A TIME. A rebuild reads the same CSVs the makers are writing and takes
# a few seconds; letting a phone with an itchy thumb start ten of them would be
# a self-inflicted denial of service. The lock makes extra clicks join the
# running build instead of starting another.
BUILD = {'state': 'idle', 'started': 0.0, 'finished': 0.0, 'message': ''}
_BUILD_LOCK = threading.Lock()


def _do_build():
    t0 = time.time()
    try:
        p = subprocess.run([sys.executable, os.path.join(ROOT, 'main',
                                                         'build_dashboard.py')],
                           cwd=ROOT, capture_output=True, text=True,
                           encoding='utf-8', errors='replace', timeout=600)
        ok = p.returncode == 0
        tail = ((p.stdout or '') + (p.stderr or '')).strip().splitlines()
        msg = tail[-1] if tail else ''
    except Exception as e:
        ok, msg = False, f'{type(e).__name__}: {e}'
    with _BUILD_LOCK:
        BUILD['state'] = 'ok' if ok else 'error'
        BUILD['finished'] = time.time()
        BUILD['message'] = msg[:300]
    print(f'refresh: {BUILD["state"]} in {time.time() - t0:.1f}s {msg[:120]}')


def start_build():
    """Kick off a rebuild unless one is already running. Returns the state."""
    with _BUILD_LOCK:
        if BUILD['state'] == 'running':
            return dict(BUILD)
        BUILD.update({'state': 'running', 'started': time.time(),
                      'finished': 0.0, 'message': ''})
        snapshot = dict(BUILD)
    threading.Thread(target=_do_build, daemon=True).start()
    return snapshot


# Injected into the served page only. It deliberately does NOT live in
# dashboard.py, because that same HTML gets emailed as an attachment, where a
# button wired to http://<box>/refresh would be dead on arrival.
REFRESH_UI = """
<style>
  #rfBtn{position:fixed;right:16px;bottom:16px;z-index:99999;font:600 14px/1
    system-ui,-apple-system,sans-serif;padding:12px 18px;border-radius:999px;
    border:1px solid rgba(127,127,127,.35);cursor:pointer;
    background:#1f6feb;color:#fff;box-shadow:0 2px 12px rgba(0,0,0,.3);
    -webkit-tap-highlight-color:transparent}
  #rfBtn[disabled]{opacity:.65;cursor:default}
  #rfMsg{position:fixed;right:16px;bottom:64px;z-index:99999;max-width:60vw;
    font:500 12px/1.35 system-ui,-apple-system,sans-serif;padding:8px 12px;
    border-radius:10px;background:rgba(0,0,0,.82);color:#fff;display:none}
</style>
<button id="rfBtn">Refresh</button><div id="rfMsg"></div>
<script>
(function(){
  var b=document.getElementById('rfBtn'), m=document.getElementById('rfMsg'), t0;
  function say(s){ m.style.display=s?'block':'none'; m.textContent=s||''; }
  function poll(){
    fetch('/refresh/status',{cache:'no-store'}).then(function(r){return r.json();})
      .then(function(j){
        if(j.state==='running'){
          say('Rebuilding… '+Math.round((Date.now()-t0)/1000)+'s');
          return setTimeout(poll,1500);
        }
        if(j.state==='ok'){ say('Updated, reloading…');
          return setTimeout(function(){ location.reload(); },400); }
        say('Failed: '+(j.message||'unknown')); b.disabled=false;
        b.textContent='Refresh';
      })
      .catch(function(e){ say('Lost the server: '+e); b.disabled=false;
                          b.textContent='Refresh'; });
  }
  b.addEventListener('click',function(){
    b.disabled=true; b.textContent='Rebuilding'; t0=Date.now(); say('Starting…');
    fetch('/refresh',{method:'POST',cache:'no-store'})
      .then(function(){ poll(); })
      .catch(function(e){ say('Could not start: '+e); b.disabled=false;
                          b.textContent='Refresh'; });
  });
})();
</script>
"""


def inject(body):
    """Put the refresh control into the page just before </body>."""
    tag = b'</body>'
    ui = REFRESH_UI.encode('utf-8')
    i = body.rfind(tag)
    return body[:i] + ui + body[i:] if i != -1 else body + ui


class Handler(BaseHTTPRequestHandler):
    def _deny(self):
        body = b'authentication required'
        self.send_response(401)
        self.send_header('WWW-Authenticate', f'Basic realm="{REALM}", charset="UTF-8"')
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _authed(self):
        if check_auth(self.headers.get('Authorization')):
            return True
        for part in (self.headers.get('Cookie') or '').split(';'):
            k, _, v = part.strip().partition('=')
            if k == COOKIE and token_ok(v):
                return True
        return False

    def _login_page(self, err=''):
        body = LOGIN_PAGE.replace('__ERR__', err).encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.send_header('Cache-Control', 'no-store')
        self.end_headers()
        self.wfile.write(body)

    def _ok(self):
        """Gate a DATA request. Never renders the form -- a CSV or a refresh
        POST wants a status code, not a page."""
        if self._authed():
            return True
        self._deny()
        return False

    def _json(self, obj, code=200):
        body = json.dumps(obj).encode('utf-8')
        self.send_response(code)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(body)))
        self.send_header('Cache-Control', 'no-store')
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        path = self.path.split('?', 1)[0]
        if path == '/login':
            n = int(self.headers.get('Content-Length') or 0)
            u, pw = _form_fields(self.rfile.read(n) if n else b'')
            ok_u = hmac.compare_digest(u, USER)
            ok_p = hmac.compare_digest(pw, PASSWORD)
            if ok_u and ok_p:
                self.send_response(303)
                self.send_header('Location', '/')
                self.send_header('Set-Cookie',
                                 f'{COOKIE}={make_token()}; Path=/; Max-Age='
                                 f'{SESSION_HOURS * 3600}; HttpOnly; SameSite=Lax')
                self.send_header('Content-Length', '0')
                self.end_headers()
            else:
                self._login_page('wrong username or password')
            return
        if not self._ok():
            return
        if path != '/refresh':
            self.send_error(404, 'Not found')
            return
        self._json(start_build())

    def do_GET(self):
        path = self.path.split('?', 1)[0]
        # KEY IN THE URL -> COOKIE, then redirect to the bare path so the secret
        # does not linger in the address bar, get bookmarked by accident, or
        # turn up in a screenshot. It is still in browser history and in any
        # proxy log on the way -- that is the cost of a browser you cannot type
        # into, and the reason the key is rotatable on its own.
        from urllib.parse import parse_qs
        q = parse_qs(self.path.split('?', 1)[1]) if '?' in self.path else {}
        k = (q.get('k') or [''])[0]
        if k and ACCESS_KEY and hmac.compare_digest(k, ACCESS_KEY):
            self.send_response(303)
            self.send_header('Location', '/' if path == '/login' else path)
            self.send_header('Set-Cookie',
                             f'{COOKIE}={make_token()}; Path=/; Max-Age='
                             f'{SESSION_HOURS * 3600}; HttpOnly; SameSite=Lax')
            self.send_header('Content-Length', '0')
            self.end_headers()
            return
        if not self._authed():
            # a PAGE request gets the form; anything else gets a status code
            if path in ('/', '/dashboard.html', '/login'):
                self._login_page()
            else:
                self._deny()
            return
        if path == '/refresh/status':
            with _BUILD_LOCK:
                self._json(dict(BUILD))
            return
        entry = ALLOW.get(path)
        if not entry:
            self.send_error(404, 'Not found')
            return
        rel, ctype = entry
        full = os.path.join(ROOT, rel)
        try:
            with open(full, 'rb') as fh:
                body = fh.read()
        except OSError as e:
            self.send_error(503, f'{rel} unavailable: {type(e).__name__}')
            return
        if ctype.startswith('text/html'):
            body = inject(body)
        self.send_response(200)
        self.send_header('Content-Type', ctype)
        self.send_header('Content-Length', str(len(body)))
        # never cache: the whole point is that the page is current
        self.send_header('Cache-Control', 'no-store')
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        # one line per request, not the default stderr noise
        print(f'{self.address_string()} {fmt % args}')


def main():
    port = 8000
    if '--port' in sys.argv:
        port = int(sys.argv[sys.argv.index('--port') + 1])
    host = '127.0.0.1' if '--localhost' in sys.argv else '0.0.0.0'

    missing = [r for r, _ in ALLOW.values()
               if not os.path.exists(os.path.join(ROOT, r))]
    if missing:
        print('note: not yet on disk -> ' + ', '.join(sorted(set(missing))))

    srv = ThreadingHTTPServer((host, port), Handler)
    print(f'serving {len(ALLOW)} allow-listed paths from {ROOT} on {host}:{port}')
    print(f'  this box   : http://localhost:{port}/')
    if host != '127.0.0.1':
        import socket
        try:
            ip = socket.gethostbyname(socket.gethostname())
            print(f'  same wifi  : http://{ip}:{port}/')
        except OSError:
            pass
        print(f'  away       : http://<tailscale-name>:{port}/  (if Tailscale is on)')
    print('Ctrl-C to stop.')
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        print('\nstopped')
    return 0


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