#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Yuvin Servers — Console CLI
===========================
v.1.0.1

The Yuvin Server Console, in your terminal. Live Tabs, service health,
and Dev Program application review — for developers and administrators
only. Sign in with your Yuvin's Test Services developer account.

Works on macOS Terminal, Linux terminals, and Windows Command Prompt.
No installs needed — just Python 3.6+ and this one file.

Run it:            python3 yuvin_servers.py     (macOS / Linux)
                   python yuvin_servers.py      (Windows)
Options:           --no-color   plain text, no colors
                   --ascii      no emoji / fancy characters
                   --version    print the version and exit

Not a developer? The console is restricted — but you can apply:
https://yuvin.com.au/dev-program
"""

import argparse
import getpass
import json
import os
import re
import shutil
import sys
import threading
import time
import uuid

VERSION = "v.1.0.1"
SITE = "https://yuvin.com.au"

# ---------------------------------------------------------------- terminal setup

def enable_ansi():
    if os.name != "nt":
        return True
    try:
        import ctypes
        kernel32 = ctypes.windll.kernel32
        handle = kernel32.GetStdHandle(-11)
        mode = ctypes.c_uint32()
        if kernel32.GetConsoleMode(handle, ctypes.byref(mode)) == 0:
            return False
        return kernel32.SetConsoleMode(handle, mode.value | 0x0004) != 0
    except Exception:
        return False


def can_encode(text):
    enc = getattr(sys.stdout, "encoding", None) or "ascii"
    try:
        text.encode(enc)
        return True
    except Exception:
        return False


ARGS = None
COLOR = True
UNICODE_OK = True
EMOJI_OK = True
USER = None  # set after login: {"id": ..., "username": ..., "joined": ...}


class C:
    """Console palette — accent matches the platform's #3b6ef5 blue."""
    RESET = "\033[0m"
    BOLD = "\033[1m"
    BLUE = "\033[94m"
    CYAN = "\033[96m"
    GREEN = "\033[92m"
    YELLOW = "\033[93m"
    RED = "\033[91m"
    GRAY = "\033[90m"


def col(code, text):
    if not COLOR:
        return text
    return code + text + C.RESET


def sym(emoji, fallback=""):
    if EMOJI_OK:
        return emoji
    return fallback


def to_int(text):
    try:
        return int(text)
    except (ValueError, TypeError):
        return None


ASCII_SWAPS = (("—", "-"), ("·", "."), ("●", "*"), ("│", "|"),
               ("─", "-"), ("┌", "+"), ("└", "+"), ("→", "->"),
               ("←", "<-"), ("✓", "OK"), ("⛔", "X"))

# Strip terminal control characters (ESC, CSI, other C0/C1 controls) from any
# text that arrived over the network before printing it — a public form field
# must never be able to move the reviewer's cursor or run OSC commands.
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f\x80-\x9f]")


def clean(text):
    """Make a remote/untrusted string safe to print in a terminal."""
    return _CONTROL_RE.sub("", str(text if text is not None else ""))


def downgrade(text):
    if not UNICODE_OK:
        for fancy, plain in ASCII_SWAPS:
            text = text.replace(fancy, plain)
    return text


def emit(text):
    """downgrade() plus a guarantee the result encodes to this stdout —
    even on Python 3.6, where reconfigure() doesn't exist."""
    text = downgrade(text)
    enc = getattr(sys.stdout, "encoding", None)
    if enc:
        try:
            text.encode(enc)
        except Exception:
            text = text.encode(enc, "replace").decode(enc, "replace")
    return text


def say(text=""):
    print(emit(text))


def width():
    try:
        w = shutil.get_terminal_size((80, 24)).columns
    except Exception:
        w = 80
    return max(56, min(w, 100))


def line():
    say(col(C.GRAY, ("─" if UNICODE_OK else "-") * width()))


def clear():
    if sys.stdout.isatty():
        os.system("cls" if os.name == "nt" else "clear")


def heading(text):
    say(col(C.BOLD + C.BLUE, text))


