// Command opstunnel is a plain TCP port forwarder with per-connection
// accounting. It listens locally, proxies bytes to a target both ways, and
// appends one auditable JSON record per finished connection: who connected,
// for how long, how many bytes moved in each direction, and why it ended.
//
// There is NO encryption and NO authentication. See README.txt.
package main

import (
	"context"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net"
	"os"
	"os/signal"
	"sort"
	"strings"
	"sync"
	"sync/atomic"
	"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])
}

// ---------------------------------------------------------------------------
// Connection records
// ---------------------------------------------------------------------------

// Close reasons recorded in the ledger.
const (
	reasonClientClosed = "client_closed"
	reasonTargetClosed = "target_closed"
	reasonIdleTimeout  = "idle_timeout"
	reasonShutdown     = "shutdown"
	reasonDeniedCIDR   = "denied_not_allowed"
	reasonMaxConns     = "denied_max_conns"
	reasonDialFailed   = "target_unreachable"
	reasonReadError    = "read_error"
	reasonWriteError   = "write_error"
)

// ConnRecord is one line of the append-only connection ledger.
type ConnRecord struct {
	ID              int64   `json:"id"`
	Client          string  `json:"client"`
	Target          string  `json:"target"`
	Start           string  `json:"start"`
	End             string  `json:"end"`
	DurationSeconds float64 `json:"duration_seconds"`
	BytesToTarget   int64   `json:"bytes_client_to_target"`
	BytesToClient   int64   `json:"bytes_target_to_client"`
	Reason          string  `json:"reason"`
	Detail          string  `json:"detail,omitempty"`
}

// recordWriter appends records to the ledger, syncing after each line so a
// signal or crash cannot lose an audit record or leave a half-written line.
type recordWriter struct {
	path string
	mu   sync.Mutex
	f    *os.File
}

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

func (w *recordWriter) append(rec ConnRecord) error {
	if w.f == nil {
		return nil
	}
	b, err := json.Marshal(rec)
	if err != nil {
		return err
	}
	w.mu.Lock()
	defer w.mu.Unlock()
	if _, err := w.f.Write(append(b, '\n')); err != nil {
		return fmt.Errorf("cannot write log %s: %w", w.path, err)
	}
	return w.f.Sync()
}

func (w *recordWriter) Close() error {
	if w.f == nil {
		return nil
	}
	return w.f.Close()
}

func readRecords(path string) ([]ConnRecord, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var out []ConnRecord
	for i, line := range strings.Split(string(raw), "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		var r ConnRecord
		if err := json.Unmarshal([]byte(line), &r); err != nil {
			return nil, fmt.Errorf("%s line %d: malformed connection record: %v", path, i+1, err)
		}
		if r.Client == "" {
			return nil, fmt.Errorf("%s line %d: record has no client address", path, i+1)
		}
		if r.Reason == "" {
			return nil, fmt.Errorf("%s line %d: record has no close reason", path, i+1)
		}
		if _, err := time.Parse(time.RFC3339Nano, r.Start); err != nil {
			return nil, fmt.Errorf("%s line %d: bad start timestamp %q", path, i+1, r.Start)
		}
		out = append(out, r)
	}
	return out, nil
}

// ---------------------------------------------------------------------------
// Forwarding
// ---------------------------------------------------------------------------

// session is one accepted connection being proxied to the target.
type session struct {
	id     int64
	client net.Conn
	server net.Conn
	start  time.Time

	toTarget atomic.Int64
	toClient atomic.Int64
	lastAct  atomic.Int64 // unix nanos of the last byte moved in either direction

	mu     sync.Mutex
	reason string
	detail string

	closeOnce sync.Once
}

func (s *session) touch() { s.lastAct.Store(time.Now().UnixNano()) }

func (s *session) setReason(reason, detail string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.reason == "" {
		s.reason = reason
		s.detail = detail
	}
}

func (s *session) why() (string, string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.reason, s.detail
}

