// Command sshdesk reads an OpenSSH ssh_config and reports the settings ssh
// would actually use for a host, with the file and line each one came from.
// It is a static analyser: it never opens a connection.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"os/user"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
)

const appName = "sshdesk"

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (verbatim across the tool line)
// ---------------------------------------------------------------------------

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

// ---------------------------------------------------------------------------
// Redaction
// ---------------------------------------------------------------------------

// privateKeyRe matches a PEM private-key header in any of its spellings.
var privateKeyRe = regexp.MustCompile(`(?i)-{2,}\s*BEGIN[A-Z0-9 ]*PRIVATE KEY|PRIVATE KEY-{2,}`)

// redact removes anything that looks like private key material. SSHDesk never
// reads a key file, but a config value could still contain one by accident and
// it must not be echoed to a terminal, a log or a JSON report.
func redact(s string) string {
	if privateKeyRe.MatchString(s) {
		return "[redacted: private key material]"
	}
	return s
}

func redactAll(ss []string) []string {
	if ss == nil {
		return nil
	}
	out := make([]string, len(ss))
	for i, s := range ss {
		out[i] = redact(s)
	}
	return out
}

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

func usage(w io.Writer) {
	fmt.Fprintf(w, `%s - your ssh config, finally legible (Techlosoft Connectivity Desk)

USAGE
  %s resolve <host>            [--config <file>] [--user <name>] [--json]
  %s explain <host> <keyword>  [--config <file>] [--user <name>] [--json]
  %s hosts                     [--config <file>] [--json]
  %s check                     [--config <file>] [--json]
  %s graph                     [--config <file>] [--out ssh.svg] [--json]
  %s help | -h | --help

COMMANDS
  resolve    Print the effective setting for every keyword that applies to
             <host>, exactly as ssh would resolve it, with the file:line each
             value came from and the connection target it adds up to.
  explain    Print every declaration of <keyword> that could have applied to
             <host>, in file order, marking the winner and saying why each
             loser lost.
  hosts      List every concrete host declared in the config with its resolved
             user@hostname:port.
  check      Analyse the config: blocks shadowed by an earlier wildcard,
             duplicate patterns, missing or world-readable identity files,
             broken or looping ProxyJump chains, settings that weaken
             security, and unknown keywords with a suggested spelling.
  graph      Draw the ProxyJump topology. Without --out it prints a text
             topology; --out writes a standalone SVG.

FLAGS
  --config <file>   ssh_config to read. Default: ~/.ssh/config
  --system <file>   System config read AFTER the user one, so the user file
                    still wins. Default: /etc/ssh/ssh_config when it exists.
  --no-system       Do not read the system config at all.
  --user <name>     Resolve as if "ssh -l <name>" had been given. Affects
                    Match user and the reported target.
  --out <file>      Write the SVG here (graph only). The ONLY path %s ever
                    writes to.
  --json            Machine-readable JSON output.

EXAMPLES
  %s resolve web1.example.com
  %s explain web1.example.com Port
  %s check --config ./ssh_config
  %s hosts --json
  %s graph --out ssh.svg

Flags may appear before or after positional arguments.

%s never opens a network connection, never runs a command, and never writes
to anything under the config directory. It only reads.
`, appName, appName, appName, appName, appName, appName, appName, appName,
		appName, appName, appName, appName, appName, appName)
}

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n", appName, fmt.Sprintf(format, args...))
	os.Exit(1)
}

func usageErr(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n\n", appName, fmt.Sprintf(format, args...))
	usage(os.Stderr)
	os.Exit(1)
}

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------

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 "help", "-h", "--help":
		usage(os.Stdout)
		os.Exit(0)
	}
	cmd := args[0]
	rest := args[1:]
	for _, a := range rest {
		if a == "-h" || a == "--help" || a == "help" {
			usage(os.Stdout)
			os.Exit(0)
		}
	}
	switch cmd {
	case "resolve":
		cmdResolve(rest)
	case "explain":
		cmdExplain(rest)
	case "hosts":
		cmdHosts(rest)
	case "check":
		cmdCheck(rest)
	case "graph":
		cmdGraph(rest)
	default:
		usageErr("unknown command %q", cmd)
	}
}

// ---------------------------------------------------------------------------
// Flag plumbing
// ---------------------------------------------------------------------------

var valueFlags = map[string]bool{
	"config": true, "f": true,
	"system": true,
	"user":   true, "u": true,
	"out": true, "o": true,
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = func() { usage(os.Stderr) }
	return fs
}

// common holds the flags every subcommand accepts.
type common struct {
	config   *string
	system   *string
	noSystem *bool
	user     *string
	asJSON   *bool
}