def card(title, body_lines):
    tl = "┌" if UNICODE_OK else "+"
    vl = "│" if UNICODE_OK else "|"
    bl = "└" if UNICODE_OK else "+"
    dash = "─" if UNICODE_OK else "-"
    say(col(C.GRAY, tl + dash + " ") + col(C.BOLD, title))
    for b in body_lines:
        say(col(C.GRAY, vl) + "  " + b)
    say(col(C.GRAY, bl + dash * 3))


def ask_raw(prompt):
    try:
        return input(emit(col(C.CYAN, prompt)))
    except (KeyboardInterrupt, EOFError):
        say("")
        goodbye()
    except UnicodeDecodeError:
        return ""


def ask(prompt):
    return ask_raw(prompt).strip()


def ask_password(prompt):
    """Hidden password input; same polite exits as ask(). When input is
    piped (not a real keyboard), fall back to plain reading so the app
    doesn't hang looking for a terminal."""
    if not sys.stdin.isatty():
        return ask_raw(prompt)
    try:
        return getpass.getpass(emit(prompt))
    except (KeyboardInterrupt, EOFError):
        say("")
        goodbye()
    except UnicodeDecodeError:
        return ""


def pause():
    ask("\nPress Enter to go back to the console... ")

# ---------------------------------------------------------------- networking

class CertError(Exception):
    """Raised when TLS certificate verification is impossible and we refuse
    to fall back to an unverified connection (e.g. while sending a password)."""


# Common system CA bundles, used when this Python's default trust store is
# missing (a frequent problem on fresh macOS installs — curl works but
# urllib doesn't). Trying these keeps verification ON instead of off.
_CA_CANDIDATES = (
    "/etc/ssl/cert.pem",                    # macOS, BSD
    "/etc/ssl/certs/ca-certificates.crt",   # Debian / Ubuntu
    "/etc/pki/tls/certs/ca-bundle.crt",     # RHEL / Fedora
    "/usr/local/etc/openssl/cert.pem",      # Homebrew OpenSSL
)


def _is_cert_error(e):
    import ssl
    cause = getattr(e, "reason", e)
    verify_err = getattr(ssl, "SSLCertVerificationError", ())
    return isinstance(cause, verify_err) or "CERTIFICATE_VERIFY_FAILED" in str(e)


def _verified_contexts():
    """Yield SSL contexts that VERIFY the server — the default trust store
    first, then any system CA bundle we can find."""
    import ssl
    yield ssl.create_default_context()
    for ca in _CA_CANDIDATES:
        if os.path.exists(ca):
            try:
                yield ssl.create_default_context(cafile=ca)
            except Exception:
                continue


def _urlopen(url_or_req, timeout=6, allow_insecure=True):
    """urlopen that verifies the server's certificate, trying system CA
    bundles if this Python's default store is broken. Only if EVERY verified
    attempt fails does it fall back to an unverified connection — and never
    when allow_insecure is False (the login POST, so a password is never
    sent to a server whose certificate we couldn't verify)."""
    import urllib.request
    import ssl
    for ctx in _verified_contexts():
        try:
            return urllib.request.urlopen(url_or_req, timeout=timeout, context=ctx)
        except Exception as e:
            if _is_cert_error(e):
                continue          # try the next CA bundle
            raise                 # timeout / DNS / reset — a real failure
    # Every verified attempt failed on certificate verification.
    if not allow_insecure:
        raise CertError()
    ctx = ssl._create_unverified_context()
    return urllib.request.urlopen(url_or_req, timeout=timeout, context=ctx)


def api(method, path, payload=None, timeout=6, allow_insecure=True):
    """Call the Yuvin backend. Returns (status_code, parsed_json_or_None).
    Network failures return (0, None) so callers never see a raw traceback;
    a refused-insecure login surfaces as CertError."""
    import urllib.request
    import urllib.error
    data = json.dumps(payload).encode("utf-8") if payload is not None else None
    req = urllib.request.Request(SITE + path, data=data,
                                 headers={"Content-Type": "application/json"},
                                 method=method)
    try:
        with _urlopen(req, timeout=timeout, allow_insecure=allow_insecure) as r:
            body = r.read()
            status = getattr(r, "status", None) or r.getcode()
    except urllib.error.HTTPError as e:
        status = e.code
        try:
            body = e.read()
        except Exception:
            body = b""
    except CertError:
        raise
    except Exception:
        return 0, None  # URLError, timeout, connection reset, read error, ...
    try:
        return status, json.loads(body.decode("utf-8"))
    except Exception:
        return status, None