// closeBoth tears the pair down; safe to call from any goroutine, any number
// of times.
func (s *session) closeBoth() {
	s.closeOnce.Do(func() {
		_ = s.client.Close()
		if s.server != nil {
			_ = s.server.Close()
		}
	})
}

// halfClose shuts down only the write side of a TCP connection, so a peer that
// closed its write side does not kill the opposite direction.
func halfClose(c net.Conn) {
	if tc, ok := c.(*net.TCPConn); ok {
		_ = tc.CloseWrite()
		return
	}
	_ = c.Close()
}

// pump copies src->dst, accounting every byte actually delivered. It returns
// true when the copy ended in a hard error (as opposed to a clean EOF).
func (s *session) pump(dst, src net.Conn, n *atomic.Int64, eofReason string) bool {
	buf := make([]byte, 32*1024)
	for {
		nr, rerr := src.Read(buf)
		if nr > 0 {
			s.touch()
			nw, werr := dst.Write(buf[:nr])
			if nw > 0 {
				n.Add(int64(nw))
				s.touch()
			}
			if werr != nil {
				s.setReason(reasonWriteError, trimErr(werr))
				return true
			}
		}
		if rerr != nil {
			if errors.Is(rerr, io.EOF) {
				s.setReason(eofReason, "")
				halfClose(dst)
				return false
			}
			s.setReason(reasonReadError, trimErr(rerr))
			return true
		}
	}
}

func trimErr(err error) string {
	msg := strings.ReplaceAll(err.Error(), "\n", " ")
	if len(msg) > 200 {
		msg = msg[:197] + "..."
	}
	return msg
}

// forwarder holds everything shared by the accept loop and live sessions.
type forwarder struct {
	listenAddr string
	target     string
	idle       time.Duration
	maxConns   int
	allow      []*net.IPNet
	allowRaw   []string
	jsonOut    bool
	dialTO     time.Duration

	log    *recordWriter
	nextID atomic.Int64
	active atomic.Int64

	mu       sync.Mutex
	sessions map[int64]*session

	wg sync.WaitGroup
}

func (f *forwarder) allowed(ip net.IP) bool {
	if len(f.allow) == 0 {
		return true
	}
	if ip == nil {
		return false
	}
	for _, n := range f.allow {
		if n.Contains(ip) {
			return true
		}
	}
	return false
}

func (f *forwarder) track(s *session) {
	f.mu.Lock()
	f.sessions[s.id] = s
	f.mu.Unlock()
}

func (f *forwarder) untrack(s *session) {
	f.mu.Lock()
	delete(f.sessions, s.id)
	f.mu.Unlock()
}

// closeAll force-closes every live session, stamping a reason on any that has
// not already decided why it is ending.
func (f *forwarder) closeAll(reason string) int {
	f.mu.Lock()
	live := make([]*session, 0, len(f.sessions))
	for _, s := range f.sessions {
		live = append(live, s)
	}
	f.mu.Unlock()
	for _, s := range live {
		s.setReason(reason, "")
		s.closeBoth()
	}
	return len(live)
}

func (f *forwarder) emit(format string, a ...any) {
	if f.jsonOut {
		return
	}
	fmt.Printf(format+"\n", a...)
}

func (f *forwarder) emitJSON(v any) {
	if !f.jsonOut {
		return
	}
	b, err := json.Marshal(v)
	if err != nil {
		return
	}
	fmt.Println(string(b))
}

// finish writes the ledger record for a connection and reports it.
func (f *forwarder) finish(s *session, end time.Time) {
	reason, detail := s.why()
	if reason == "" {
		reason = reasonClientClosed
	}
	rec := ConnRecord{
		ID:              s.id,
		Client:          s.client.RemoteAddr().String(),
		Target:          f.target,
		Start:           s.start.UTC().Format(time.RFC3339Nano),
		End:             end.UTC().Format(time.RFC3339Nano),
		DurationSeconds: end.Sub(s.start).Seconds(),
		BytesToTarget:   s.toTarget.Load(),
		BytesToClient:   s.toClient.Load(),
		Reason:          reason,
		Detail:          detail,
	}
	f.writeRecord(rec)
}