func addCommon(fs *flag.FlagSet) *common {
	c := &common{}
	c.config = fs.String("config", "", "ssh_config to read (default ~/.ssh/config)")
	fs.StringVar(c.config, "f", "", "shorthand for --config")
	c.system = fs.String("system", "/etc/ssh/ssh_config", "system ssh_config, read after the user one")
	c.noSystem = fs.Bool("no-system", false, "do not read the system ssh_config")
	c.user = fs.String("user", "", "resolve as if ssh -l <name> had been given")
	fs.StringVar(c.user, "u", "", "shorthand for --user")
	c.asJSON = fs.Bool("json", false, "JSON output")
	return c
}

// roots returns the config files to parse, in ssh's own order.
func (c *common) roots() []string {
	path := *c.config
	if path == "" {
		home := homeDir()
		if home == "" {
			fail("cannot determine your home directory; pass --config <file>")
		}
		path = filepath.Join(home, ".ssh", "config")
	}
	if _, err := os.Stat(path); err != nil {
		if os.IsNotExist(err) {
			fail("no ssh config at %s (pass --config <file>)", path)
		}
		fail("cannot read %s: %v", path, err)
	}
	roots := []string{path}
	if !*c.noSystem && *c.system != "" {
		if info, err := os.Stat(*c.system); err == nil && !info.IsDir() {
			roots = append(roots, *c.system)
		}
	}
	return roots
}

func (c *common) load() *Config {
	roots := c.roots()
	cfg, err := LoadConfig(roots, LoadOptions{Home: homeDir()})
	if err != nil {
		fail("%v", err)
	}
	return cfg
}

func (c *common) query(host string) Query {
	return Query{Host: host, User: *c.user, LocalUser: localUserName()}
}

func localUserName() string {
	if u, err := user.Current(); err == nil && u.Username != "" {
		return u.Username
	}
	return "unknown"
}

func writeJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fail("%v", err)
	}
}

// ---------------------------------------------------------------------------
// resolve
// ---------------------------------------------------------------------------

func cmdResolve(argv []string) {
	fs := newFlagSet("resolve")
	c := addCommon(fs)
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if fs.NArg() < 1 {
		usageErr("resolve needs a host: %s resolve <host>", appName)
	}
	if fs.NArg() > 1 {
		usageErr("resolve takes exactly one host (got %d arguments)", fs.NArg())
	}
	host := fs.Arg(0)
	cfg := c.load()
	q := c.query(host)
	res := Resolve(cfg, q)
	target := TargetOf(res, q)

	if *c.asJSON {
		type setting struct {
			Keyword string `json:"keyword"`
			Value   string `json:"value"`
			File    string `json:"file"`
			Line    int    `json:"line"`
			Block   string `json:"block"`
		}
		var settings []setting
		for _, k := range res.Keys() {
			for _, d := range res.GetAll(k) {
				settings = append(settings, setting{
					Keyword: canonicalKeyword(d.Key), Value: redact(d.Value),
					File: d.File, Line: d.Line, Block: d.Block,
				})
			}
		}
		writeJSON(map[string]any{
			"host":            host,
			"config":          cfg.Roots,
			"files_read":      cfg.Files,
			"target":          target,
			"settings":        settings,
			"matched_blocks":  res.Blocks,
			"unevaluated":     res.Unevals,
			"declarations":    redactDecls(res.Decls),
			"parse_issue_cnt": len(cfg.Issues),
		})
		return
	}

	fmt.Printf("SSHDesk effective configuration\n")
	fmt.Printf("config : %s\n", strings.Join(cfg.Roots, ", "))
	if len(cfg.Files) > len(cfg.Roots) {
		fmt.Printf("files  : %d read (%d via Include)\n", len(cfg.Files), len(cfg.Files)-len(cfg.Roots))
	}
	fmt.Printf("host   : %s\n", host)
	fmt.Println()

	if len(res.Blocks) == 0 {
		fmt.Printf("no Host or Match block matches %q - ssh would use its built-in defaults\n\n", host)
	} else {
		fmt.Printf("matching blocks, in the order ssh reads them:\n")
		for _, b := range res.Blocks {
			fmt.Printf("  %s:%d  %s\n", b.File, b.Line, b.Text)
		}
		fmt.Println()
	}

	keys := res.Keys()
	if len(keys) == 0 {
		fmt.Printf("no keyword applies to %q.\n", host)
	} else {
		fmt.Printf("effective settings (first obtained value wins):\n")
		width := 0
		for _, k := range keys {
			if n := len(canonicalKeyword(k)); n > width {
				width = n
			}
		}
		vwidth := 0
		for _, k := range keys {
			for _, d := range res.GetAll(k) {
				if n := len(redact(d.Value)); n > vwidth {
					vwidth = n
				}
			}
		}
		if vwidth > 44 {
			vwidth = 44
		}
		for _, k := range keys {
			for _, d := range res.GetAll(k) {
				fmt.Printf("  %-*s  %-*s  %s:%d\n",
					width, canonicalKeyword(k), vwidth, redact(d.Value), d.File, d.Line)
			}
		}
	}
	fmt.Println()
	fmt.Printf("connection target:\n")
	fmt.Printf("  %s\n", target)
	fmt.Printf("    user     : %-24s %s\n", target.User, target.UserSource)
	fmt.Printf("    hostname : %-24s %s\n", target.HostName, target.HostSource)
	fmt.Printf("    port     : %-24s %s\n", target.Port, target.PortSource)
	if target.ProxyJump != "" {
		fmt.Printf("    via      : %-24s %s\n", target.ProxyJump, target.ProxySource)
	}
	if len(res.Unevals) > 0 {
		fmt.Println()
		fmt.Printf("not evaluated:\n")
		for _, u := range res.Unevals {
			fmt.Printf("  %s\n", u)
		}
	}
	if n := countIgnored(res); n > 0 {
		fmt.Println()
		fmt.Printf("%d matching declaration(s) were ignored because an earlier one already\n", n)
		fmt.Printf("set the keyword. Run '%s explain %s <keyword>' to see which.\n", appName, host)
	}
	if len(cfg.Issues) > 0 {
		fmt.Println()
		fmt.Printf("%d problem(s) found while reading the files - run '%s check'.\n", len(cfg.Issues), appName)
	}
}

