// Command linkguard is a connectivity watchdog with an append-only outage
// ledger. It probes TCP, HTTP and DNS targets on a schedule, records only
// state TRANSITIONS, and computes availability statistics from that history.
package main

import (
	"context"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"sort"
	"strings"
	"syscall"
	"time"
)

const version = "1.0.0"

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (identical across the tool family).
// ---------------------------------------------------------------------------

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...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

// Target is one monitored endpoint.
type Target struct {
	Name           string `json:"name"`
	Type           string `json:"type"`
	Target         string `json:"target"`
	ExpectedStatus int    `json:"expected_status,omitempty"`
}

// Config is the on-disk target list.
type Config struct {
	Targets []Target `json:"targets"`
}

func loadConfig(path string) (*Config, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, fmt.Errorf("config file not found: %s", path)
		}
		return nil, fmt.Errorf("cannot read config %s: %w", path, err)
	}
	trimmed := strings.TrimSpace(string(raw))
	if trimmed == "" {
		return nil, fmt.Errorf("config %s is empty", path)
	}
	cfg := &Config{}
	if strings.HasPrefix(trimmed, "[") {
		var list []Target
		if err := json.Unmarshal(raw, &list); err != nil {
			return nil, fmt.Errorf("malformed config %s: %v", path, err)
		}
		cfg.Targets = list
	} else {
		if err := json.Unmarshal(raw, cfg); err != nil {
			return nil, fmt.Errorf("malformed config %s: %v", path, err)
		}
	}
	if len(cfg.Targets) == 0 {
		return nil, fmt.Errorf("config %s defines no targets", path)
	}
	seen := map[string]bool{}
	for i, t := range cfg.Targets {
		if strings.TrimSpace(t.Name) == "" {
			return nil, fmt.Errorf("config %s: target #%d has no name", path, i+1)
		}
		if seen[t.Name] {
			return nil, fmt.Errorf("config %s: duplicate target name %q", path, t.Name)
		}
		seen[t.Name] = true
		switch t.Type {
		case "tcp", "http", "dns":
		case "":
			return nil, fmt.Errorf("config %s: target %q has no type (want tcp, http or dns)", path, t.Name)
		default:
			return nil, fmt.Errorf("config %s: target %q has unknown type %q (want tcp, http or dns)", path, t.Name, t.Type)
		}
		if strings.TrimSpace(t.Target) == "" {
			return nil, fmt.Errorf("config %s: target %q has an empty target address", path, t.Name)
		}
		if t.Type == "http" {
			u, err := url.Parse(t.Target)
			if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
				return nil, fmt.Errorf("config %s: target %q is not a valid http/https URL: %s", path, t.Name, t.Target)
			}
		}
		if t.Type == "tcp" {
			if _, _, err := net.SplitHostPort(t.Target); err != nil {
				return nil, fmt.Errorf("config %s: target %q must be host:port, got %q", path, t.Name, t.Target)
			}
		}
	}
	return cfg, nil
}

// ---------------------------------------------------------------------------
// Probing
// ---------------------------------------------------------------------------

// Result is the outcome of a single probe.
type Result struct {
	Name    string        `json:"name"`
	Type    string        `json:"type"`
	Target  string        `json:"target"`
	State   string        `json:"state"`
	Reason  string        `json:"reason,omitempty"`
	Latency time.Duration `json:"-"`
	Detail  string        `json:"detail,omitempty"`
}

// LatencyMS renders the probe latency for JSON output.
func (r Result) LatencyMS() float64 {
	return float64(r.Latency.Microseconds()) / 1000.0
}

func probeTCP(ctx context.Context, t Target) (string, error) {
	var d net.Dialer
	conn, err := d.DialContext(ctx, "tcp", t.Target)
	if err != nil {
		return "", err
	}
	local := conn.RemoteAddr().String()
	_ = conn.Close()
	return "connected to " + local, nil
}