func (f *forwarder) writeRecord(rec ConnRecord) {
	if err := f.log.append(rec); err != nil {
		fmt.Fprintf(os.Stderr, "opstunnel: %v\n", err)
	}
	if f.jsonOut {
		type ev struct {
			Event string `json:"event"`
			ConnRecord
		}
		f.emitJSON(ev{Event: "conn_close", ConnRecord: rec})
		return
	}
	f.emit("[%d] close %s  %s  up %s  down %s  (%s)%s",
		rec.ID, rec.Client, fmtDur(rec.DurationSeconds),
		humanBytes(rec.BytesToTarget), humanBytes(rec.BytesToClient),
		rec.Reason, detailSuffix(rec.Detail))
}

func detailSuffix(d string) string {
	if d == "" {
		return ""
	}
	return " " + d
}

// reject logs and closes a connection that is never proxied at all.
func (f *forwarder) reject(c net.Conn, id int64, start time.Time, reason, detail string) {
	_ = c.Close()
	rec := ConnRecord{
		ID:              id,
		Client:          c.RemoteAddr().String(),
		Target:          f.target,
		Start:           start.UTC().Format(time.RFC3339Nano),
		End:             time.Now().UTC().Format(time.RFC3339Nano),
		DurationSeconds: time.Since(start).Seconds(),
		Reason:          reason,
		Detail:          detail,
	}
	f.writeRecord(rec)
}

// handle proxies one accepted connection until both directions are done.
func (f *forwarder) handle(c net.Conn, id int64, start time.Time) {
	defer f.wg.Done()
	defer f.active.Add(-1)

	s := &session{id: id, client: c, start: start}
	s.touch()

	server, err := net.DialTimeout("tcp", f.target, f.dialTO)
	if err != nil {
		s.setReason(reasonDialFailed, trimErr(err))
		s.closeBoth()
		f.finish(s, time.Now())
		return
	}
	s.server = server
	f.track(s)
	defer f.untrack(s)

	if f.jsonOut {
		f.emitJSON(map[string]any{
			"event":  "conn_open",
			"id":     id,
			"client": c.RemoteAddr().String(),
			"target": f.target,
			"ts":     start.UTC().Format(time.RFC3339Nano),
		})
	} else {
		f.emit("[%d] open  %s -> %s", id, c.RemoteAddr().String(), f.target)
	}

	done := make(chan struct{})
	if f.idle > 0 {
		go f.watchIdle(s, done)
	}

	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		if s.pump(server, c, &s.toTarget, reasonClientClosed) {
			s.closeBoth()
		}
	}()
	go func() {
		defer wg.Done()
		if s.pump(c, server, &s.toClient, reasonTargetClosed) {
			s.closeBoth()
		}
	}()
	wg.Wait()
	close(done)
	s.closeBoth()
	f.finish(s, time.Now())
}

// watchIdle closes a session that has moved no bytes in either direction for
// longer than the idle timeout.
func (f *forwarder) watchIdle(s *session, done <-chan struct{}) {
	for {
		last := time.Unix(0, s.lastAct.Load())
		wait := f.idle - time.Since(last)
		if wait <= 0 {
			s.setReason(reasonIdleTimeout, "")
			s.closeBoth()
			return
		}
		select {
		case <-done:
			return
		case <-time.After(wait):
		}
	}
}