func countIgnored(res *Result) int {
	n := 0
	for _, d := range res.Decls {
		if d.Status == StatusIgnored {
			n++
		}
	}
	return n
}

func redactDecls(ds []Decl) []Decl {
	out := make([]Decl, len(ds))
	for i, d := range ds {
		d.Value = redact(d.Value)
		out[i] = d
	}
	return out
}

// ---------------------------------------------------------------------------
// explain
// ---------------------------------------------------------------------------

func cmdExplain(argv []string) {
	fs := newFlagSet("explain")
	c := addCommon(fs)
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if fs.NArg() < 2 {
		usageErr("explain needs a host and a keyword: %s explain <host> <keyword>", appName)
	}
	if fs.NArg() > 2 {
		usageErr("explain takes exactly one host and one keyword (got %d arguments)", fs.NArg())
	}
	host, keyword := fs.Arg(0), fs.Arg(1)
	key := strings.ToLower(keyword)
	cfg := c.load()
	q := c.query(host)
	res := Resolve(cfg, q)

	var decls []Decl
	for _, d := range res.Decls {
		if d.Key == key {
			decls = append(decls, d)
		}
	}

	if *c.asJSON {
		out := map[string]any{
			"host":         host,
			"keyword":      canonicalKeyword(key),
			"known":        isKnownKeyword(key),
			"accumulating": listKeywords[key],
			"declarations": redactDecls(decls),
			"effective":    redactDecls(res.GetAll(key)),
		}
		if !isKnownKeyword(key) {
			if s := suggestKeyword(keyword); s != "" {
				out["suggestion"] = s
			}
		}
		writeJSON(out)
		return
	}

	fmt.Printf("SSHDesk declaration trace\n")
	fmt.Printf("config  : %s\n", strings.Join(cfg.Roots, ", "))
	fmt.Printf("host    : %s\n", host)
	fmt.Printf("keyword : %s\n", canonicalKeyword(key))
	if !isKnownKeyword(key) {
		fmt.Printf("          (not a keyword this build recognises")
		if s := suggestKeyword(keyword); s != "" {
			fmt.Printf("; did you mean %s?", s)
		}
		fmt.Printf(")\n")
	}
	if listKeywords[key] {
		fmt.Printf("          (accumulating keyword: every matching block contributes a value)\n")
	}
	fmt.Println()

	if len(decls) == 0 {
		fmt.Printf("%s is never declared in any file that was read.\n", canonicalKeyword(key))
		return
	}

	for i, d := range decls {
		mark := "  "
		switch d.Status {
		case StatusApplied:
			mark = "=>"
		case StatusAppended:
			mark = "+ "
		}
		fmt.Printf("%s #%d  %s:%d\n", mark, i+1, d.File, d.Line)
		fmt.Printf("      block  : %s   (%s)\n", d.Block, d.BlockWhere())
		fmt.Printf("      value  : %s\n", redact(d.Value))
		fmt.Printf("      status : %s\n", statusWord(d.Status))
		fmt.Printf("      why    : %s\n", d.Reason)
		fmt.Println()
	}

	eff := res.GetAll(key)
	switch {
	case len(eff) == 0:
		fmt.Printf("EFFECTIVE: none of these apply to %s, so ssh uses its built-in default.\n", host)
	case listKeywords[key]:
		fmt.Printf("EFFECTIVE: %d value(s), used in this order:\n", len(eff))
		for _, d := range eff {
			fmt.Printf("  %s   (%s:%d)\n", redact(d.Value), d.File, d.Line)
		}
	default:
		fmt.Printf("EFFECTIVE: %s   (from %s:%d)\n", redact(eff[0].Value), eff[0].File, eff[0].Line)
	}
}

