// Command remotedeck is a persistent multi-host TCP health/uptime watchlist.
//
// It reads a small text file of "name host:port" targets, repeatedly probes
// each one with a TCP dial, and reports only when a target's up/down state
// changes -- the underlying "is everything I'm responsible for actually up"
// layer beneath a full remote-ops workspace (SSH/SFTP/RDP/tunnels/vault).
// See README.txt for the full scope note and roadmap.
package main

import (
	"bufio"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"net"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"
)

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}

	switch os.Args[1] {
	case "-h", "--help", "help":
		usage()
		return
	case "watch":
		runWatch(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "remotedeck: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `RemoteDeck - persistent multi-host TCP health/uptime watchlist

Usage:
  remotedeck watch --targets <file> [--interval 5s] [--timeout 2s] [--once] [--log events.log] [--json]
  remotedeck help

Watchlist file format (one target per line):
  # comment lines and blank lines are ignored
  name host:port

Watch flags:
  --targets   path to the watchlist file (required)
  --interval  time between poll passes in continuous mode (default 5s)
  --timeout   per-target TCP connect timeout (default 2s)
  --once      do a single poll pass, print a full status table, and exit
              (non-zero exit if any target is DOWN; useful for cron/CI)
  --log       append every state-transition event as a JSON line to this file
  --json      print console events as JSON instead of human-readable text
              (the --log file is always JSON-lines regardless of this flag)

Examples:
  remotedeck watch --targets targets.txt --once
  remotedeck watch --targets targets.txt --interval 10s --log events.log
`)
}

// Target is one entry from the watchlist file.
type Target struct {
	Name string
	Addr string // host:port, passed straight to net.DialTimeout
}

func parseTargets(path string) ([]Target, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("could not read targets file %q: %w", path, err)
	}
	defer f.Close()

	var targets []Target
	sc := bufio.NewScanner(f)
	lineNo := 0
	for sc.Scan() {
		lineNo++
		line := strings.TrimSpace(sc.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		fields := strings.Fields(line)
		if len(fields) != 2 {
			return nil, fmt.Errorf("targets file %q line %d: expected \"name host:port\", got %q", path, lineNo, line)
		}
		name, addr := fields[0], fields[1]
		if _, _, err := net.SplitHostPort(addr); err != nil {
			return nil, fmt.Errorf("targets file %q line %d: invalid host:port %q: %w", path, lineNo, addr, err)
		}
		targets = append(targets, Target{Name: name, Addr: addr})
	}
	if err := sc.Err(); err != nil {
		return nil, fmt.Errorf("could not read targets file %q: %w", path, err)
	}
	if len(targets) == 0 {
		return nil, fmt.Errorf("targets file %q contains no targets", path)
	}
	return targets, nil
}

// probeResult is the outcome of dialing a single target once.
type probeResult struct {
	Target    Target
	Up        bool
	LatencyMs float64
	Reason    string // populated when Up == false: "refused", "timeout", or the raw error
}

func probe(t Target, timeout time.Duration) probeResult {
	start := time.Now()
	conn, err := net.DialTimeout("tcp", t.Addr, timeout)
	elapsed := time.Since(start)
	if err != nil {
		reason := classifyErr(err)
		return probeResult{Target: t, Up: false, Reason: reason}
	}
	conn.Close()
	return probeResult{Target: t, Up: true, LatencyMs: float64(elapsed) / float64(time.Millisecond)}
}

func classifyErr(err error) string {
	var netErr net.Error
	if errors.As(err, &netErr) && netErr.Timeout() {
		return "timeout"
	}
	if strings.Contains(err.Error(), "connection refused") {
		return "refused"
	}
	if strings.Contains(err.Error(), "no such host") {
		return "no such host"
	}
	return err.Error()
}

// event is one recorded state transition, in the shape written to --log.
type event struct {
	Time      string  `json:"time"`
	Target    string  `json:"target"`
	Addr      string  `json:"addr"`
	Old       string  `json:"old_state"`
	New       string  `json:"new_state"`
	LatencyMs float64 `json:"latency_ms,omitempty"`
	Reason    string  `json:"reason,omitempty"`
}

func runWatch(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			usage()
			return
		}
	}

	valueFlags := map[string]bool{
		"targets": true, "interval": true, "timeout": true, "log": true,
	}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("watch", flag.ExitOnError)
	targetsPath := fs.String("targets", "", "path to the watchlist file (required)")
	interval := fs.Duration("interval", 5*time.Second, "time between poll passes in continuous mode")
	timeout := fs.Duration("timeout", 2*time.Second, "per-target TCP connect timeout")
	once := fs.Bool("once", false, "do a single poll pass, print a status table, and exit")
	logPath := fs.String("log", "", "append transition events as JSON lines to this file")
	jsonOut := fs.Bool("json", false, "print console events as JSON")

	if err := fs.Parse(args); err != nil {
		os.Exit(2)
	}

	if *targetsPath == "" {
		fmt.Fprintln(os.Stderr, "remotedeck watch: --targets is required")
		usage()
		os.Exit(1)
	}

	targets, err := parseTargets(*targetsPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "remotedeck watch: %v\n", err)
		os.Exit(1)
	}

	var logFile *os.File
	if *logPath != "" {
		logFile, err = os.OpenFile(*logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
		if err != nil {
			fmt.Fprintf(os.Stderr, "remotedeck watch: could not open --log file %q: %v\n", *logPath, err)
			os.Exit(1)
		}
		defer logFile.Close()
	}

	if *once {
		os.Exit(runOnce(targets, *timeout, *jsonOut, logFile))
	}

	runContinuous(targets, *interval, *timeout, *jsonOut, logFile)
}

// reorderFlags works around the standard flag package's behavior of stopping
// option parsing at the first positional argument, which doesn't suit this
// CLI's conventions of accepting flags in any order.
func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

// runOnce does a single poll pass over every target, prints a full status
// table (every target is "new" since there is no meaningful previous state),
// optionally logs each as a transition-from-unknown event, and returns the
// process exit code: 0 if every target is UP, 1 if any target is DOWN.
func runOnce(targets []Target, timeout time.Duration, jsonOut bool, logFile *os.File) int {
	anyDown := false
	results := make([]probeResult, 0, len(targets))
	for _, t := range targets {
		r := probe(t, timeout)
		results = append(results, r)
		if !r.Up {
			anyDown = true
		}
		ev := eventFromResult(r, "unknown")
		emitEvent(ev, jsonOut, logFile)
	}

	if !jsonOut {
		printStatusTable(results)
	}

	if anyDown {
		return 1
	}
	return 0
}

// runContinuous loops, polling all targets every interval and reporting only
// state transitions, until interrupted (SIGINT/SIGTERM) or the process is
// killed.
func runContinuous(targets []Target, interval, timeout time.Duration, jsonOut bool, logFile *os.File) {
	state := make(map[string]string, len(targets))
	for _, t := range targets {
		state[t.Name] = "unknown"
	}

	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
	defer signal.Stop(sigCh)

	for {
		for _, t := range targets {
			r := probe(t, timeout)
			newState := "down"
			if r.Up {
				newState = "up"
			}
			oldState := state[t.Name]
			if newState != oldState {
				ev := eventFromResult(r, oldState)
				emitEvent(ev, jsonOut, logFile)
				state[t.Name] = newState
			}
		}

		select {
		case <-sigCh:
			return
		case <-time.After(interval):
		}
	}
}

func eventFromResult(r probeResult, oldState string) event {
	newState := "down"
	if r.Up {
		newState = "up"
	}
	return event{
		Time:      time.Now().UTC().Format(time.RFC3339),
		Target:    r.Target.Name,
		Addr:      r.Target.Addr,
		Old:       oldState,
		New:       newState,
		LatencyMs: round2(r.LatencyMs),
		Reason:    r.Reason,
	}
}

func round2(f float64) float64 {
	return float64(int(f*100+0.5)) / 100
}

func emitEvent(ev event, jsonOut bool, logFile *os.File) {
	line, err := json.Marshal(ev)
	if err != nil {
		fmt.Fprintf(os.Stderr, "remotedeck: internal error encoding event: %v\n", err)
		return
	}

	if jsonOut {
		fmt.Println(string(line))
	} else {
		fmt.Println(formatEventHuman(ev))
	}

	if logFile != nil {
		logFile.Write(line)
		logFile.Write([]byte("\n"))
	}
}

func formatEventHuman(ev event) string {
	arrow := fmt.Sprintf("%s -> %s", ev.Old, ev.New)
	if ev.New == "up" {
		return fmt.Sprintf("[%s] %-16s %-22s %-16s latency=%.2fms", ev.Time, ev.Target, ev.Addr, arrow, ev.LatencyMs)
	}
	return fmt.Sprintf("[%s] %-16s %-22s %-16s reason=%s", ev.Time, ev.Target, ev.Addr, arrow, ev.Reason)
}

func printStatusTable(results []probeResult) {
	fmt.Println()
	fmt.Printf("%-16s %-22s %-6s %-10s %s\n", "NAME", "ADDR", "STATE", "LATENCY", "REASON")
	for _, r := range results {
		state := "DOWN"
		latency := "-"
		reason := r.Reason
		if r.Up {
			state = "UP"
			latency = fmt.Sprintf("%.2fms", r.LatencyMs)
			reason = "-"
		}
		fmt.Printf("%-16s %-22s %-6s %-10s %s\n", r.Target.Name, r.Target.Addr, state, latency, reason)
	}
	fmt.Println()
}