func (f *forwarder) serve(ctx context.Context, ln net.Listener) {
	for {
		c, err := ln.Accept()
		if err != nil {
			select {
			case <-ctx.Done():
				return
			default:
			}
			var ne net.Error
			if errors.As(err, &ne) && ne.Timeout() {
				continue
			}
			return
		}
		start := time.Now()
		id := f.nextID.Add(1)

		host, _, _ := net.SplitHostPort(c.RemoteAddr().String())
		if !f.allowed(net.ParseIP(host)) {
			f.reject(c, id, start, reasonDeniedCIDR, host+" not in "+strings.Join(f.allowRaw, ","))
			continue
		}
		if f.maxConns > 0 && int(f.active.Load()) >= f.maxConns {
			f.reject(c, id, start, reasonMaxConns,
				fmt.Sprintf("%d connection(s) already open", f.active.Load()))
			continue
		}
		f.active.Add(1)
		f.wg.Add(1)
		go f.handle(c, id, start)
	}
}

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

// ClientStats aggregates every connection made by one client IP.
type ClientStats struct {
	Client        string  `json:"client"`
	Connections   int     `json:"connections"`
	BytesToTarget int64   `json:"bytes_client_to_target"`
	BytesToClient int64   `json:"bytes_target_to_client"`
	TotalBytes    int64   `json:"total_bytes"`
	Seconds       float64 `json:"total_seconds"`
}

// Summary is the `status` payload.
type Summary struct {
	Log           string         `json:"log"`
	LogSize       string         `json:"log_size"`
	Connections   int            `json:"connections"`
	Accepted      int            `json:"accepted"`
	Refused       int            `json:"refused"`
	BytesToTarget int64          `json:"bytes_client_to_target"`
	BytesToClient int64          `json:"bytes_target_to_client"`
	TotalBytes    int64          `json:"total_bytes"`
	TotalSeconds  float64        `json:"total_seconds"`
	First         string         `json:"first_connection,omitempty"`
	Last          string         `json:"last_connection,omitempty"`
	Longest       *ConnRecord    `json:"longest_connection,omitempty"`
	Busiest       *ClientStats   `json:"busiest_client,omitempty"`
	Reasons       map[string]int `json:"reasons"`
	Clients       []ClientStats  `json:"clients"`
}

// refusedReasons are the outcomes where no proxying ever happened.
func isRefusal(reason string) bool {
	switch reason {
	case reasonDeniedCIDR, reasonMaxConns, reasonDialFailed:
		return true
	}
	return false
}

func summarize(path string, recs []ConnRecord) Summary {
	s := Summary{
		Log:     path,
		Reasons: map[string]int{},
		Clients: []ClientStats{},
	}
	if fi, err := os.Stat(path); err == nil {
		s.LogSize = humanBytes(fi.Size())
	}
	s.Connections = len(recs)
	byClient := map[string]*ClientStats{}
	var order []string
	for i := range recs {
		r := recs[i]
		s.Reasons[r.Reason]++
		if isRefusal(r.Reason) {
			s.Refused++
		} else {
			s.Accepted++
		}
		s.BytesToTarget += r.BytesToTarget
		s.BytesToClient += r.BytesToClient
		s.TotalSeconds += r.DurationSeconds
		if s.Longest == nil || r.DurationSeconds > s.Longest.DurationSeconds {
			cp := r
			s.Longest = &cp
		}
		if s.First == "" || r.Start < s.First {
			s.First = r.Start
		}
		if s.Last == "" || r.Start > s.Last {
			s.Last = r.Start
		}
		ip := r.Client
		if h, _, err := net.SplitHostPort(r.Client); err == nil {
			ip = h
		}
		cs, ok := byClient[ip]
		if !ok {
			cs = &ClientStats{Client: ip}
			byClient[ip] = cs
			order = append(order, ip)
		}
		cs.Connections++
		cs.BytesToTarget += r.BytesToTarget
		cs.BytesToClient += r.BytesToClient
		cs.TotalBytes += r.BytesToTarget + r.BytesToClient
		cs.Seconds += r.DurationSeconds
	}
	s.TotalBytes = s.BytesToTarget + s.BytesToClient
	for _, ip := range order {
		s.Clients = append(s.Clients, *byClient[ip])
	}
	sort.SliceStable(s.Clients, func(i, j int) bool {
		if s.Clients[i].TotalBytes != s.Clients[j].TotalBytes {
			return s.Clients[i].TotalBytes > s.Clients[j].TotalBytes
		}
		return s.Clients[i].Connections > s.Clients[j].Connections
	})
	if len(s.Clients) > 0 {
		b := s.Clients[0]
		s.Busiest = &b
	}
	return s
}

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.Microsecond).String()
	}
	return d.Round(time.Millisecond).String()
}

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

