#!/usr/bin/env python3
"""
Service Plan — what Mesa's routing would look like if work were batched.

Mesa won't work on a live bus, so servicing any gen takes its whole group down
and every pod on it with it. The cost is therefore per SHUTDOWN, not per gen:
once the group is off, the second and third gen are nearly free. History says
we've been paying it one gen at a time — 827 group shutdowns to perform 919 PMs,
90% of them a single gen.

This page proposes the other way round. For each group it anchors a visit on the
soonest-due gen (you can never run past due), pulls in everything else in that
group coming due within the horizon, and adds the dated-free work — inspect
tags, notes, swaps — that can ride along for nothing.

READ-ONLY. It proposes; it books nothing. The horizon is adjustable in the URL
so the trade is visible: pulling a PM forward shifts that gen's cycle
permanently earlier, costing days_pulled/interval of an extra service, paid
against a pod shutdown avoided.
"""

import cgi
import cgitb
import html
import sys

sys.path.insert(0, '/opt/ngon/apps')
sys.path.insert(0, '/opt/ngon/apps/managers')
sys.path.insert(0, '/var/www/html/ngon')
from managers.auth_manager import AuthManager, generate_login_page_html, generate_access_denied_html

# NO nav dropdown here, deliberately — this page carries the mesa_gen_tracker
# access key, so Mesa can open it, and the Mesa Gen Tracker omits the standard
# menu for exactly that reason. The only link out is back to the tracker.

form = cgi.FieldStorage()
auth = AuthManager('mesa_gen_tracker')
auth_required, should_exit, headers = auth.require_auth(form)

if should_exit:
    print("Content-Type: application/json")
    if headers:
        print(headers)
    print("")
    print('{"success": false, "error": "Authentication required"}' if auth_required
          else '{"success": true}')
    sys.exit(0)

cgitb.enable()
print("Content-Type: text/html; charset=utf-8")
print("Cache-Control: no-cache, no-store, must-revalidate")
print()

if auth_required:
    print(generate_access_denied_html("Service Plan") if auth.is_authenticated()
          else generate_login_page_html("Service Plan"))
    sys.exit(0)

try:
    horizon = float(form.getvalue('horizon') or 21)
except (TypeError, ValueError):
    horizon = 21.0
horizon = max(0.0, min(90.0, horizon))

# Swaps are ours, not Mesa's — off unless explicitly asked for.
include_swaps = str(form.getvalue('swaps') or '').lower() in ('1', 'true', 'on', 'yes')
view = (form.getvalue('view') or 'site').lower()
if view not in ('site', 'week'):
    view = 'site'
region = (form.getvalue('region') or 'all').upper()
if region not in ('ALL', 'ND', 'TX'):
    region = 'ALL'


def url(**over):
    """This page's URL with one control changed and the rest kept — otherwise
    switching the view silently resets your horizon and region."""
    q = {'horizon': int(horizon), 'view': view, 'region': region.lower()}
    if include_swaps:
        q['swaps'] = 1
    q.update(over)
    q = {k: v for k, v in q.items() if v not in (None, '', 'all')}
    return 'service_plan.py?' + '&'.join(f'{k}={v}' for k, v in q.items())

from generators import pm_service
plan = pm_service.build_plan(horizon_days=horizon, include_swaps=include_swaps)

t = plan.get('totals') or {}
# Median PM invoice across 900 costed visits in gen_service.db — used only to put
# a number on the horizon trade, not to make the decision.
PM_COST = 737

STATE_CLS = {'overdue': 'overdue', 'due': 'due', 'upcoming': 'upcoming', 'ok': 'ok'}