func statusWord(s string) string {
	switch s {
	case StatusApplied:
		return "WINNER - this is the value ssh uses"
	case StatusAppended:
		return "used - appended to the list"
	case StatusIgnored:
		return "IGNORED - matched, but too late"
	case StatusNotMatched:
		return "ignored - block does not match this host"
	case StatusNotReached:
		return "ignored - file never read for this host"
	case StatusUnevaluated:
		return "unknown - Match block not evaluated"
	}
	return s
}

// ---------------------------------------------------------------------------
// hosts
// ---------------------------------------------------------------------------

func cmdHosts(argv []string) {
	fs := newFlagSet("hosts")
	c := addCommon(fs)
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if fs.NArg() > 0 {
		usageErr("hosts takes no positional arguments (got %q)", fs.Arg(0))
	}
	cfg := c.load()

	type row struct {
		Host      string `json:"host"`
		User      string `json:"user"`
		HostName  string `json:"hostname"`
		Port      string `json:"port"`
		Target    string `json:"target"`
		ProxyJump string `json:"proxy_jump,omitempty"`
		Identity  string `json:"identity_file,omitempty"`
		File      string `json:"file"`
		Line      int    `json:"line"`
		PortOK    bool   `json:"port_valid"`
	}
	where := map[string]HostPattern{}
	for _, hp := range hostPatterns(cfg) {
		if _, ok := where[hp.Pattern]; !ok {
			where[hp.Pattern] = hp
		}
	}
	var rows []row
	for _, h := range concreteHosts(cfg) {
		q := c.query(h)
		res := Resolve(cfg, q)
		t := TargetOf(res, q)
		r := row{
			Host: h, User: t.User, HostName: t.HostName, Port: t.Port,
			Target: t.String(), ProxyJump: t.ProxyJump,
			File: where[h].File, Line: where[h].Line, PortOK: portLooksValid(t.Port),
		}
		if ids := res.GetAll("identityfile"); len(ids) > 0 {
			r.Identity = redact(ids[0].Value)
			if len(ids) > 1 {
				r.Identity += fmt.Sprintf(" (+%d more)", len(ids)-1)
			}
		}
		rows = append(rows, r)
	}
	sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host })

	if *c.asJSON {
		if rows == nil {
			rows = []row{}
		}
		writeJSON(map[string]any{
			"config": cfg.Roots,
			"count":  len(rows),
			"hosts":  rows,
		})
		return
	}

	fmt.Printf("SSHDesk host list\n")
	fmt.Printf("config : %s\n", strings.Join(cfg.Roots, ", "))
	fmt.Printf("hosts  : %d concrete host pattern(s)\n\n", len(rows))
	if len(rows) == 0 {
		fmt.Println("(no Host block names a single concrete host)")
		return
	}
	hw, tw := 4, 6
	for _, r := range rows {
		if len(r.Host) > hw {
			hw = len(r.Host)
		}
		if len(r.Target) > tw {
			tw = len(r.Target)
		}
	}
	fmt.Printf("  %-*s  %-*s  %s\n", hw, "HOST", tw, "TARGET", "DECLARED AT")
	for _, r := range rows {
		fmt.Printf("  %-*s  %-*s  %s:%d\n", hw, r.Host, tw, r.Target, r.File, r.Line)
		if r.ProxyJump != "" {
			fmt.Printf("  %-*s    via %s\n", hw, "", r.ProxyJump)
		}
		if r.Identity != "" {
			fmt.Printf("  %-*s    key %s\n", hw, "", r.Identity)
		}
		if !r.PortOK {
			fmt.Printf("  %-*s    !!  port %q is not a number between 1 and 65535\n", hw, "", r.Port)
		}
	}
}