func probeHTTP(ctx context.Context, t Target, timeout time.Duration) (string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.Target, nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("User-Agent", "linkguard/"+version)
	// Probe the endpoint directly: a watchdog must observe the target, not a
	// proxy in front of it, so Proxy is deliberately nil.
	client := &http.Client{
		Timeout: timeout,
		Transport: &http.Transport{
			Proxy:                 nil,
			DisableKeepAlives:     true,
			DialContext:           (&net.Dialer{}).DialContext,
			TLSHandshakeTimeout:   timeout,
			ResponseHeaderTimeout: timeout,
		},
	}
	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
	if t.ExpectedStatus > 0 {
		if resp.StatusCode != t.ExpectedStatus {
			return "", fmt.Errorf("unexpected status %d (expected %d)", resp.StatusCode, t.ExpectedStatus)
		}
		return fmt.Sprintf("status %d (expected %d)", resp.StatusCode, t.ExpectedStatus), nil
	}
	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		return "", fmt.Errorf("unexpected status %d (expected any 2xx)", resp.StatusCode)
	}
	return fmt.Sprintf("status %d", resp.StatusCode), nil
}

func probeDNS(ctx context.Context, t Target) (string, error) {
	host := t.Target
	if strings.Contains(host, "://") {
		if u, err := url.Parse(host); err == nil && u.Host != "" {
			host = u.Hostname()
		}
	}
	var r net.Resolver
	addrs, err := r.LookupHost(ctx, host)
	if err != nil {
		return "", err
	}
	if len(addrs) == 0 {
		return "", fmt.Errorf("no addresses returned for %s", host)
	}
	shown := addrs
	if len(shown) > 3 {
		shown = shown[:3]
	}
	return fmt.Sprintf("%d address(es): %s", len(addrs), strings.Join(shown, ", ")), nil
}

// probeOne runs a single probe under a hard per-probe timeout.
func probeOne(parent context.Context, t Target, timeout time.Duration) Result {
	ctx, cancel := context.WithTimeout(parent, timeout)
	defer cancel()
	start := time.Now()
	var detail string
	var err error
	switch t.Type {
	case "tcp":
		detail, err = probeTCP(ctx, t)
	case "http":
		detail, err = probeHTTP(ctx, t, timeout)
	case "dns":
		detail, err = probeDNS(ctx, t)
	default:
		err = fmt.Errorf("unknown target type %q", t.Type)
	}
	res := Result{Name: t.Name, Type: t.Type, Target: t.Target, Latency: time.Since(start)}
	if err != nil {
		res.State = stateDown
		res.Reason = cleanReason(ctx, err, timeout)
		return res
	}
	res.State = stateUp
	res.Detail = detail
	return res
}

func cleanReason(ctx context.Context, err error, timeout time.Duration) string {
	msg := err.Error()
	if errors.Is(err, context.DeadlineExceeded) || ctx.Err() == context.DeadlineExceeded {
		return fmt.Sprintf("timeout after %s", timeout)
	}
	msg = strings.ReplaceAll(msg, "\n", " ")
	if len(msg) > 200 {
		msg = msg[:197] + "..."
	}
	return msg
}

// probeRound probes every target concurrently and returns results in config order.
func probeRound(ctx context.Context, targets []Target, timeout time.Duration) []Result {
	results := make([]Result, len(targets))
	done := make(chan int, len(targets))
	for i, t := range targets {
		go func(i int, t Target) {
			results[i] = probeOne(ctx, t, timeout)
			done <- i
		}(i, t)
	}
	for range targets {
		<-done
	}
	return results
}

// ---------------------------------------------------------------------------
// Ledger
// ---------------------------------------------------------------------------

const (
	stateUp   = "up"
	stateDown = "down"

	eventInitial    = "initial"
	eventTransition = "transition"
)

// Event is one append-only ledger record. A record is written only when a
// target's state changes (plus one record the first time it is ever observed).
type Event struct {
	TS     string `json:"ts"`
	Target string `json:"target"`
	Type   string `json:"type"`
	Addr   string `json:"addr"`
	State  string `json:"state"`
	Event  string `json:"event"`
	Reason string `json:"reason,omitempty"`

	parsed time.Time
}

func readLedger(path string) ([]Event, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var out []Event
	for i, line := range strings.Split(string(raw), "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		var e Event
		if err := json.Unmarshal([]byte(line), &e); err != nil {
			return nil, fmt.Errorf("%s line %d: malformed ledger record: %v", path, i+1, err)
		}
		ts, err := time.Parse(time.RFC3339Nano, e.TS)
		if err != nil {
			return nil, fmt.Errorf("%s line %d: bad timestamp %q", path, i+1, e.TS)
		}
		if e.State != stateUp && e.State != stateDown {
			return nil, fmt.Errorf("%s line %d: bad state %q", path, i+1, e.State)
		}
		if e.Target == "" {
			return nil, fmt.Errorf("%s line %d: record has no target name", path, i+1)
		}
		e.parsed = ts
		out = append(out, e)
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].parsed.Before(out[j].parsed) })
	return out, nil
}