def _gen_statuses():
    """Maintenance-tag palette from master_config misc.display — the same source
    constants_cgi.py serves to the browser and gen_manager.py reads, so a tag is
    the same colour here, on the status page and on the Gen Tracker. Literal
    below is the fallback if config is unreadable."""
    fallback = {
        'active': {'label': 'Active', 'color': '#00ff00', 'textColor': 'black'},
        'active-swap': {'label': 'Active-Swap', 'color': '#ff6600', 'textColor': 'white'},
        'swap': {'label': 'Swap', 'color': '#ff6600', 'textColor': 'white'},
        'inspect': {'label': 'Inspect', 'color': '#ffaa00', 'textColor': 'black'},
        'parts': {'label': 'Parts', 'color': '#ff4444', 'textColor': 'white'},
        'gas': {'label': 'Gas', 'color': '#4a9eff', 'textColor': 'white'},
        'oos': {'label': 'OOS', 'color': '#888888', 'textColor': 'white'},
    }
    try:
        import json
        with open('/opt/ngon/config/master_config.json') as f:
            got = ((json.load(f).get('misc') or {}).get('display') or {}).get('genStatuses')
        return got or fallback
    except Exception:
        return fallback


GEN_STATUSES = _gen_statuses()

print(f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Service Plan</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
        background:#1a1a1a; color:#e0e0e0; margin:0; padding:0; line-height:1.4; }}
.header {{ background-color: rgba(255,255,255,0.05); border-radius:8px; padding:30px;
           margin:20px; border:1px solid rgba(255,255,255,0.1);
           display:flex; justify-content:space-between; align-items:flex-start; }}
.header h1 {{ margin:0; color:#fff; font-size:22px; }}
.header .subtitle {{ color:#888; font-size:12px; margin-top:4px; }}
.wrap {{ padding:0 20px 30px; }}

.summary {{ display:flex; gap:10px; flex-wrap:wrap; margin-bottom:14px; }}
.stat {{ background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1);
         border-radius:8px; padding:10px 14px; min-width:120px; }}
.stat .n {{ font-size:20px; font-weight:bold; color:#fff; }}
.stat .k {{ font-size:10px; color:#8b98a6; text-transform:uppercase; letter-spacing:.06em; }}
.stat.good .n {{ color:#7fd6a0; }}
.stat.cost .n {{ color:#ffd48a; }}
.stat.insp .n {{ color:#ffaa00; }}

.controls {{ background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1);
             padding:12px 15px; border-radius:8px; margin-bottom:15px;
             display:flex; gap:10px; align-items:center; flex-wrap:wrap; font-size:12px; }}
.controls a {{ color:#8b98a6; text-decoration:none; padding:4px 10px; border-radius:12px;
               border:1px solid #2f3742; }}
.controls a.on {{ color:#c9a6ff; border-color:rgba(170,68,255,.5); background:rgba(170,68,255,.13); }}
.controls .note {{ color:#6f7c89; margin-left:auto; }}

.site {{ background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1);
         border-radius:10px; padding:12px; margin-bottom:12px; }}
.site h2 {{ margin:0 0 10px; font-size:15px; color:#00ff00; display:flex; align-items:baseline; gap:10px; }}
.site h2 .sub {{ color:#6f7c89; font-size:11px; font-weight:normal; }}

.visit {{ background:#12161b; border-left:3px solid #2f3742; border-radius:0 6px 6px 0;
          padding:9px 11px; margin-bottom:7px; }}
.visit.soon {{ border-left-color:#ff6600; }}
.visit.now {{ border-left-color:#ff4444; }}
.visit.later {{ border-left-color:#4a9eff; }}
.visit.none {{ border-left-color:#3a4450; }}
/* An inspect-driven visit. Not the same thing as a PM coming due — a gen is
   impaired now, so the row says why it's happening rather than leaving the
   reader to infer it from a chip. */
.visit.urgent {{ border-left-color:#ffaa00; background:#191710; }}
.vurgent {{ color:#ffaa00; font-weight:bold; font-size:11px; }}
.vhead {{ display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }}
.vgroup {{ color:#fff; font-weight:bold; font-size:13px; }}
.vweek {{ color:#c9a6ff; font-weight:bold; font-size:12px; }}
.vmeta {{ color:#6f7c89; font-size:11px; margin-left:auto; }}
.vsave {{ color:#7fd6a0; font-size:11px; font-weight:bold; }}
.vcost {{ color:#8b98a6; font-size:11px; }}
.chips {{ display:flex; gap:5px; flex-wrap:wrap; margin-top:6px; }}
.chip {{ display:inline-flex; align-items:center; padding:2px 7px; border-radius:10px;
         font-size:10px; font-weight:700; white-space:nowrap; border:1px solid; }}
.chip.overdue  {{ color:#ff8a8a; border-color:rgba(255,68,68,.55);   background:rgba(255,68,68,.13); }}
.chip.due      {{ color:#ffb27a; border-color:rgba(255,102,0,.55);   background:rgba(255,102,0,.13); }}
.chip.upcoming {{ color:#ffd48a; border-color:rgba(255,170,0,.45);   background:rgba(255,170,0,.10); }}
.chip.ok       {{ color:#93a3b3; border-color:rgba(147,163,179,.32); background:rgba(147,163,179,.07); }}
/* A maintenance TAG is a solid squared badge in the fleet-wide colour from
   misc.display — identical to how it reads on the status page and the Gen
   Tracker, so Inspect is Inspect wherever you see it.
   The canonical amber (inspect) and orange (swap) are the same hues this page
   uses for PM urgency, so they're separated by SHAPE instead: PM work is an
   outlined pill, a tag is a filled rectangle. Same trick that fixed the
   Inspect-vs-PM collision on the status page. */
.chip.tag  {{ border-radius:3px; border-style:solid; }}
.chip.note {{ color:#8b98a6; border-color:rgba(139,152,166,.30); background:rgba(139,152,166,.06); }}
.chip.ride {{ color:#7fbcff; border-color:rgba(74,158,255,.45);  background:rgba(74,158,255,.10); }}
.chip .early {{ color:#6f7c89; font-weight:normal; margin-left:4px; }}
.deferred {{ color:#5a6672; font-size:10px; margin-top:5px; }}
.empty {{ color:#6f7c89; font-style:italic; font-size:12px; }}
.btn-back {{ padding:6px 12px; border:none; border-radius:4px; cursor:pointer;
             font-weight:bold; font-size:12px; background:#4a9eff; color:#fff;
             text-decoration:none; display:inline-block; }}
.btn-back:hover {{ background:#2a7edf; }}
</style></head><body>
<div class="header">
  <div>
    <h1><i class="fas fa-route"></i> Service Plan</h1>
    <div class="subtitle">What Mesa's routing would look like if the work were batched &mdash;
      proposal only, nothing is booked from this page</div>
  </div>
  <div><a class="btn-back" href="mesa_gen_tracker.py">&larr; Gen Tracker</a></div>
</div>
<div class="wrap">
""")

if plan.get('error'):
    print(f'<div class="empty">Could not build a plan: {html.escape(plan["error"])}</div>')
    print('</div></body></html>')
    sys.exit(0)

saved = t.get('shutdowns_saved', 0)
extra = t.get('extra_pms', 0) or 0
print(f"""
<div class="summary">
  <div class="stat"><div class="n">{t.get('groups',0)}</div><div class="k">groups with work</div></div>
  <div class="stat"><div class="n">{t.get('pms',0)}</div><div class="k">PMs batched</div></div>
  <div class="stat insp"><div class="n">{t.get('inspects',0)}</div>
       <div class="k">inspects &mdash; {t.get('urgent_groups',0)} group(s) to visit now</div></div>
  <div class="stat"><div class="n">{t.get('ride_along',0)}</div><div class="k">ride-along items</div></div>
  <div class="stat good"><div class="n">{saved}</div><div class="k">shutdowns avoided</div></div>
  <div class="stat cost"><div class="n">{extra:g}</div><div class="k">extra PMs &asymp; ${extra*PM_COST:,.0f}</div></div>
</div>

<div class="controls">
  <span style="color:#8b98a6;">Batch horizon &mdash; how far forward work may be pulled:</span>
""")
for h in (7, 14, 21, 30, 45):
    on = ' class="on"' if abs(h - horizon) < 0.5 else ''
    print(f'  <a href="{url(horizon=h)}"{on}>{h}d</a>')

hidden = (plan.get('totals') or {}).get('swaps_hidden', 0)
print('  <span style="color:#8b98a6;margin-left:14px;">Swaps:</span>')
print(f'  <a href="{url(swaps=None)}"' + ('' if include_swaps else ' class="on"') + '>hidden</a>')
print(f'  <a href="{url(swaps=1)}"' + (' class="on"' if include_swaps else '') + '>shown</a>')
if not include_swaps and hidden:
    print(f'  <span style="color:#6f7c89;">({hidden} swap-tagged gen'
          f'{"s" if hidden != 1 else ""} left out)</span>')
print('</div>')

print('<div class="controls">')
print('  <span style="color:#8b98a6;">Group by:</span>')
for v, lbl in (('site', 'site &mdash; one trip, one place'), ('week', 'week &mdash; a calendar')):
    print(f'  <a href="{url(view=v)}"' + (' class="on"' if view == v else '') + f'>{lbl}</a>')
print('  <span style="color:#8b98a6;margin-left:14px;">Region:</span>')
for rg, lbl in (('all', 'All'), ('nd', 'ND'), ('tx', 'TX')):
    print(f'  <a href="{url(region=rg)}"'
          + (' class="on"' if region == rg.upper() else '') + f'>{lbl}</a>')
print(f"""
  <span class="note">A gen pulled {int(horizon)}d early loses {horizon/plan.get('interval_days',83)*100:.0f}%
    of one interval &middot; median PM ${PM_COST}</span>
</div>
""")

def render_visit(g, show_site=False):
    """One proposed visit. Same markup in both views — only the grouping differs."""
    d = g['visit_in_days']
    cls = 'none' if d is None else ('now' if d <= 3 else ('soon' if d <= 14 else 'later'))
    if g.get('urgent'):
        cls = 'urgent'
    week = f'week of {g["visit_week"][5:]}' if g['visit_week'] else 'no dated work'
    label = f'{g["site"]} {g["group"]}' if show_site else g['group']
    print(f'  <div class="visit {cls}"><div class="vhead">')
    print(f'    <span class="vgroup">{html.escape(label)}</span>')
    if not show_site:
        print(f'    <span class="vweek">{week}</span>')
    if d is not None and not show_site:
        print(f'    <span class="vcost">in {d}d</span>')
    if g.get('urgent'):
        n = g.get('urgent_count', 0)
        print(f'    <span class="vurgent">&#9888; {n} inspect{"s" if n != 1 else ""}</span>')
    if g['shutdowns_saved']:
        print(f'    <span class="vsave">saves {g["shutdowns_saved"]} shutdown'
              f'{"s" if g["shutdowns_saved"] != 1 else ""}</span>')
    if g['days_pulled']:
        print(f'    <span class="vcost">&middot; {g["days_pulled"]}d pulled forward '
              f'= {g["extra_pm_cost"]:g} extra PM</span>')
    print(f'    <span class="vmeta">{g["miners"]:,} miners &middot; {g["ph"]:g} PH/s</span>')
    print('  </div><div class="chips">')
    for i in g['pms']:
        early = i['days_left'] - (g['visit_in_days'] or 0)
        cls2 = STATE_CLS.get(i['state'], 'ok')
        extra_txt = f'<span class="early">{early:.0f}d early</span>' if early >= 1 else ''
        print(f'    <span class="chip {cls2}" title="due in {i["days_left"]:g} days">'
              f'PM {html.escape(i["gen"])} {extra_txt}</span>')
    # Inspects first: they're the reason a visit gets scheduled at all when no PM
    # is due, so they shouldn't sort behind a pile of notes.
    order = {'inspect': 0, 'parts': 1, 'active-swap': 2, 'swap': 3, 'gas': 4, 'oos': 5, 'note': 9}
    for r in sorted(g['ride_along'], key=lambda x: (order.get(x['kind'], 8), x['gen'])):
        tip = html.escape(r['detail'] or '', quote=True)[:160]
        info = GEN_STATUSES.get(r['kind'])
        if info:
            style = (f'background:{info["color"]};color:{info.get("textColor","white")};'
                     f'border-color:{info["color"]};')
            print(f'    <span class="chip tag" style="{style}" title="{tip}">'
                  f'{html.escape(r["gen"])} &middot; {html.escape(info["label"])}</span>')
        else:
            # 'note' isn't a maintenance_status, so it has no fleet-wide colour.
            print(f'    <span class="chip note" title="{tip}">{html.escape(r["gen"])} '
                  f'&middot; note</span>')
    print('  </div>')
    if g['deferred']:
        txt = ', '.join(f'{i["gen"]} ({i["days_left"]:.0f}d)' for i in g['deferred'])
        print(f'  <div class="deferred">not in this visit at a {int(horizon)}d horizon: '
              f'{html.escape(txt)}</div>')
    print('  </div>')


# Region is a scope, applied before anything is grouped or ranked, so the counts
# and the ordering both describe what you're actually looking at.
sites = [s for s in plan['sites'] if region == 'ALL' or s.get('region') == region]
visits = [g for s in sites for g in s['groups']]

if not visits:
    print(f'<div class="empty">No outstanding work'
          + (f' in {html.escape(region)}' if region != 'ALL' else '') + '.</div>')

elif view == 'site':
    # Biggest wins first — as a routing aid it should lead with the trips worth making.
    for site in sorted(sites, key=lambda s: (-sum(1 for g in s['groups'] if g.get('urgent')),
                                             -sum(g['shutdowns_saved'] for g in s['groups']),
                                             s['site'])):
        ssave = sum(g['shutdowns_saved'] for g in site['groups'])
        spms = sum(len(g['pms']) for g in site['groups'])
        surg = sum(g.get('urgent_count', 0) for g in site['groups'])
        print(f'<div class="site"><h2>{html.escape(site["site"])}'
              + (f' <span class="sub">[{site["region"]}]</span>' if site.get('region') else '')
              + f'<span class="sub">{site["miners"]:,} miners &middot; '
                f'{len(site["groups"])} group(s) &middot; {spms} PM(s)'
              + (f' &middot; <span style="color:#ffaa00;">{surg} inspect(s)</span>' if surg else '')
              + (f' &middot; {ssave} shutdown(s) avoided' if ssave else '') + '</span></h2>')
        for g in site['groups']:
            render_visit(g)
        print('</div>')

else:
    # By week: the same visits on a calendar. A week is a TRIP-PLANNING unit —
    # several groups landing in one week at one site is one drive, so the site
    # name moves onto each row and the week becomes the heading.
    from collections import defaultdict
    buckets = defaultdict(list)
    for g in visits:
        buckets[g['visit_week']].append(g)
    dated = sorted([w for w in buckets if w])
    for wk in dated + ([None] if None in buckets else []):
        rows = sorted(buckets[wk], key=lambda g: (not g.get('urgent'), g['site'], g['group']))
        wsave = sum(g['shutdowns_saved'] for g in rows)
        wpms = sum(len(g['pms']) for g in rows)
        wmin = sum(g['miners'] for g in rows)
        bysite = {}
        for g in rows:
            bysite.setdefault(g['site'], 0)
            bysite[g['site']] += 1
        trips = ', '.join(f'{k} ({v})' if v > 1 else k for k, v in sorted(bysite.items()))
        head = f'Week of {wk}' if wk else 'No dated work'
        print(f'<div class="site"><h2>{html.escape(head)}'
              f'<span class="sub">{len(rows)} shutdown(s) &middot; {wpms} PM(s) &middot; '
              f'{wmin:,} miners affected'
              + (f' &middot; {wsave} avoided' if wsave else '')
              + f' &middot; sites: {html.escape(trips)}</span></h2>')
        for g in rows:
            render_visit(g, show_site=True)
        print('</div>')

print("""</div>
</body></html>""")
