﻿import json
import os
import signal
import subprocess
import sys
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from urllib.request import urlopen

BASE_DIR = Path(__file__).resolve().parent
CONFIG_PATH = BASE_DIR / "supervisor_config.json"
LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(exist_ok=True)

def ts():
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

def safe_age_text(dt):
    if not dt:
        return "never"
    sec = int((datetime.now() - dt).total_seconds())
    if sec < 60:
        return f"{sec}s ago"
    mins = sec // 60
    if mins < 60:
        return f"{mins}m ago"
    hrs = mins // 60
    return f"{hrs}h {mins % 60}m ago"

def log_line(message):
    line = f"[{ts()}] {message}"
    with open(LOG_DIR / "supervisor.log", "a", encoding="utf-8") as f:
        f.write(line + "\n")

def load_config():
    with open(CONFIG_PATH, "r", encoding="utf-8") as f:
        return json.load(f)

class Service:
    def __init__(self, cfg, settings):
        self.cfg = cfg
        self.settings = settings
        self.name = cfg.get("name", cfg.get("key", "service"))
        self.key = cfg.get("key", self.name.lower().replace(" ", "_"))
        self.enabled = bool(cfg.get("enabled", True))
        self.process = None
        self.status = "Stopped"
        self.status_icon = "âš«"
        self.last_output = None
        self.last_start = None
        self.last_restart = None
        self.restart_count = 0
        self.last_error = ""
        self.unhealthy_since = None
        self.lock = threading.Lock()
        self.log_path = LOG_DIR / f"{self.key}.log"

    def command_display(self):
        return " ".join([self.cfg["command"]] + self.cfg.get("args", []))

    def write_service_log(self, line):
        with open(self.log_path, "a", encoding="utf-8", errors="replace") as f:
            f.write(line)

    def start(self):
        if not self.enabled:
            self.status = "Disabled"
            self.status_icon = "âšª"
            return

        cwd = self.cfg.get("cwd", "")
        cmd = [self.cfg["command"]] + self.cfg.get("args", [])

        with self.lock:
            self.status = "Starting"
            self.status_icon = "ðŸŸ¡"
            self.last_error = ""

        try:
            self.process = subprocess.Popen(
                cmd,
                cwd=cwd,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                stdin=subprocess.DEVNULL,
                text=True,
                encoding='utf-8',
                errors='replace',
                bufsize=1,
                universal_newlines=True,
                creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
            )
            self.last_start = datetime.now()
            self.last_output = datetime.now()
            with self.lock:
                self.status = "Running"
                self.status_icon = "ðŸŸ¢"
            log_line(f"{self.name}: started with `{self.command_display()}` in `{cwd}`")
            threading.Thread(target=self._pump_output, daemon=True).start()
        except Exception as e:
            with self.lock:
                self.status = "Failed to start"
                self.status_icon = "ðŸ”´"
                self.last_error = str(e)
            log_line(f"{self.name}: failed to start: {e}")

    def _pump_output(self):
        if not self.process or not self.process.stdout:
            return
        try:
            for line in self.process.stdout:
                self.last_output = datetime.now()
                self.write_service_log(line)
        except Exception as e:
            self.last_error = f"output reader error: {e}"

    def stop(self):
        p = self.process
        if not p or p.poll() is not None:
            return

        with self.lock:
            self.status = "Stopping"
            self.status_icon = "ðŸŸ¡"

        log_line(f"{self.name}: stopping")

        try:
            p.send_signal(signal.CTRL_BREAK_EVENT)
        except Exception:
            try:
                p.terminate()
            except Exception:
                pass

        deadline = time.time() + int(self.settings.get("graceful_stop_seconds", 6))
        while time.time() < deadline:
            if p.poll() is not None:
                break
            time.sleep(0.25)

        if p.poll() is None:
            try:
                p.kill()
            except Exception:
                pass

        with self.lock:
            self.status = "Stopped"
            self.status_icon = "âš«"

    def restart(self, reason):
        with self.lock:
            self.status = f"Restarting: {reason}"
            self.status_icon = "ðŸŸ¡"
        log_line(f"{self.name}: restarting because {reason}")
        self.stop()
        time.sleep(int(self.settings.get("restart_cooldown_seconds", 5)))
        self.restart_count += 1
        self.last_restart = datetime.now()
        self.unhealthy_since = None
        self.start()

    def process_dead(self):
        return self.process is None or self.process.poll() is not None

    def health_url_ok(self):
        url = (self.cfg.get("health_url") or "").strip()
        if not url:
            return True, ""
        try:
            with urlopen(url, timeout=5) as response:
                if 200 <= response.status < 500:
                    return True, f"HTTP {response.status}"
                return False, f"HTTP {response.status}"
        except Exception as e:
            return False, str(e)

    def file_stale(self):
        watch_file = (self.cfg.get("watch_file") or "").strip()
        stale_minutes = int(self.cfg.get("restart_if_file_stale_minutes") or 0)
        if not watch_file or stale_minutes <= 0:
            return False, ""
        path = Path(watch_file)
        if not path.exists():
            return True, f"watch file missing: {watch_file}"
        mtime = datetime.fromtimestamp(path.stat().st_mtime)
        age = datetime.now() - mtime
        if age > timedelta(minutes=stale_minutes):
            return True, f"watch file stale: {safe_age_text(mtime)}"
        return False, f"file updated {safe_age_text(mtime)}"

    def check(self):
        if not self.enabled:
            return

        if self.process_dead():
            if self.cfg.get("restart_if_process_exits", True):
                self.restart("process exited")
            return

        silent_minutes = int(self.cfg.get("restart_if_silent_minutes") or 0)
        if silent_minutes > 0 and self.last_output:
            if datetime.now() - self.last_output > timedelta(minutes=silent_minutes):
                self.restart(f"silent for {silent_minutes} minutes")
                return

        stale, file_msg = self.file_stale()
        if stale:
            self.last_error = file_msg
            self.restart(file_msg)
            return

        health_url = (self.cfg.get("health_url") or "").strip()
        unhealthy_minutes = int(self.cfg.get("restart_if_unhealthy_minutes") or 0)
        if health_url and unhealthy_minutes > 0:
            ok, msg = self.health_url_ok()
            if ok:
                self.unhealthy_since = None
                if self.status.startswith("Unhealthy"):
                    self.status = "Running"
                    self.status_icon = "ðŸŸ¢"
            else:
                self.last_error = msg
                if self.unhealthy_since is None:
                    self.unhealthy_since = datetime.now()
                with self.lock:
                    self.status = f"Unhealthy: {msg}"
                    self.status_icon = "ðŸŸ "
                if datetime.now() - self.unhealthy_since > timedelta(minutes=unhealthy_minutes):
                    self.restart(f"health check failed for {unhealthy_minutes} minutes: {msg}")
                    return

        if not self.status.startswith("Unhealthy"):
            with self.lock:
                self.status = "Running"
                self.status_icon = "ðŸŸ¢"