// ledgerWriter appends records to the ledger, syncing after each write so a
// signal or crash cannot lose a transition.
type ledgerWriter struct {
	path string
	f    *os.File
}

func openLedger(path string) (*ledgerWriter, error) {
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return nil, fmt.Errorf("cannot open ledger %s: %w", path, err)
	}
	return &ledgerWriter{path: path, f: f}, nil
}

func (w *ledgerWriter) append(e Event) error {
	b, err := json.Marshal(e)
	if err != nil {
		return err
	}
	if _, err := w.f.Write(append(b, '\n')); err != nil {
		return fmt.Errorf("cannot write ledger %s: %w", w.path, err)
	}
	return w.f.Sync()
}

func (w *ledgerWriter) Close() error { return w.f.Close() }

// ---------------------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------------------

// TargetStats is the availability arithmetic for one target.
//
// Window definition: a target's observation window runs from its FIRST ledger
// record to the LAST record in the whole ledger (the most recent moment any
// target was observed). MTTR is the mean of COMPLETED outages only; an outage
// that has not recovered is excluded from MTTR but does count toward the
// outage count, total downtime and longest outage.
type TargetStats struct {
	Name             string  `json:"name"`
	Type             string  `json:"type"`
	Target           string  `json:"target"`
	State            string  `json:"state"`
	Reason           string  `json:"reason,omitempty"`
	FirstSeen        string  `json:"first_seen"`
	LastChange       string  `json:"last_change"`
	WindowSeconds    float64 `json:"window_seconds"`
	UptimeSeconds    float64 `json:"uptime_seconds"`
	DowntimeSeconds  float64 `json:"total_downtime_seconds"`
	UptimePercent    float64 `json:"uptime_percent"`
	Outages          int     `json:"outages"`
	CompletedOutages int     `json:"completed_outages"`
	OngoingOutage    bool    `json:"ongoing_outage"`
	LongestOutage    float64 `json:"longest_outage_seconds"`
	MTTRSeconds      float64 `json:"mttr_seconds"`
	Transitions      int     `json:"transitions"`
}

// FleetStats aggregates every target in the ledger.
type FleetStats struct {
	Targets          int     `json:"targets"`
	Up               int     `json:"up"`
	Down             int     `json:"down"`
	Outages          int     `json:"outages"`
	CompletedOutages int     `json:"completed_outages"`
	DowntimeSeconds  float64 `json:"total_downtime_seconds"`
	WindowSeconds    float64 `json:"total_window_seconds"`
	UptimePercent    float64 `json:"uptime_percent"`
	LongestOutage    float64 `json:"longest_outage_seconds"`
	MTTRSeconds      float64 `json:"mttr_seconds"`
}

// Report is the full `report` payload.
type Report struct {
	Ledger      string        `json:"ledger"`
	LedgerSize  string        `json:"ledger_size"`
	Events      int           `json:"events"`
	WindowStart string        `json:"window_start"`
	WindowEnd   string        `json:"window_end"`
	Targets     []TargetStats `json:"targets"`
	Summary     FleetStats    `json:"summary"`
}

func secs(d time.Duration) float64 { return d.Seconds() }