// ---------------------------------------------------------------------------
// check
// ---------------------------------------------------------------------------

func cmdCheck(argv []string) {
	fs := newFlagSet("check")
	c := addCommon(fs)
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if fs.NArg() > 0 {
		usageErr("check takes no positional arguments (got %q)", fs.Arg(0))
	}
	cfg := c.load()
	findings := Check(cfg, c.query(""))
	errs, warns, infos := countBySeverity(findings)

	if *c.asJSON {
		if findings == nil {
			findings = []Finding{}
		}
		writeJSON(map[string]any{
			"config":     cfg.Roots,
			"files_read": cfg.Files,
			"counts": map[string]int{
				"error": errs, "warning": warns, "info": infos, "total": len(findings),
			},
			"findings": findings,
		})
		return
	}

	fmt.Printf("SSHDesk configuration check\n")
	fmt.Printf("config : %s\n", strings.Join(cfg.Roots, ", "))
	fmt.Printf("files  : %d read\n", len(cfg.Files))
	for _, f := range cfg.Files[len(cfg.Roots):] {
		fmt.Printf("         included: %s\n", f)
	}
	fmt.Printf("result : %d error(s), %d warning(s), %d note(s)\n\n", errs, warns, infos)
	if len(findings) == 0 {
		fmt.Println("Nothing to report. That is rarer than you think.")
		return
	}
	for _, f := range findings {
		loc := ""
		if f.File != "" {
			loc = fmt.Sprintf("%s:%d: ", f.File, f.Line)
		}
		fmt.Printf("%-7s %s%s\n", strings.ToUpper(f.Severity), loc, f.Message)
		fmt.Printf("        [%s]\n", f.Rule)
		for _, d := range f.Detail {
			fmt.Printf("        - %s\n", d)
		}
		fmt.Println()
	}
}

// ---------------------------------------------------------------------------
// graph
// ---------------------------------------------------------------------------

func cmdGraph(argv []string) {
	fs := newFlagSet("graph")
	c := addCommon(fs)
	out := fs.String("out", "", "write an SVG here")
	fs.StringVar(out, "o", "", "shorthand for --out")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if fs.NArg() > 0 {
		usageErr("graph takes no positional arguments (got %q)", fs.Arg(0))
	}
	cfg := c.load()
	g := BuildGraph(cfg, c.query(""))

	if *out != "" {
		svg := RenderSVG(g, "ProxyJump topology")
		if err := os.WriteFile(*out, []byte(svg), 0o644); err != nil {
			fail("cannot write %s: %v", *out, err)
		}
		if !*c.asJSON {
			fmt.Printf("wrote %s (%d hosts, %d jumps)\n", *out, len(g.Nodes), len(g.Edges))
		}
	}
	if *c.asJSON {
		writeJSON(map[string]any{
			"config": cfg.Roots,
			"nodes":  g.Nodes,
			"edges":  g.Edges,
			"out":    *out,
		})
		return
	}
	if *out != "" {
		return
	}

	fmt.Printf("SSHDesk ProxyJump topology\n")
	fmt.Printf("config : %s\n", strings.Join(cfg.Roots, ", "))
	fmt.Printf("hosts  : %d, jumps: %d\n\n", len(g.Nodes), len(g.Edges))
	if len(g.Edges) == 0 {
		fmt.Println("No ProxyJump is declared anywhere; every host is reached directly.")
		return
	}
	for _, n := range g.Nodes {
		var chain []string
		loopsBackTo := ""
		cur := n.Name
		seen := map[string]bool{strings.ToLower(cur): true}
		for {
			var via string
			for _, e := range g.Edges {
				if strings.EqualFold(e.From, cur) {
					via = e.Via
					break
				}
			}
			if via == "" {
				break
			}
			if seen[strings.ToLower(via)] {
				loopsBackTo = via
				break
			}
			chain = append(chain, via)
			seen[strings.ToLower(via)] = true
			cur = via
		}
		if len(chain) == 0 {
			continue
		}
		path := append(reverse(chain), n.Name)
		fmt.Printf("  %s\n", n.Name)
		fmt.Printf("    path : you -> %s\n", strings.Join(path, " -> "))
		if loopsBackTo != "" {
			fmt.Printf("    !!     %s jumps back through %s: this chain never terminates\n",
				chain[len(chain)-1], loopsBackTo)
		}
	}
	fmt.Println()
	fmt.Printf("Pass --out ssh.svg to draw it.\n")
}

func reverse(ss []string) []string {
	out := make([]string, len(ss))
	for i, s := range ss {
		out[len(ss)-1-i] = s
	}
	return out
}