// ---------------------------------------------------------------------------
// 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, "opstunnel: "+format+"\n\n", a...)
	u(os.Stderr)
	os.Exit(1)
}

// stringList collects a flag that may be repeated.
type stringList []string

func (s *stringList) String() string { return strings.Join(*s, ",") }

func (s *stringList) Set(v string) error {
	for _, part := range strings.Split(v, ",") {
		part = strings.TrimSpace(part)
		if part != "" {
			*s = append(*s, part)
		}
	}
	return nil
}

func cmdForward(args []string) error {
	if wantsHelp(args) {
		usageForward(os.Stdout)
		return nil
	}
	fs := flag.NewFlagSet("forward", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	listen := fs.String("listen", "", "local address to listen on (host:port)")
	target := fs.String("target", "", "target address to forward to (host:port)")
	maxConns := fs.Int("max-conns", 0, "maximum simultaneous connections (0 = unlimited)")
	idle := fs.Duration("idle-timeout", 60*time.Second, "close a connection idle this long (0 = never)")
	dialTO := fs.Duration("dial-timeout", 5*time.Second, "timeout for dialling the target")
	grace := fs.Duration("grace", 5*time.Second, "how long in-flight connections may finish after a signal")
	logPath := fs.String("log", "", "append one JSON record per connection to this file")
	asJSON := fs.Bool("json", false, "emit JSON events instead of text")
	var allow stringList
	fs.Var(&allow, "allow", "CIDR allowed to connect (repeatable)")

	args = reorderFlags(args, map[string]bool{
		"listen": true, "target": true, "max-conns": true, "idle-timeout": true,
		"dial-timeout": true, "grace": true, "log": true, "allow": true,
	})
	if err := fs.Parse(args); err != nil {
		failUsage(usageForward, "%v", err)
	}
	if fs.NArg() > 0 {
		failUsage(usageForward, "unexpected argument %q", fs.Arg(0))
	}
	if *listen == "" {
		failUsage(usageForward, "--listen is required")
	}
	if *target == "" {
		failUsage(usageForward, "--target is required")
	}
	if _, _, err := net.SplitHostPort(*listen); err != nil {
		failUsage(usageForward, "--listen must be host:port, got %q", *listen)
	}
	if _, _, err := net.SplitHostPort(*target); err != nil {
		failUsage(usageForward, "--target must be host:port, got %q", *target)
	}
	if *maxConns < 0 {
		failUsage(usageForward, "--max-conns cannot be negative")
	}
	if *idle < 0 {
		failUsage(usageForward, "--idle-timeout cannot be negative")
	}
	if *dialTO <= 0 {
		failUsage(usageForward, "--dial-timeout must be positive")
	}

	var nets []*net.IPNet
	for _, c := range allow {
		_, n, err := net.ParseCIDR(c)
		if err != nil {
			failUsage(usageForward, "malformed --allow %q: not a CIDR (want e.g. 10.0.0.0/8)", c)
		}
		nets = append(nets, n)
	}

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

	ln, err := net.Listen("tcp", *listen)
	if err != nil {
		return fmt.Errorf("cannot listen on %s: %v", *listen, err)
	}

	f := &forwarder{
		listenAddr: ln.Addr().String(),
		target:     *target,
		idle:       *idle,
		maxConns:   *maxConns,
		allow:      nets,
		allowRaw:   allow,
		jsonOut:    *asJSON,
		dialTO:     *dialTO,
		log:        lw,
		sessions:   map[int64]*session{},
	}

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

	if *asJSON {
		f.emitJSON(map[string]any{
			"event": "listening", "listen": f.listenAddr, "target": f.target,
			"max_conns": *maxConns, "idle_timeout_seconds": idle.Seconds(),
			"allow": append([]string{}, allow...), "log": *logPath,
			"ts": time.Now().UTC().Format(time.RFC3339Nano),
		})
	} else {
		f.emit("opstunnel %s  forwarding %s -> %s", version, f.listenAddr, f.target)
		f.emit("  max-conns %s  idle-timeout %s  allow %s  log %s",
			limitLabel(*maxConns), durLabel(*idle), allowLabel(allow), pathLabel(*logPath))
		f.emit("  WARNING: plain TCP, no encryption and no authentication.")
	}

	served := make(chan struct{})
	go func() {
		f.serve(ctx, ln)
		close(served)
	}()

	<-ctx.Done()
	_ = ln.Close()
	<-served

	// Give in-flight connections a chance to finish, then close what is left.
	drained := make(chan struct{})
	go func() {
		f.wg.Wait()
		close(drained)
	}()
	forced := 0
	select {
	case <-drained:
	case <-time.After(*grace):
		forced = f.closeAll(reasonShutdown)
		<-drained
	}
	total := f.nextID.Load()
	if *asJSON {
		f.emitJSON(map[string]any{
			"event": "shutdown", "connections": total, "forced_closed": forced,
			"ts": time.Now().UTC().Format(time.RFC3339Nano),
		})
	} else {
		f.emit("shutdown: %d connection(s) handled, %d forced closed, listener released", total, forced)
	}
	return nil
}

func limitLabel(n int) string {
	if n <= 0 {
		return "unlimited"
	}
	return fmt.Sprintf("%d", n)
}

func durLabel(d time.Duration) string {
	if d <= 0 {
		return "off"
	}
	return d.String()
}

func allowLabel(a []string) string {
	if len(a) == 0 {
		return "any"
	}
	return strings.Join(a, ",")
}

func pathLabel(p string) string {
	if p == "" {
		return "(none)"
	}
	return p
}

func cmdStatus(args []string) error {
	if wantsHelp(args) {
		usageStatus(os.Stdout)
		return nil
	}
	fs := flag.NewFlagSet("status", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	logPath := fs.String("log", "", "connection log written by `opstunnel forward`")
	asJSON := fs.Bool("json", false, "emit JSON instead of text")
	args = reorderFlags(args, map[string]bool{"log": true})
	if err := fs.Parse(args); err != nil {
		failUsage(usageStatus, "%v", err)
	}
	if fs.NArg() > 0 {
		failUsage(usageStatus, "unexpected argument %q", fs.Arg(0))
	}
	if *logPath == "" {
		failUsage(usageStatus, "--log is required")
	}
	recs, err := readRecords(*logPath)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return fmt.Errorf("log file not found: %s (run `opstunnel forward --log %s` first)", *logPath, *logPath)
		}
		return err
	}
	sum := summarize(*logPath, recs)
	if *asJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		return enc.Encode(sum)
	}
	fmt.Printf("opstunnel status  %s  (%s, %d connection record(s))\n", sum.Log, sum.LogSize, sum.Connections)
	if sum.Connections == 0 {
		fmt.Println("log is empty: no connections recorded yet")
		return nil
	}
	fmt.Printf("window: %s -> %s\n", sum.First, sum.Last)
	fmt.Println(strings.Repeat("=", 88))
	fmt.Printf("connections     : %d (%d proxied, %d refused)\n", sum.Connections, sum.Accepted, sum.Refused)
	fmt.Printf("client -> target: %s (%d bytes)\n", humanBytes(sum.BytesToTarget), sum.BytesToTarget)
	fmt.Printf("target -> client: %s (%d bytes)\n", humanBytes(sum.BytesToClient), sum.BytesToClient)
	fmt.Printf("total moved     : %s (%d bytes)\n", humanBytes(sum.TotalBytes), sum.TotalBytes)
	fmt.Printf("connected time  : %s\n", fmtDur(sum.TotalSeconds))
	if sum.Longest != nil {
		fmt.Printf("longest conn    : #%d %s for %s (%s up, %s down, %s)\n",
			sum.Longest.ID, sum.Longest.Client, fmtDur(sum.Longest.DurationSeconds),
			humanBytes(sum.Longest.BytesToTarget), humanBytes(sum.Longest.BytesToClient),
			sum.Longest.Reason)
	}
	if sum.Busiest != nil {
		fmt.Printf("busiest client  : %s (%d conn, %s total)\n",
			sum.Busiest.Client, sum.Busiest.Connections, humanBytes(sum.Busiest.TotalBytes))
	}
	fmt.Println(strings.Repeat("-", 88))
	fmt.Printf("%-24s %6s %14s %14s %12s\n", "CLIENT", "CONNS", "UP", "DOWN", "TIME")
	for _, c := range sum.Clients {
		fmt.Printf("%-24s %6d %14s %14s %12s\n", truncate(c.Client, 24), c.Connections,
			humanBytes(c.BytesToTarget), humanBytes(c.BytesToClient), fmtDur(c.Seconds))
	}
	fmt.Println(strings.Repeat("-", 88))
	fmt.Println("how connections ended:")
	reasons := make([]string, 0, len(sum.Reasons))
	for r := range sum.Reasons {
		reasons = append(reasons, r)
	}
	sort.SliceStable(reasons, func(i, j int) bool {
		if sum.Reasons[reasons[i]] != sum.Reasons[reasons[j]] {
			return sum.Reasons[reasons[i]] > sum.Reasons[reasons[j]]
		}
		return reasons[i] < reasons[j]
	})
	for _, r := range reasons {
		fmt.Printf("  %-20s %d\n", r, sum.Reasons[r])
	}
	return nil
}