func computeStats(events []Event) ([]TargetStats, FleetStats, time.Time, time.Time) {
	var stats []TargetStats
	var fleet FleetStats
	if len(events) == 0 {
		return stats, fleet, time.Time{}, time.Time{}
	}
	windowEnd := events[len(events)-1].parsed
	windowStart := events[0].parsed

	order := []string{}
	byTarget := map[string][]Event{}
	for _, e := range events {
		if _, ok := byTarget[e.Target]; !ok {
			order = append(order, e.Target)
		}
		byTarget[e.Target] = append(byTarget[e.Target], e)
	}

	var completedTotal time.Duration
	for _, name := range order {
		evs := byTarget[name]
		last := evs[len(evs)-1]
		st := TargetStats{
			Name:        name,
			Type:        last.Type,
			Target:      last.Addr,
			State:       last.State,
			Reason:      last.Reason,
			FirstSeen:   evs[0].parsed.UTC().Format(time.RFC3339Nano),
			LastChange:  last.parsed.UTC().Format(time.RFC3339Nano),
			Transitions: len(evs),
		}
		window := windowEnd.Sub(evs[0].parsed)
		var downtime, longest, completedSum time.Duration
		for i, e := range evs {
			segEnd := windowEnd
			completed := false
			if i+1 < len(evs) {
				segEnd = evs[i+1].parsed
				completed = true
			}
			if e.State != stateDown {
				continue
			}
			dur := segEnd.Sub(e.parsed)
			if dur < 0 {
				dur = 0
			}
			downtime += dur
			st.Outages++
			if dur > longest {
				longest = dur
			}
			if completed {
				st.CompletedOutages++
				completedSum += dur
			} else {
				st.OngoingOutage = true
			}
		}
		uptime := window - downtime
		if uptime < 0 {
			uptime = 0
		}
		st.WindowSeconds = secs(window)
		st.UptimeSeconds = secs(uptime)
		st.DowntimeSeconds = secs(downtime)
		st.LongestOutage = secs(longest)
		if window > 0 {
			st.UptimePercent = float64(uptime) / float64(window) * 100
		} else if last.State == stateUp {
			st.UptimePercent = 100
		}
		if st.CompletedOutages > 0 {
			st.MTTRSeconds = secs(completedSum) / float64(st.CompletedOutages)
		}
		completedTotal += completedSum

		fleet.Targets++
		if st.State == stateUp {
			fleet.Up++
		} else {
			fleet.Down++
		}
		fleet.Outages += st.Outages
		fleet.CompletedOutages += st.CompletedOutages
		fleet.DowntimeSeconds += st.DowntimeSeconds
		fleet.WindowSeconds += st.WindowSeconds
		if st.LongestOutage > fleet.LongestOutage {
			fleet.LongestOutage = st.LongestOutage
		}
		stats = append(stats, st)
	}
	if fleet.WindowSeconds > 0 {
		fleet.UptimePercent = (fleet.WindowSeconds - fleet.DowntimeSeconds) / fleet.WindowSeconds * 100
	} else if fleet.Down == 0 {
		fleet.UptimePercent = 100
	}
	if fleet.CompletedOutages > 0 {
		fleet.MTTRSeconds = secs(completedTotal) / float64(fleet.CompletedOutages)
	}
	return stats, fleet, windowStart, windowEnd
}

func fmtDur(seconds float64) string {
	d := time.Duration(seconds * float64(time.Second))
	if d == 0 {
		return "0s"
	}
	if d < time.Second {
		return d.Round(time.Millisecond).String()
	}
	return d.Round(10 * time.Millisecond).String()
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

func wantsHelp(args []string) bool {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			return true
		}
	}
	return false
}

func failUsage(u func(io.Writer), format string, a ...any) {
	fmt.Fprintf(os.Stderr, "linkguard: "+format+"\n\n", a...)
	u(os.Stderr)
	os.Exit(1)
}