def dev_query():
    from urllib.parse import quote
    return "?userId=" + str(USER["id"]) + "&username=" + quote(USER["username"])

# ---------------------------------------------------------------- live presence

CURRENT_PAGE = "Console"
_TAB_ID = "cli-srv-" + uuid.uuid4().hex[:20]
_STOP = threading.Event()
_BEACON = None


def _beacon_loop():
    while not _STOP.is_set():
        try:
            api("POST", "/api/presence/ping", {
                "tabId": _TAB_ID,
                "page": "/terminal",
                "title": "Yuvin Console CLI " + VERSION + " — " + CURRENT_PAGE,
                "site": "yuvin-servers",
                "username": USER["username"] if USER else None,
            }, timeout=4)
        except Exception:
            pass
        _STOP.wait(25)


def start_beacon():
    global _BEACON
    _BEACON = threading.Thread(target=_beacon_loop, daemon=True)
    _BEACON.start()


def goodbye():
    _STOP.set()
    try:
        if _BEACON is not None:
            _BEACON.join(timeout=6)
        if _BEACON is not None:  # only ping bye for a tab that actually pinged
            api("POST", "/api/presence/bye", {"tabId": _TAB_ID}, timeout=2)
    except (Exception, KeyboardInterrupt):
        pass
    if USER is not None:
        say(col(C.BLUE, "\nSigned out of the Yuvin Server Console. " + SITE + "/server-home"))
    else:
        say(col(C.BLUE, "\nGoodbye."))
    sys.exit(0)

# ---------------------------------------------------------------- login gate

def banner():
    say("")
    say(col(C.BOLD + C.BLUE, "  YUVIN") + col(C.GRAY, "  ·  SERVER CONSOLE") +
        col(C.GRAY, "  ·  Terminal " + VERSION))
    line()


def login():
    """Sign in with a YTS account; only developers may enter."""
    global USER
    say("Sign in with your Yuvin's Test Services developer account.")
    say(col(C.GRAY, "(the console is restricted — normal accounts are turned away)\n"))
    attempt = 0
    while attempt < 3:
        username = ask("Username: ")
        if not username:
            continue  # blank Enter re-prompts without spending an attempt
        password = ask_password("Password: ")
        say(col(C.GRAY, "Verifying..."))
        try:
            # allow_insecure=False: never send the password over an unverified
            # connection, even if this computer's certificate store is broken.
            status, d = api("POST", "/api/login",
                            {"username": username, "password": password},
                            allow_insecure=False)
        except CertError:
            say(col(C.RED, "\nCouldn't safely verify yuvin.com.au's security certificate,"))
            say(col(C.RED, "so your password was NOT sent. This usually means this computer's"))
            say(col(C.RED, "certificate store needs repair (on a Mac, run Python's"))
            say(col(C.RED, "'Install Certificates.command'; on Linux, update ca-certificates)."))
            sys.exit(1)
        if status == 0:
            say(col(C.RED, "\nCannot reach yuvin.com.au — check your connection and try again."))
            sys.exit(1)
        if status == 200 and d and d.get("success") and isinstance(d.get("user"), dict):
            u = d["user"]
            if u.get("is_dev") is True:
                USER = {"id": u.get("id"), "username": clean(u.get("username")),
                        "joined": clean(u.get("joined"))}
                say(col(C.GREEN, "\nWelcome, @" + USER["username"] + " — developer access confirmed."))
                return
            say("")
            card(sym("⛔ ", "") + "Access denied", [
                "You don't have permission to access this console.",
                "@" + clean(u.get("username")) + " is not a developer account.",
                "Apply to join: " + col(C.CYAN, SITE + "/dev-program"),
            ])
            sys.exit(1)
        attempt += 1
        msg = clean((d or {}).get("error")) or "Invalid username or password"
        say(col(C.RED, msg + (" — try again.\n" if attempt < 3 else ".")))
    say(col(C.RED, "Too many attempts. The console stays sealed."))
    sys.exit(1)