def clear_screen():
    os.system("cls" if os.name == "nt" else "clear")

def draw_dashboard(services, started_at):
    clear_screen()
    print("=" * 72)
    print("              NWALS BROADCAST SUPERVISOR")
    print("=" * 72)
    print(f"Started: {started_at.strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"Uptime : {safe_age_text(started_at).replace(' ago','')}")
    print("-" * 72)
    for s in services:
        print(f"{s.status_icon} {s.name}")
        print(f"   Status       : {s.status}")
        print(f"   Last output  : {safe_age_text(s.last_output)}")
        print(f"   Last restart : {safe_age_text(s.last_restart)}")
        print(f"   Restarts     : {s.restart_count}")
        if (s.cfg.get("health_url") or "").strip():
            print(f"   Health URL   : {s.cfg.get('health_url')}")
        if (s.cfg.get("watch_file") or "").strip():
            print(f"   Watch file   : {s.cfg.get('watch_file')}")
        if s.last_error:
            print(f"   Last note    : {s.last_error}")
        print("-" * 72)
    print("Logs folder:", LOG_DIR)
    print("Press CTRL+C to stop supervisor and child services.")
    print("=" * 72)

def main():
    cfg = load_config()
    settings = cfg.get("settings", {})
    services = [Service(s, settings) for s in cfg.get("services", [])]
    started_at = datetime.now()
    log_line("NWALS Broadcast Supervisor starting")

    for svc in services:
        svc.start()

    last_draw = 0
    try:
        while True:
            for svc in services:
                svc.check()

            if time.time() - last_draw >= int(settings.get("dashboard_refresh_seconds", 2)):
                draw_dashboard(services, started_at)
                last_draw = time.time()

            time.sleep(int(settings.get("check_every_seconds", 10)))

    except KeyboardInterrupt:
        log_line("NWALS Broadcast Supervisor stopping by keyboard interrupt")
        for svc in services:
            svc.stop()
        print("\nSupervisor stopped.")

if __name__ == "__main__":
    main()