func cmdWatch(args []string) error {
	if wantsHelp(args) {
		usageWatch(os.Stdout)
		return nil
	}
	fs := flag.NewFlagSet("watch", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	cfgPath := fs.String("config", "", "path to the target config JSON")
	ledgerPath := fs.String("ledger", "", "path to the append-only ledger JSONL")
	interval := fs.Duration("interval", 5*time.Second, "time between probe rounds")
	timeout := fs.Duration("timeout", 3*time.Second, "per-probe timeout")
	once := fs.Bool("once", false, "run a single round and exit")
	quiet := fs.Bool("quiet", false, "print transitions only")
	args = reorderFlags(args, map[string]bool{"config": true, "ledger": true, "interval": true, "timeout": true})
	if err := fs.Parse(args); err != nil {
		failUsage(usageWatch, "%v", err)
	}
	if fs.NArg() > 0 {
		failUsage(usageWatch, "unexpected argument %q", fs.Arg(0))
	}
	if *cfgPath == "" {
		failUsage(usageWatch, "--config is required")
	}
	if *ledgerPath == "" {
		failUsage(usageWatch, "--ledger is required")
	}
	if *timeout <= 0 {
		failUsage(usageWatch, "--timeout must be positive")
	}
	if *interval <= 0 && !*once {
		failUsage(usageWatch, "--interval must be positive")
	}

	cfg, err := loadConfig(*cfgPath)
	if err != nil {
		return err
	}

	known := map[string]string{}
	if prior, err := readLedger(*ledgerPath); err == nil {
		for _, e := range prior {
			known[e.Target] = e.State
		}
	} else if !errors.Is(err, os.ErrNotExist) {
		return err
	}

	lw, err := openLedger(*ledgerPath)
	if err != nil {
		return err
	}
	defer lw.Close()

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	round := 0
	for {
		round++
		results := probeRound(ctx, cfg.Targets, *timeout)
		now := time.Now().UTC()
		up := 0
		var changes []string
		for _, r := range results {
			if r.State == stateUp {
				up++
			}
			prev, seen := known[r.Name]
			if seen && prev == r.State {
				continue
			}
			kind := eventTransition
			if !seen {
				kind = eventInitial
			}
			ev := Event{
				TS:     now.Format(time.RFC3339Nano),
				Target: r.Name,
				Type:   r.Type,
				Addr:   r.Target,
				State:  r.State,
				Event:  kind,
				Reason: r.Reason,
			}
			if err := lw.append(ev); err != nil {
				return err
			}
			known[r.Name] = r.State
			if kind == eventInitial {
				changes = append(changes, fmt.Sprintf("%s: initial state %s", r.Name, strings.ToUpper(r.State)))
			} else {
				changes = append(changes, fmt.Sprintf("%s: %s -> %s", r.Name, strings.ToUpper(prev), strings.ToUpper(r.State)))
			}
		}
		if !*quiet {
			fmt.Printf("[%s] round %d: %d/%d up\n", now.Format(time.RFC3339), round, up, len(results))
			for _, r := range results {
				printResultLine(r)
			}
		}
		for _, c := range changes {
			fmt.Printf("  * LEDGER %s\n", c)
		}
		if len(changes) == 0 && !*quiet {
			fmt.Printf("  (no state changes; ledger unchanged)\n")
		}
		if *once {
			return nil
		}
		select {
		case <-ctx.Done():
			fmt.Printf("[%s] signal received, ledger flushed and closed\n", time.Now().UTC().Format(time.RFC3339))
			return nil
		case <-time.After(*interval):
		}
	}
}

func printResultLine(r Result) {
	label := "UP  "
	note := r.Detail
	if r.State == stateDown {
		label = "DOWN"
		note = r.Reason
	}
	fmt.Printf("  %s  %-16s %-5s %-34s %8s  %s\n",
		label, truncate(r.Name, 16), r.Type, truncate(r.Target, 34),
		r.Latency.Round(time.Millisecond/10).String(), note)
}

func truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	if n <= 3 {
		return s[:n]
	}
	return s[:n-3] + "..."
}