# ---------------------------------------------------------------- console screens

def fmt_dur(sec):
    sec = max(0, int(sec))
    if sec >= 3600:
        return "{}h {}m".format(sec // 3600, (sec % 3600) // 60)
    if sec >= 60:
        return "{}m {}s".format(sec // 60, sec % 60)
    return "{}s".format(sec)


def render_tabs():
    status, d = api("GET", "/api/presence" + dev_query())
    if status == 0:
        say(col(C.RED, "Couldn't reach the server — check your connection."))
        return
    if status != 200 or not isinstance(d, dict) or not isinstance(d.get("tabs"), list):
        say(col(C.RED, "Couldn't load Live Tabs (HTTP {}).".format(status)))
        return
    tabs = d["tabs"]
    heading(sym("🌍 ", "") + "Live Tabs — {} open".format(d.get("count", len(tabs))))
    say(col(C.GRAY, time.strftime("as of %I:%M:%S %p") + "\n"))
    if not tabs:
        say("No open tabs right now. The internet sleeps.")
        return
    for t in tabs:
        tag = ("[SRV]" if t.get("site") == "yuvin-servers" else "[YTS]")
        tagc = col(C.YELLOW, tag) if tag == "[SRV]" else col(C.BLUE, tag)
        who = ("@" + clean(t["username"])) if t.get("username") else "anonymous"
        page = clean(t.get("page", "?"))
        say("  " + tagc + " " +
            col(C.BOLD, page.ljust(24)[:24]) + " " +
            col(C.CYAN, who.ljust(14)[:14]) +
            col(C.GRAY, " open " + fmt_dur(t.get("openForSec", 0)).rjust(7) +
                " · seen " + fmt_dur(t.get("lastSeenSec", 0)) + " ago"))


def live_tabs():
    render_tabs()
    say("")
    choice = ask("[R]efresh · [W]atch (auto-refresh) · Enter to go back: ").lower()
    while choice in ("r", "w"):
        if choice == "r":
            clear()
            render_tabs()
            say("")
            choice = ask("[R]efresh · [W]atch (auto-refresh) · Enter to go back: ").lower()
        else:
            say(col(C.GRAY, "Watching — refreshing every 10 seconds. Press Ctrl+C to stop."))
            try:
                while True:
                    time.sleep(10)
                    clear()
                    render_tabs()
                    say(col(C.GRAY, "\nWatching — Ctrl+C to stop."))
            except KeyboardInterrupt:
                say("")
                choice = ""


def service_health():
    heading(sym("🩺 ", "") + "Service Health")
    say("Checking all Yuvin services...\n")
    checks = [
        ("Website", SITE + "/"),
        ("Classic /home", SITE + "/home"),
        ("Server Console", SITE + "/server-home"),
        ("Backend API", SITE + "/api/health"),
    ]
    import urllib.error
    lines = []
    for name, url in checks:
        started = time.time()
        try:
            with _urlopen(url, timeout=6) as r:
                ms = int((time.time() - started) * 1000)
                status = getattr(r, "status", None) or r.getcode()
            mark = (col(C.GREEN, sym("🟢", "[UP]") + " Online") if 200 <= status < 400
                    else col(C.YELLOW, "HTTP " + str(status)))
            lines.append("{}  {}  ({} ms)".format(name.ljust(16), mark, ms))
        except urllib.error.HTTPError as e:
            # a real response with an error status (e.g. 500) — that's the very
            # thing a health check should surface, not hide as "unreachable"
            ms = int((time.time() - started) * 1000)
            lines.append("{}  {}  ({} ms)".format(
                name.ljust(16), col(C.YELLOW, sym("🟠", "[ERR]") + " HTTP " + str(e.code)), ms))
        except Exception:
            lines.append("{}  {}".format(name.ljust(16),
                                         col(C.RED, sym("🔴", "[DOWN]") + " Unreachable")))
    card("yuvin.com.au", lines)


def _draw_applications(rows):
    heading(sym("📋 ", "") + "Dev Program Applications")
    pending = sum(1 for a in rows if a.get("status") == "pending")
    say(col(C.GRAY, "{} application(s) · {} awaiting review\n".format(len(rows), pending)))
    for i, a in enumerate(rows, 1):
        st = a.get("status") or "pending"
        stc = {"pending": C.YELLOW, "accepted": C.GREEN, "rejected": C.RED}.get(st, C.GRAY)
        body = [
            "Status: " + col(C.BOLD + stc, str(st).upper()),
            "Contact: " + (clean(a.get("contact")) or "—"),
            "Experience: " + (clean(a.get("experience")) or "—"),
        ]
        for label, key in (("Languages", "languages"), ("Project", "project"), ("Why", "why")):
            v = clean(a.get(key)).strip()
            if v:
                body.append(label + ": " + (v[:70] + ("..." if len(v) > 70 else "")))
        card("#{}  {}".format(i, clean(a.get("name")) or "?"), body)


def dev_applications():
    status, rows = api("GET", "/api/dev-applications" + dev_query())
    if status == 0:
        say(col(C.RED, "Couldn't reach the server — check your connection."))
        return
    if status != 200 or not isinstance(rows, list):
        say(col(C.RED, "Couldn't load applications (HTTP {}).".format(status)))
        return
    if not rows:
        heading(sym("📋 ", "") + "Dev Program Applications")
        say("No applications yet. When someone applies, they'll appear here.")
        return
    _draw_applications(rows)
    say("")
    say(col(C.GRAY, "Type a number + letter to act: e.g. " + col(C.BOLD, "1A") +
            " accepts #1 · A=accept, R=reject, P=reset to pending"))
    statuses = {"a": "accepted", "r": "rejected", "p": "pending"}
    while True:
        choice = ask("Action (or Enter to go back): ").lower()
        if not choice:
            return
        n, act = to_int(choice[:-1]), choice[-1:]
        if n is None or not (1 <= n <= len(rows)) or act not in statuses:
            say(col(C.RED, "Didn't get that — try like '2A' or '3R'."))
            continue
        app = rows[n - 1]
        if not isinstance(app, dict) or "id" not in app:
            say(col(C.RED, "That application is missing its id — refresh and try again."))
            continue
        pstatus, d = api("PATCH",
                         "/api/dev-applications/" + str(app["id"]) + dev_query(),
                         {"status": statuses[act]})
        if pstatus == 200 and d and d.get("success"):
            g = d.get("granted")
            if g and g.get("is_dev"):
                say(col(C.GREEN, sym("✓ ", "") + "Accepted — developer access granted to @" + clean(g.get("username"))))
            elif g and not g.get("is_dev"):
                say(col(C.YELLOW, "Rejected — developer access removed from @" + clean(g.get("username"))))
            elif statuses[act] == "accepted":
                say(col(C.YELLOW, "Accepted — but no account matches that name yet."))
            else:
                say(col(C.GREEN, "Updated #{} → {}".format(n, statuses[act])))
            # re-fetch so the on-screen statuses reflect the change
            rstatus, fresh = api("GET", "/api/dev-applications" + dev_query())
            if rstatus == 200 and isinstance(fresh, list) and fresh:
                rows = fresh
                say("")
                _draw_applications(rows)
                say("")
        else:
            say(col(C.RED, "Update failed (HTTP {}).".format(pstatus)))


def platform_updates():
    heading(sym("📢 ", "") + "Platform Updates")
    card("v.1.25  ·  July 24, 2026  ·  Latest", [
        "Yuvin Terminal appears in Live Tabs — first non-browser client",
        "Dev application review console with Accept / Reject / Reset",
        "Accepting an application grants developer access instantly",
    ])
    card("v.1.15  ·  July 22, 2026", [
        "The Server Console is born — developers only",
        "Live Tabs: every open tab worldwide, in real time",
    ])
    card("v1.00  ·  July 19, 2026", [
        "Platform launch: Daily Word, Daily Maths, contact form",
    ])
    say("Full release notes: " + col(C.CYAN, SITE + "/updateNEW.html"))
    say("Shared infrastructure: " + col(C.CYAN, SITE + "/shared_updates.html"))


def about_console():
    heading(sym("ℹ️ ", "") + "About this console")
    say("The Yuvin Server Console is the private management layer of")
    say("Yuvin's Test Services — reserved for developers and administrators.")
    say("")
    card("Your session", [
        "Signed in as: " + col(C.BOLD, "@" + str(USER["username"])),
        "Joined: " + str(USER.get("joined") or "—"),
        "Access level: " + col(C.GREEN, "Developer"),
        "This console appears in Live Tabs as /terminal [SRV].",
    ])
    say("Web console: " + col(C.CYAN, SITE + "/server-home"))
    say("Dev Program: " + col(C.CYAN, SITE + "/dev-program"))

# ---------------------------------------------------------------- main loop

def home():
    clear()
    banner()
    say(col(C.GRAY, "Signed in as ") + col(C.BOLD, "@" + str(USER["username"])) +
        col(C.GRAY, " · developer"))
    say("")
    heading("CONSOLE")
    say("  [1] " + sym("🌍 ", "") + "Live Tabs           — every open tab, worldwide")
    say("  [2] " + sym("🩺 ", "") + "Service Health      — website, API, latency")
    say("  [3] " + sym("📋 ", "") + "Dev Applications    — review and grant access")
    say("  [4] " + sym("📢 ", "") + "Platform Updates    — release history")
    say("  [5] " + sym("ℹ️  ", "") + "About this console")
    say("  [0] Sign out & exit")
    line()
    say(col(C.GRAY, "(c) 2026 Yuvin · All rights reserved · " + VERSION))
    say("")


# each: (label, fn, self_paced) — self_paced screens run their own
# "Enter to go back" prompt, so main() must not add a second pause()
PAGES = {
    "1": ("Live Tabs", live_tabs, True),
    "2": ("Service Health", service_health, False),
    "3": ("Dev Applications", dev_applications, True),
    "4": ("Platform Updates", platform_updates, False),
    "5": ("About", about_console, False),
}


def main():
    global ARGS, COLOR, UNICODE_OK, EMOJI_OK, CURRENT_PAGE
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(errors="replace")
        except Exception:
            pass

    parser = argparse.ArgumentParser(
        prog="yuvin_servers.py", description="Yuvin Servers - Console CLI (developers only)")
    parser.add_argument("--no-color", action="store_true", help="disable colors")
    parser.add_argument("--ascii", action="store_true", help="disable emoji and fancy characters")
    parser.add_argument("--version", action="version", version="Yuvin Servers Console CLI " + VERSION)
    ARGS = parser.parse_args()

    ansi_ok = enable_ansi()
    COLOR = ansi_ok and not ARGS.no_color and os.environ.get("NO_COLOR") is None
    legacy_console = (os.name == "nt" and not os.environ.get("WT_SESSION")
                      and not os.environ.get("TERM_PROGRAM"))
    UNICODE_OK = (not ARGS.ascii) and can_encode("─│┌└—·●→")
    EMOJI_OK = (not ARGS.ascii) and not legacy_console and can_encode("🌍🩺📋")

    clear()
    banner()
    login()
    start_beacon()
    time.sleep(0.4)

    try:
        while True:
            CURRENT_PAGE = "Console"
            home()
            choice = ask("Where to? ").lower()
            if choice == "0" or choice in ("exit", "quit", "q", "signout"):
                goodbye()
            if choice in PAGES:
                name, fn, self_paced = PAGES[choice]
                CURRENT_PAGE = name
                clear()
                fn()
                if not self_paced:
                    pause()
    except KeyboardInterrupt:
        say("")
        goodbye()


if __name__ == "__main__":
    main()