func cmdCheck(args []string) error {
	if wantsHelp(args) {
		usageCheck(os.Stdout)
		return nil
	}
	fs := flag.NewFlagSet("check", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	target := fs.String("target", "", "target address to test (host:port)")
	timeout := fs.Duration("timeout", 3*time.Second, "dial timeout")
	args = reorderFlags(args, map[string]bool{"target": true, "timeout": true})
	if err := fs.Parse(args); err != nil {
		failUsage(usageCheck, "%v", err)
	}
	if fs.NArg() > 0 {
		failUsage(usageCheck, "unexpected argument %q", fs.Arg(0))
	}
	if *target == "" {
		failUsage(usageCheck, "--target is required")
	}
	if _, _, err := net.SplitHostPort(*target); err != nil {
		failUsage(usageCheck, "--target must be host:port, got %q", *target)
	}
	if *timeout <= 0 {
		failUsage(usageCheck, "--timeout must be positive")
	}
	start := time.Now()
	conn, err := net.DialTimeout("tcp", *target, *timeout)
	elapsed := time.Since(start)
	if err != nil {
		return fmt.Errorf("target %s is NOT reachable after %s: %s",
			*target, elapsed.Round(time.Microsecond), trimErr(err))
	}
	remote := conn.RemoteAddr().String()
	_ = conn.Close()
	fmt.Printf("target %s is reachable (%s) in %s\n", *target, remote, elapsed.Round(time.Microsecond))
	return nil
}

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

func usage(w io.Writer) {
	fmt.Fprintf(w, `opstunnel %s - plain TCP port forwarder with per-connection accounting

USAGE
    opstunnel <command> [flags]

COMMANDS
    forward   Listen locally, proxy bytes to a target, log every connection
    status    Summarise a connection log (bytes, durations, how they ended)
    check     One-shot pre-flight: can the target be reached at all?
    help      Show this help

SECURITY
    opstunnel forwards PLAIN TCP. There is NO encryption and NO authentication.
    Use it only on a trusted network or inside an existing secure channel.

EXAMPLES
    opstunnel check   --target 10.0.0.5:5432
    opstunnel forward --listen 127.0.0.1:15432 --target 10.0.0.5:5432 --log conns.jsonl
    opstunnel forward --listen 0.0.0.0:8080 --target api:80 --allow 10.0.0.0/8 --max-conns 50
    opstunnel status  --log conns.jsonl --json

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

func usageForward(w io.Writer) {
	fmt.Fprint(w, `opstunnel forward - proxy TCP connections to a target and account for them

USAGE
    opstunnel forward --listen <host:port> --target <host:port> [flags]

FLAGS
    --listen <addr>       Local address to accept connections on (required)
    --target <host:port>  Where to forward those connections (required)
    --allow <cidr>        Only accept clients inside this CIDR; repeatable,
                          comma-separated lists accepted. Default: any client
    --max-conns <n>       Refuse connections beyond n simultaneous (0 = unlimited)
    --idle-timeout <dur>  Close a connection after this long with no bytes in
                          either direction (default 60s, 0 = never)
    --dial-timeout <dur>  Timeout when dialling the target (default 5s)
    --grace <dur>         After SIGINT/SIGTERM, how long in-flight connections
                          may finish before being closed (default 5s)
    --log <path>          Append one JSON record per connection (created if absent)
    --json                Emit JSON events on stdout instead of text
    -h, --help            Show this help

LOG RECORD (one JSON object per line)
    id, client, target, start, end, duration_seconds,
    bytes_client_to_target, bytes_target_to_client, reason

REASONS
    client_closed       the client closed its side first
    target_closed       the target closed its side first
    idle_timeout        no bytes moved for --idle-timeout
    shutdown            still open when the grace period after a signal expired
    denied_not_allowed  client address outside every --allow CIDR
    denied_max_conns    --max-conns already reached
    target_unreachable  the target could not be dialled
    read_error/write_error  the transfer failed mid-stream

Both directions are proxied concurrently and a half-close is preserved: a client
that closes its write side still receives the rest of the target's response.
SIGINT/SIGTERM stops accepting, drains in-flight connections and releases the
port. Every record is fsynced as it is written.

NO ENCRYPTION, NO AUTHENTICATION - this is plain TCP forwarding.
`)
}

func usageStatus(w io.Writer) {
	fmt.Fprint(w, `opstunnel status - summarise a connection log

USAGE
    opstunnel status --log <conns.jsonl> [flags]

FLAGS
    --log <path>   Connection log written by "opstunnel forward" (required)
    --json         Emit JSON instead of text
    -h, --help     Show this help

REPORTS
    connection count (proxied vs refused), total bytes in each direction,
    the longest connection, the busiest client, per-client totals, and a
    breakdown of how connections ended.
`)
}

func usageCheck(w io.Writer) {
	fmt.Fprint(w, `opstunnel check - can the target be reached at all?

USAGE
    opstunnel check --target <host:port> [flags]

FLAGS
    --target <host:port>  Address to dial (required)
    --timeout <dur>       Dial timeout (default 3s)
    -h, --help            Show this help

Exits 0 when the TCP connection is established, 1 otherwise. Nothing is sent to
the target: the connection is closed as soon as it is up.
`)
}

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// 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.Stderr)
		os.Exit(1)
	}
	switch args[0] {
	case "-h", "--help", "help":
		usage(os.Stdout)
		return
	case "-v", "--version", "version":
		fmt.Printf("opstunnel %s\n", version)
		return
	}
	var err error
	switch args[0] {
	case "forward":
		err = cmdForward(args[1:])
	case "status":
		err = cmdStatus(args[1:])
	case "check":
		err = cmdCheck(args[1:])
	default:
		fmt.Fprintf(os.Stderr, "opstunnel: unknown command %q\n\n", args[0])
		usage(os.Stderr)
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintf(os.Stderr, "opstunnel: %v\n", err)
		os.Exit(1)
	}
}