func cmdProbe(args []string) error {
	if wantsHelp(args) {
		usageProbe(os.Stdout)
		return nil
	}
	fs := flag.NewFlagSet("probe", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	cfgPath := fs.String("config", "", "path to the target config JSON")
	timeout := fs.Duration("timeout", 3*time.Second, "per-probe timeout")
	asJSON := fs.Bool("json", false, "emit JSON instead of text")
	args = reorderFlags(args, map[string]bool{"config": true, "timeout": true})
	if err := fs.Parse(args); err != nil {
		failUsage(usageProbe, "%v", err)
	}
	if fs.NArg() > 0 {
		failUsage(usageProbe, "unexpected argument %q", fs.Arg(0))
	}
	if *cfgPath == "" {
		failUsage(usageProbe, "--config is required")
	}
	if *timeout <= 0 {
		failUsage(usageProbe, "--timeout must be positive")
	}
	cfg, err := loadConfig(*cfgPath)
	if err != nil {
		return err
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	started := time.Now().UTC()
	results := probeRound(ctx, cfg.Targets, *timeout)
	up := 0
	for _, r := range results {
		if r.State == stateUp {
			up++
		}
	}
	if *asJSON {
		type jr struct {
			Result
			LatencyMS float64 `json:"latency_ms"`
		}
		payload := struct {
			Timestamp string  `json:"timestamp"`
			Elapsed   float64 `json:"elapsed_seconds"`
			Targets   []jr    `json:"targets"`
			Summary   struct {
				Total int `json:"total"`
				Up    int `json:"up"`
				Down  int `json:"down"`
			} `json:"summary"`
		}{Timestamp: started.Format(time.RFC3339Nano), Elapsed: time.Since(started).Seconds()}
		for _, r := range results {
			payload.Targets = append(payload.Targets, jr{Result: r, LatencyMS: r.LatencyMS()})
		}
		payload.Summary.Total = len(results)
		payload.Summary.Up = up
		payload.Summary.Down = len(results) - up
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		return enc.Encode(payload)
	}
	fmt.Printf("linkguard probe  %s  (%d target(s), timeout %s)\n",
		started.Format(time.RFC3339), len(results), *timeout)
	fmt.Println(strings.Repeat("-", 96))
	for _, r := range results {
		printResultLine(r)
	}
	fmt.Println(strings.Repeat("-", 96))
	fmt.Printf("%d up, %d down, round completed in %s\n", up, len(results)-up,
		time.Since(started).Round(time.Millisecond))
	return nil
}

func cmdReport(args []string) error {
	if wantsHelp(args) {
		usageReport(os.Stdout)
		return nil
	}
	fs := flag.NewFlagSet("report", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	ledgerPath := fs.String("ledger", "", "path to the ledger JSONL")
	asJSON := fs.Bool("json", false, "emit JSON instead of text")
	args = reorderFlags(args, map[string]bool{"ledger": true})
	if err := fs.Parse(args); err != nil {
		failUsage(usageReport, "%v", err)
	}
	if fs.NArg() > 0 {
		failUsage(usageReport, "unexpected argument %q", fs.Arg(0))
	}
	if *ledgerPath == "" {
		failUsage(usageReport, "--ledger is required")
	}
	events, err := readLedger(*ledgerPath)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return fmt.Errorf("ledger file not found: %s (run `linkguard watch` first)", *ledgerPath)
		}
		return err
	}
	size := int64(0)
	if fi, err := os.Stat(*ledgerPath); err == nil {
		size = fi.Size()
	}
	stats, fleet, wStart, wEnd := computeStats(events)
	rep := Report{
		Ledger:     *ledgerPath,
		LedgerSize: humanBytes(size),
		Events:     len(events),
		Targets:    stats,
		Summary:    fleet,
	}
	if len(events) > 0 {
		rep.WindowStart = wStart.UTC().Format(time.RFC3339Nano)
		rep.WindowEnd = wEnd.UTC().Format(time.RFC3339Nano)
	}
	if rep.Targets == nil {
		rep.Targets = []TargetStats{}
	}
	if *asJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		return enc.Encode(rep)
	}
	fmt.Printf("linkguard report  %s  (%s, %d transition record(s))\n",
		rep.Ledger, rep.LedgerSize, rep.Events)
	if len(events) == 0 {
		fmt.Println("ledger is empty: no observations recorded yet, nothing to report")
		return nil
	}
	fmt.Printf("observation window: %s -> %s\n", rep.WindowStart, rep.WindowEnd)
	fmt.Println(strings.Repeat("=", 104))
	fmt.Printf("%-16s %-6s %9s %8s %9s %10s %10s %10s\n",
		"TARGET", "STATE", "UPTIME%", "OUTAGES", "DOWNTIME", "LONGEST", "MTTR", "WINDOW")
	fmt.Println(strings.Repeat("-", 104))
	for _, s := range stats {
		mttr := "n/a"
		if s.CompletedOutages > 0 {
			mttr = fmtDur(s.MTTRSeconds)
		}
		outages := fmt.Sprintf("%d", s.Outages)
		if s.OngoingOutage {
			outages += "*"
		}
		fmt.Printf("%-16s %-6s %8.4f%% %8s %9s %10s %10s %10s\n",
			truncate(s.Name, 16), strings.ToUpper(s.State), s.UptimePercent, outages,
			fmtDur(s.DowntimeSeconds), fmtDur(s.LongestOutage), mttr, fmtDur(s.WindowSeconds))
	}
	fmt.Println(strings.Repeat("-", 104))
	fleetMTTR := "n/a"
	if fleet.CompletedOutages > 0 {
		fleetMTTR = fmtDur(fleet.MTTRSeconds)
	}
	fmt.Printf("FLEET: %d target(s), %d up, %d down | uptime %.4f%% | %d outage(s) (%d completed) | downtime %s | longest %s | MTTR %s\n",
		fleet.Targets, fleet.Up, fleet.Down, fleet.UptimePercent,
		fleet.Outages, fleet.CompletedOutages, fmtDur(fleet.DowntimeSeconds),
		fmtDur(fleet.LongestOutage), fleetMTTR)
	fmt.Println()
	fmt.Println("notes: * marks an outage still ongoing at the end of the window.")
	fmt.Println("       MTTR is the mean of COMPLETED outages only; ongoing outages are")
	fmt.Println("       excluded from MTTR but counted in outages/downtime/longest.")
	fmt.Println("       A target's window starts at its first record and ends at the last")
	fmt.Println("       record in the ledger (the most recent observation of any target).")
	return nil
}

// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------

func usage(w io.Writer) {
	fmt.Fprintf(w, `linkguard %s - connectivity watchdog with an append-only outage ledger

USAGE
    linkguard <command> [flags]

COMMANDS
    watch     Probe targets on a schedule; append UP<->DOWN transitions to a ledger
    probe     One-shot health check of every target (no ledger is written)
    report    Availability statistics computed from a ledger
    help      Show this help

TARGET TYPES
    tcp       Dial host:port; UP when the connection is established
    http      GET a URL; UP when the status matches expected_status (default any 2xx)
    dns       Resolve a hostname; UP when at least one address is returned

CONFIG FILE (JSON)
    {
      "targets": [
        {"name": "api",   "type": "http", "target": "http://127.0.0.1:8080/", "expected_status": 200},
        {"name": "db",    "type": "tcp",  "target": "127.0.0.1:5432"},
        {"name": "resolv","type": "dns",  "target": "localhost"}
      ]
    }
    A bare JSON array of target objects is also accepted.

EXAMPLES
    linkguard probe  --config targets.json
    linkguard watch  --config targets.json --ledger link.jsonl --once
    linkguard watch  --config targets.json --ledger link.jsonl --interval 30s
    linkguard report --ledger link.jsonl --json

Run "linkguard <command> --help" for per-command flags.
`, version)
}

func usageWatch(w io.Writer) {
	fmt.Fprint(w, `linkguard watch - probe on a schedule and record state transitions

USAGE
    linkguard watch --config <targets.json> --ledger <ledger.jsonl> [flags]

FLAGS
    --config <path>    Target config JSON (required)
    --ledger <path>    Append-only ledger JSONL, created if absent (required)
    --interval <dur>   Time between rounds (default 5s)
    --timeout <dur>    Per-probe timeout (default 3s)
    --once             Run exactly one round and exit
    --quiet            Print transitions only, not every probe
    -h, --help         Show this help

The ledger is a TRANSITION log, not a sample dump: a record is appended only
when a target changes state, plus one "initial" record the first time a target
is observed. Prior state is recovered from the ledger on start, so repeated
--once runs drive the same state machine as a long-running watch.

Ctrl-C or SIGTERM stops the loop cleanly; every record is fsynced when written.
`)
}

func usageProbe(w io.Writer) {
	fmt.Fprint(w, `linkguard probe - one-shot health check, no ledger written

USAGE
    linkguard probe --config <targets.json> [flags]

FLAGS
    --config <path>    Target config JSON (required)
    --timeout <dur>    Per-probe timeout (default 3s)
    --json             Emit JSON instead of text
    -h, --help         Show this help

All targets are probed concurrently, so one dead target cannot stall the round.
`)
}

func usageReport(w io.Writer) {
	fmt.Fprint(w, `linkguard report - availability statistics from a ledger

USAGE
    linkguard report --ledger <ledger.jsonl> [flags]

FLAGS
    --ledger <path>    Ledger JSONL written by "linkguard watch" (required)
    --json             Emit JSON instead of text
    -h, --help         Show this help

PER TARGET
    current state, observation window, uptime %, outage count, total downtime,
    longest outage and MTTR, plus a fleet-wide summary.

DEFINITIONS
    window     first record for that target -> last record in the whole ledger
    outage     a "down" record until the next record (or the window end)
    downtime   sum of all outage durations, ongoing outages included
    MTTR       mean duration of COMPLETED outages only (ongoing excluded)
    uptime %   (window - downtime) / window * 100
`)
}

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// 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.Stderr)
		os.Exit(1)
	}
	switch args[0] {
	case "-h", "--help", "help":
		usage(os.Stdout)
		return
	case "-v", "--version", "version":
		fmt.Printf("linkguard %s\n", version)
		return
	}
	var err error
	switch args[0] {
	case "watch":
		err = cmdWatch(args[1:])
	case "probe":
		err = cmdProbe(args[1:])
	case "report":
		err = cmdReport(args[1:])
	default:
		fmt.Fprintf(os.Stderr, "linkguard: unknown command %q\n\n", args[0])
		usage(os.Stderr)
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintf(os.Stderr, "linkguard: %v\n", err)
		os.Exit(1)
	}
}
