package main

import (
	"encoding/xml"
	"io"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------

func writeFile(t *testing.T, dir, name, content string) string {
	t.Helper()
	p := filepath.Join(dir, name)
	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
		t.Fatalf("write %s: %v", p, err)
	}
	return p
}

// loadCfg parses a single config file with the temp dir standing in for $HOME.
func loadCfg(t *testing.T, home, path string) *Config {
	t.Helper()
	cfg, err := LoadConfig([]string{path}, LoadOptions{Home: home})
	if err != nil {
		t.Fatalf("LoadConfig(%s): %v", path, err)
	}
	return cfg
}

func resolveHost(t *testing.T, cfg *Config, host string) *Result {
	t.Helper()
	return Resolve(cfg, Query{Host: host, LocalUser: "localuser"})
}

// declAt finds the declaration of a keyword recorded at a given line.
func declAt(res *Result, key string, line int) (Decl, bool) {
	for _, d := range res.Decls {
		if d.Key == key && d.Line == line {
			return d, true
		}
	}
	return Decl{}, false
}

func findingsWithRule(fs []Finding, rule string) []Finding {
	var out []Finding
	for _, f := range fs {
		if f.Rule == rule {
			out = append(out, f)
		}
	}
	return out
}

func joinFinding(f Finding) string {
	return f.Message + " | " + strings.Join(f.Detail, " | ")
}

// ---------------------------------------------------------------------------
// THE test: first-obtained-value-wins
// ---------------------------------------------------------------------------

// TestFirstObtainedValueWins asserts the single most misunderstood rule in
// ssh_config. The EARLIEST matching declaration is the effective one and every
// later declaration - however much more specific its block - is ignored.
func TestFirstObtainedValueWins(t *testing.T) {
	dir := t.TempDir()
	// Line numbers matter here, so they are written out deliberately:
	//  1 Host *
	//  2     Port 2222
	//  3     User deploy
	//  4
	//  5 Host web1
	//  6     Port 22
	//  7     User www-data
	//  8     HostName web1.example.com
	cfgPath := writeFile(t, dir, "config", strings.Join([]string{
		"Host *",
		"    Port 2222",
		"    User deploy",
		"",
		"Host web1",
		"    Port 22",
		"    User www-data",
		"    HostName web1.example.com",
	}, "\n")+"\n")
	cfg := loadCfg(t, dir, cfgPath)
	res := resolveHost(t, cfg, "web1")

	cases := []struct {
		name       string
		key        string
		wantValue  string
		wantLine   int // where the winning value came from
		losingLine int // a declaration that MUST be ignored (0 = none)
	}{
		{"port comes from the wildcard, not the specific block", "port", "2222", 2, 6},
		{"user comes from the wildcard, not the specific block", "user", "deploy", 3, 7},
		{"a keyword only the specific block sets still applies", "hostname", "web1.example.com", 8, 0},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := res.GetAll(tc.key)
			if len(got) != 1 {
				t.Fatalf("%s: got %d effective values, want 1", tc.key, len(got))
			}
			if got[0].Value != tc.wantValue {
				t.Errorf("%s = %q, want %q (last-obtained-wins is the classic bug)",
					tc.key, got[0].Value, tc.wantValue)
			}
			if got[0].Line != tc.wantLine {
				t.Errorf("%s came from line %d, want line %d", tc.key, got[0].Line, tc.wantLine)
			}
			if tc.losingLine == 0 {
				return
			}
			loser, ok := declAt(res, tc.key, tc.losingLine)
			if !ok {
				t.Fatalf("no %s declaration recorded at line %d", tc.key, tc.losingLine)
			}
			if loser.Status != StatusIgnored {
				t.Errorf("%s at line %d has status %q, want %q: the later declaration "+
					"MUST be recorded as ignored", tc.key, tc.losingLine, loser.Status, StatusIgnored)
			}
			if !strings.Contains(loser.Reason, "already set") {
				t.Errorf("ignored %s at line %d has reason %q, want it to name the earlier winner",
					tc.key, tc.losingLine, loser.Reason)
			}
		})
	}

	t.Run("the ignored value never leaks into the target", func(t *testing.T) {
		target := TargetOf(res, Query{Host: "web1", LocalUser: "localuser"})
		if target.Port != "2222" {
			t.Errorf("target port = %q, want 2222", target.Port)
		}
		if target.User != "deploy" {
			t.Errorf("target user = %q, want deploy", target.User)
		}
	})
}

// TestAccumulatingKeywords covers the one exception to first-wins.
func TestAccumulatingKeywords(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config", strings.Join([]string{
		"Host *",
		"    IdentityFile ~/.ssh/id_common",
		"    Port 2222",
		"Host web1",
		"    IdentityFile ~/.ssh/id_web",
		"    Port 22",
	}, "\n")+"\n")
	res := resolveHost(t, loadCfg(t, dir, cfgPath), "web1")

	ids := res.GetAll("identityfile")
	if len(ids) != 2 {
		t.Fatalf("IdentityFile: got %d values, want 2 (it accumulates)", len(ids))
	}
	if ids[0].Value != "~/.ssh/id_common" || ids[1].Value != "~/.ssh/id_web" {
		t.Errorf("IdentityFile order = %q, %q; want the wildcard one first",
			ids[0].Value, ids[1].Value)
	}
	if got := res.Get("port"); got != "2222" {
		t.Errorf("Port = %q, want 2222: Port does NOT accumulate", got)
	}
}

// ---------------------------------------------------------------------------
// pattern matching
// ---------------------------------------------------------------------------

func TestMatchPattern(t *testing.T) {
	cases := []struct {
		s, pattern string
		want       bool
	}{
		{"web1", "web1", true},
		{"web1", "web2", false},
		{"web1", "*", true},
		{"", "*", true},
		{"", "?", false},
		{"web1", "web?", true},
		{"web12", "web?", false},
		{"web1.example.com", "*.example.com", true},
		{"example.com", "*.example.com", false},
		{"a.b.example.com", "*.example.com", true}, // '*' crosses dots
		{"web1.example.com", "web1.*", true},
		{"web1.example.com", "*1.*.com", true},
		{"web1.example.com", "*.example.org", false},
		{"web1", "WEB1", false}, // matching is case sensitive
		{"host", "h*t", true},
		{"ht", "h*t", true},
		{"ht", "h?t", false},
		{"hat", "h?t", true},
		{"abc", "a**c", true},
		{"abc", "a*?c", true}, // '*' matches nothing, '?' takes the b
		{"ac", "a*?c", false}, // '?' still needs one character
		{"abbc", "a*?c", true},
		{"a/b", "a*b", true}, // no separator awareness, unlike filepath.Match
		{"a[b]c", "a[b]c", true},
		{"abc", "a[b]c", false}, // no character classes
		{"", "", true},
		{"x", "", false},
		{"10.0.0.1", "10.0.0.*", true},
		{"10.0.0.1", "10.0.*.1", true},
		{"10.1.0.1", "10.0.*", false},
	}
	for _, tc := range cases {
		if got := matchPattern(tc.s, tc.pattern); got != tc.want {
			t.Errorf("matchPattern(%q, %q) = %v, want %v", tc.s, tc.pattern, got, tc.want)
		}
	}
}

func TestMatchPatternList(t *testing.T) {
	cases := []struct {
		s, list string
		want    int
	}{
		{"web1", "web1,web2", patternPositive},
		{"web3", "web1,web2", patternNoMatch},
		{"web1", "!web1,web*", patternNegated},
		{"web2", "!web1,web*", patternPositive},
		{"web1", "*,!web1", patternNegated},
		{"anything", "*", patternPositive},
	}
	for _, tc := range cases {
		if got := matchPatternList(tc.s, tc.list, false); got != tc.want {
			t.Errorf("matchPatternList(%q, %q) = %d, want %d", tc.s, tc.list, got, tc.want)
		}
	}
}

// TestNegatedHostPatterns checks the Host-line rules, including the fact that
// a negated pattern kills the whole line even when an earlier pattern on the
// same line matched.
func TestNegatedHostPatterns(t *testing.T) {
	cases := []struct {
		host     string
		patterns []string
		want     bool
	}{
		{"web1.example.com", []string{"*.example.com"}, true},
		{"bastion.example.com", []string{"*.example.com", "!bastion.example.com"}, false},
		{"web1.example.com", []string{"*.example.com", "!bastion.example.com"}, true},
		// negation short-circuits even though the positive pattern came first
		{"bastion.example.com", []string{"bastion.example.com", "!bastion.*"}, false},
		// a negation that matches nothing leaves the positives alone
		{"web1", []string{"web1", "!db*"}, true},
		// only a negation: nothing can ever activate the block
		{"web1", []string{"!db1"}, false},
		{"db1", []string{"!db1"}, false},
	}
	for _, tc := range cases {
		got, _ := matchHostList(tc.host, tc.patterns)
		if got != tc.want {
			t.Errorf("matchHostList(%q, %v) = %v, want %v", tc.host, tc.patterns, got, tc.want)
		}
	}

	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config",
		"Host *.example.com !bastion.example.com\n    User svc\n")
	cfg := loadCfg(t, dir, cfgPath)
	if got := resolveHost(t, cfg, "web1.example.com").Get("user"); got != "svc" {
		t.Errorf("web1.example.com User = %q, want svc", got)
	}
	if got := resolveHost(t, cfg, "bastion.example.com").Get("user"); got != "" {
		t.Errorf("bastion.example.com User = %q, want no value (the block is negated away)", got)
	}
}

// TestUppercaseHostPatternNeverMatches documents ssh's case handling: the host
// you type is lowercased, the pattern is not.
func TestUppercaseHostPatternNeverMatches(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config", "Host CI-Runner\n    HostName ci.example.com\n")
	cfg := loadCfg(t, dir, cfgPath)
	if got := resolveHost(t, cfg, "CI-Runner").Get("hostname"); got != "" {
		t.Errorf("HostName = %q, want none: ssh lowercases the target host", got)
	}
	fs := Check(cfg, Query{LocalUser: "localuser"})
	if len(findingsWithRule(fs, "uppercase-host-pattern")) != 1 {
		t.Errorf("check did not report the uppercase host pattern")
	}
}

// ---------------------------------------------------------------------------
// tokenising
// ---------------------------------------------------------------------------

func TestSplitArgs(t *testing.T) {
	cases := []struct {
		line string
		want []string
	}{
		{"Host web1", []string{"Host", "web1"}},
		{"   Host    web1   web2 ", []string{"Host", "web1", "web2"}},
		{"# a comment", nil},
		{"Host web1 # trailing comment", []string{"Host", "web1"}},
		{"Host web1#nospace", []string{"Host", "web1#nospace"}},
		{`ProxyCommand "/usr/bin/nc -X 5 %h %p"`, []string{"ProxyCommand", "/usr/bin/nc -X 5 %h %p"}},
		{`LocalCommand echo "hello world"`, []string{"LocalCommand", "echo", "hello world"}},
		{`IdentityFile ~/my\ keys/id`, []string{"IdentityFile", "~/my keys/id"}},
		{`SetEnv FOO=bar`, []string{"SetEnv", "FOO=bar"}},
		{`Host 'quoted host'`, []string{"Host", "quoted host"}},
		{`ProxyCommand /bin/false \\`, []string{"ProxyCommand", "/bin/false", `\`}},
		{"", nil},
		{"\t", nil},
	}
	for _, tc := range cases {
		got, err := splitArgs(tc.line)
		if err != nil {
			t.Errorf("splitArgs(%q) error: %v", tc.line, err)
			continue
		}
		if strings.Join(got, "\x00") != strings.Join(tc.want, "\x00") {
			t.Errorf("splitArgs(%q) = %q, want %q", tc.line, got, tc.want)
		}
	}
	if _, err := splitArgs(`Host "unterminated`); err == nil {
		t.Errorf("splitArgs did not reject an unterminated quote")
	}
}

func TestSplitKeywordEquals(t *testing.T) {
	cases := []struct {
		line     string
		wantKw   string
		wantArgs []string
	}{
		{"Port 2222", "Port", []string{"2222"}},
		{"Port=2222", "Port", []string{"2222"}},
		{"Port = 2222", "Port", []string{"2222"}},
		{"Port =2222", "Port", []string{"2222"}},
		{"SetEnv FOO=bar", "SetEnv", []string{"FOO=bar"}},
		{"SetEnv=FOO=bar", "SetEnv", []string{"FOO=bar"}},
		{"Host=web1", "Host", []string{"web1"}},
	}
	for _, tc := range cases {
		fields, err := splitArgs(tc.line)
		if err != nil {
			t.Fatalf("splitArgs(%q): %v", tc.line, err)
		}
		kw, args := splitKeyword(fields)
		if kw != tc.wantKw || strings.Join(args, ",") != strings.Join(tc.wantArgs, ",") {
			t.Errorf("splitKeyword(%q) = %q %q, want %q %q",
				tc.line, kw, args, tc.wantKw, tc.wantArgs)
		}
	}
}

func TestKeywordCaseInsensitivity(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config",
		"host web1\n"+ // the keyword is case-insensitive, the pattern is not
			"    hOsTnAmE  web1.example.com\n"+
			"    PORT 2200\n")
	res := resolveHost(t, loadCfg(t, dir, cfgPath), "web1")
	if got := res.Get("hostname"); got != "web1.example.com" {
		t.Errorf("HostName = %q, want web1.example.com (keywords are case-insensitive)", got)
	}
	if got := res.Get("port"); got != "2200" {
		t.Errorf("Port = %q, want 2200", got)
	}
}

// ---------------------------------------------------------------------------
// Include
// ---------------------------------------------------------------------------

// TestIncludeResolution covers globbing, relative-path resolution against the
// config directory, a nested include, and the fact that first-obtained-wins
// applies across file boundaries in textual order.
func TestIncludeResolution(t *testing.T) {
	dir := t.TempDir()
	writeFile(t, dir, "conf.d/10-first.conf", "Host web1\n    Port 2201\n")
	writeFile(t, dir, "conf.d/20-second.conf", "Host web1\n    Port 2202\n    User second\n")
	// A nested Include, written relative: it must resolve against the
	// directory of the top-level config, not against conf.d/.
	writeFile(t, dir, "conf.d/30-nested.conf", "Include nested.conf\nHost *\n")
	writeFile(t, dir, "nested.conf", "Host web1\n    HostName nested.example.com\n")
	cfgPath := writeFile(t, dir, "config",
		"Include conf.d/*.conf\nHost web1\n    Port 2299\n    User last\n")

	cfg := loadCfg(t, dir, cfgPath)
	res := resolveHost(t, cfg, "web1")

	if got := res.Get("port"); got != "2201" {
		t.Errorf("Port = %q, want 2201 from the first included file", got)
	}
	if got := res.Get("user"); got != "second" {
		t.Errorf("User = %q, want 'second': the first file does not set User", got)
	}
	if got := res.Get("hostname"); got != "nested.example.com" {
		t.Errorf("HostName = %q, want nested.example.com from the nested include", got)
	}

	t.Run("every file is recorded", func(t *testing.T) {
		want := []string{"config", "10-first.conf", "20-second.conf", "30-nested.conf", "nested.conf"}
		if len(cfg.Files) != len(want) {
			t.Fatalf("read %d files (%v), want %d", len(cfg.Files), cfg.Files, len(want))
		}
		for i, w := range want {
			if filepath.Base(cfg.Files[i]) != w {
				t.Errorf("file %d = %s, want %s", i, filepath.Base(cfg.Files[i]), w)
			}
		}
	})

	t.Run("provenance points into the included file", func(t *testing.T) {
		d := res.GetAll("port")[0]
		if filepath.Base(d.File) != "10-first.conf" || d.Line != 2 {
			t.Errorf("Port came from %s:%d, want 10-first.conf:2", d.File, d.Line)
		}
	})
}

// TestIncludeInsideInactiveBlockIsNotRead: ssh does not read an included file
// when the block containing the Include does not match.
func TestIncludeInsideInactiveBlockIsNotRead(t *testing.T) {
	dir := t.TempDir()
	writeFile(t, dir, "secret.conf", "Port 2222\n")
	cfgPath := writeFile(t, dir, "config",
		"Host bastion\n    Include secret.conf\nHost web1\n    HostName w.example\n")
	cfg := loadCfg(t, dir, cfgPath)

	if got := resolveHost(t, cfg, "web1").Get("port"); got != "" {
		t.Errorf("web1 Port = %q, want none: the Include lives in a block that does not match", got)
	}
	if got := resolveHost(t, cfg, "bastion").Get("port"); got != "2222" {
		t.Errorf("bastion Port = %q, want 2222", got)
	}
	res := resolveHost(t, cfg, "web1")
	d, ok := declAt(res, "port", 1)
	if !ok {
		t.Fatal("the never-read Port declaration was not recorded at all")
	}
	if d.Status != StatusNotReached {
		t.Errorf("status = %q, want %q", d.Status, StatusNotReached)
	}
}

// TestIncludeLeaksTrailingBlock covers the quirk that makes whole files dead:
// an included file that ends inside a Host block carries that block back into
// the including file.
func TestIncludeLeaksTrailingBlock(t *testing.T) {
	dir := t.TempDir()
	writeFile(t, dir, "part.conf", "Host bastion\n    Port 2200\n")
	cfgPath := writeFile(t, dir, "config",
		"Include part.conf\nUser afterwards\n")
	cfg := loadCfg(t, dir, cfgPath)

	if got := resolveHost(t, cfg, "web1").Get("user"); got != "" {
		t.Errorf("web1 User = %q, want none: the trailing 'Host bastion' leaks over it", got)
	}
	if got := resolveHost(t, cfg, "bastion").Get("user"); got != "afterwards" {
		t.Errorf("bastion User = %q, want afterwards", got)
	}
	fs := Check(cfg, Query{LocalUser: "localuser"})
	if len(findingsWithRule(fs, "include-leaks-block")) != 1 {
		t.Errorf("check did not report include-leaks-block; got %d findings", len(fs))
	}
}

func TestIncludeDepthLimit(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config", "Include config\nHost web1\n    Port 22\n")
	cfg := loadCfg(t, dir, cfgPath)
	found := false
	for _, is := range cfg.Issues {
		if is.Rule == "include-depth" {
			found = true
		}
	}
	if !found {
		t.Errorf("a self-including file did not trip the depth limit; issues=%v", cfg.Issues)
	}
}

// ---------------------------------------------------------------------------
// Match
// ---------------------------------------------------------------------------

func TestMatchBlocks(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config", strings.Join([]string{
		"Match originalhost web1",
		"    Port 2210",
		"Match user deploy",
		"    Port 2211",
		"Match localuser localuser host *.example.com",
		"    Compression yes",
		"Match !host bastion.example.com",
		"    LogLevel DEBUG1",
		"Match all",
		"    ServerAliveInterval 30",
	}, "\n")+"\n")
	cfg := loadCfg(t, dir, cfgPath)

	cases := []struct {
		name  string
		q     Query
		key   string
		want  string
		where int
	}{
		{"originalhost matches the typed name", Query{Host: "web1", LocalUser: "localuser"}, "port", "2210", 2},
		{"user criterion uses -l", Query{Host: "other", User: "deploy", LocalUser: "localuser"}, "port", "2211", 4},
		{"two criteria are ANDed", Query{Host: "a.example.com", LocalUser: "localuser"}, "compression", "yes", 6},
		{"a criterion that fails blocks the whole line", Query{Host: "a.example.org", LocalUser: "localuser"}, "compression", "", 0},
		{"negated criterion", Query{Host: "bastion.example.com", LocalUser: "localuser"}, "loglevel", "", 0},
		{"negated criterion, other host", Query{Host: "web9", LocalUser: "localuser"}, "loglevel", "DEBUG1", 8},
		{"Match all always applies", Query{Host: "anything", LocalUser: "localuser"}, "serveraliveinterval", "30", 10},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			res := Resolve(cfg, tc.q)
			got := res.Get(tc.key)
			if got != tc.want {
				t.Fatalf("%s = %q, want %q", tc.key, got, tc.want)
			}
			if tc.where != 0 && res.GetAll(tc.key)[0].Line != tc.where {
				t.Errorf("%s came from line %d, want %d", tc.key, res.GetAll(tc.key)[0].Line, tc.where)
			}
		})
	}
}

// TestMatchHostUsesResolvedHostName: `Match host` sees the HostName already in
// effect, not the name typed on the command line.
func TestMatchHostUsesResolvedHostName(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config", strings.Join([]string{
		"Host w",
		"    HostName web1.example.com",
		"Match host *.example.com",
		"    User svc",
		"Match originalhost *.example.com",
		"    Compression yes",
	}, "\n")+"\n")
	res := resolveHost(t, loadCfg(t, dir, cfgPath), "w")
	if got := res.Get("user"); got != "svc" {
		t.Errorf("User = %q, want svc: Match host sees the substituted HostName", got)
	}
	if got := res.Get("compression"); got != "" {
		t.Errorf("Compression = %q, want none: Match originalhost sees the typed name", got)
	}
}

// TestMatchExecIsReportedNotEvaluated proves the analyser refuses to run
// commands and says so instead of guessing.
func TestMatchExecIsReportedNotEvaluated(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config",
		"Match exec \"true\"\n    ProxyJump none\n    Port 2299\n")
	cfg := loadCfg(t, dir, cfgPath)
	res := resolveHost(t, cfg, "anything")

	if got := res.Get("port"); got != "" {
		t.Errorf("Port = %q, want none: an unevaluated Match must not contribute values", got)
	}
	if len(res.Unevals) != 1 {
		t.Fatalf("got %d unevaluated notes, want 1", len(res.Unevals))
	}
	if !strings.Contains(res.Unevals[0], "exec") {
		t.Errorf("unevaluated note %q does not mention exec", res.Unevals[0])
	}
	d, ok := declAt(res, "port", 3)
	if !ok || d.Status != StatusUnevaluated {
		t.Errorf("Port declaration status = %q, want %q", d.Status, StatusUnevaluated)
	}
	if len(findingsWithRule(Check(cfg, Query{LocalUser: "localuser"}), "match-exec")) != 1 {
		t.Errorf("check did not report the unevaluated Match exec")
	}
}

func TestParseMatchErrors(t *testing.T) {
	bad := []string{"host", "nonsense foo", "all extra", ""}
	for _, line := range bad {
		fields, err := splitArgs("Match " + line)
		if err != nil {
			t.Fatalf("splitArgs: %v", err)
		}
		if _, err := parseMatch(fields[1:]); err == nil {
			t.Errorf("parseMatch(%q) accepted an invalid Match line", line)
		}
	}
	good := map[string]int{
		"all":                          1,
		"host foo,bar user root":       2,
		"host=foo":                     1,
		"!host foo":                    1,
		"final canonical":              2,
		`exec "test -e /tmp/x" host y`: 2,
	}
	for line, want := range good {
		fields, err := splitArgs("Match " + line)
		if err != nil {
			t.Fatalf("splitArgs: %v", err)
		}
		crit, err := parseMatch(fields[1:])
		if err != nil {
			t.Errorf("parseMatch(%q): %v", line, err)
			continue
		}
		if len(crit) != want {
			t.Errorf("parseMatch(%q) = %d criteria, want %d", line, len(crit), want)
		}
	}
	if c, _ := parseMatch([]string{"!host", "foo"}); len(c) != 1 || !c[0].Negated || c[0].Arg != "foo" {
		t.Errorf("negated criterion parsed wrong: %+v", c)
	}
}

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

func TestShadowingDetection(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config", strings.Join([]string{
		"Host *",
		"    User deploy",
		"    Port 2222",
		"    ServerAliveInterval 60",
		"Host web1",
		"    User www-data",
		"    Port 22",
		"    HostName web1.example.com",
	}, "\n")+"\n")
	fs := Check(loadCfg(t, dir, cfgPath), Query{LocalUser: "localuser"})

	got := findingsWithRule(fs, "shadowed-block")
	if len(got) != 1 {
		t.Fatalf("got %d shadowed-block findings, want 1: %v", len(got), fs)
	}
	f := got[0]
	if f.Severity != sevError {
		t.Errorf("severity = %q, want error", f.Severity)
	}
	if f.Line != 5 {
		t.Errorf("finding is at line %d, want 5 (the shadowed block)", f.Line)
	}
	text := joinFinding(f)
	for _, want := range []string{"Port", "User", "Host *"} {
		if !strings.Contains(text, want) {
			t.Errorf("finding does not mention %q: %s", want, text)
		}
	}
	if strings.Contains(f.Message, "HostName") {
		t.Errorf("HostName is not shadowed but was reported: %s", f.Message)
	}
	// The same file with the wildcard block LAST must be clean.
	fixed := writeFile(t, dir, "fixed", strings.Join([]string{
		"Host web1",
		"    User www-data",
		"    Port 22",
		"Host *",
		"    User deploy",
		"    Port 2222",
	}, "\n")+"\n")
	if n := len(findingsWithRule(Check(loadCfg(t, dir, fixed), Query{LocalUser: "localuser"}), "shadowed-block")); n != 0 {
		t.Errorf("got %d shadowed-block findings after moving Host * to the end, want 0", n)
	}
}

func TestDuplicateHostPatterns(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config",
		"Host web1\n    Port 22\nHost db1\n    Port 22\nHost web1\n    User x\n")
	fs := findingsWithRule(Check(loadCfg(t, dir, cfgPath), Query{LocalUser: "localuser"}), "duplicate-host-pattern")
	if len(fs) != 1 {
		t.Fatalf("got %d duplicate findings, want 1", len(fs))
	}
	if !strings.Contains(fs[0].Message, `"web1"`) {
		t.Errorf("finding does not name web1: %s", fs[0].Message)
	}
}

func TestProxyJumpCycleDetection(t *testing.T) {
	dir := t.TempDir()

	t.Run("two-host cycle", func(t *testing.T) {
		p := writeFile(t, dir, "cycle2",
			"Host a\n    ProxyJump b\nHost b\n    ProxyJump a\n")
		fs := findingsWithRule(Check(loadCfg(t, dir, p), Query{LocalUser: "localuser"}), "proxyjump-cycle")
		if len(fs) != 1 {
			t.Fatalf("got %d cycle findings, want exactly 1 (deduplicated): %v", len(fs), fs)
		}
		if !strings.Contains(fs[0].Message, "a -> b -> a") && !strings.Contains(fs[0].Message, "b -> a -> b") {
			t.Errorf("cycle message does not show the loop: %s", fs[0].Message)
		}
	})

	t.Run("three-host cycle with a run-up", func(t *testing.T) {
		p := writeFile(t, dir, "cycle3", strings.Join([]string{
			"Host entry", "    ProxyJump a",
			"Host a", "    ProxyJump b",
			"Host b", "    ProxyJump c",
			"Host c", "    ProxyJump a",
		}, "\n")+"\n")
		fs := findingsWithRule(Check(loadCfg(t, dir, p), Query{LocalUser: "localuser"}), "proxyjump-cycle")
		if len(fs) != 1 {
			t.Fatalf("got %d cycle findings, want 1: %v", len(fs), fs)
		}
		if strings.Contains(fs[0].Message, "entry") {
			t.Errorf("the run-up host must not be reported as part of the loop: %s", fs[0].Message)
		}
	})

	t.Run("no cycle", func(t *testing.T) {
		p := writeFile(t, dir, "nocycle",
			"Host a\n    ProxyJump b\nHost b\n    HostName b.example.com\n")
		fs := findingsWithRule(Check(loadCfg(t, dir, p), Query{LocalUser: "localuser"}), "proxyjump-cycle")
		if len(fs) != 0 {
			t.Errorf("got %d cycle findings on an acyclic config, want 0", len(fs))
		}
	})

	t.Run("self jump", func(t *testing.T) {
		p := writeFile(t, dir, "selfjump", "Host a\n    ProxyJump a\n")
		fs := findingsWithRule(Check(loadCfg(t, dir, p), Query{LocalUser: "localuser"}), "proxyjump-cycle")
		if len(fs) != 1 {
			t.Errorf("a host jumping through itself was not reported: %v", fs)
		}
	})
}

func TestProxyJumpUndefinedHost(t *testing.T) {
	dir := t.TempDir()
	p := writeFile(t, dir, "config",
		"Host a\n    ProxyJump typo-bastion\nHost bastion\n    HostName b.example.com\n")
	fs := findingsWithRule(Check(loadCfg(t, dir, p), Query{LocalUser: "localuser"}), "proxyjump-undefined")
	if len(fs) != 1 {
		t.Fatalf("got %d undefined-jump findings, want 1: %v", len(fs), fs)
	}
	if !strings.Contains(fs[0].Message, "typo-bastion") {
		t.Errorf("finding does not name the target: %s", fs[0].Message)
	}
}

func TestParseJumpChain(t *testing.T) {
	cases := []struct {
		in    string
		hosts []string
	}{
		{"bastion", []string{"bastion"}},
		{"a,b,c", []string{"a", "b", "c"}},
		{"user@host:2222", []string{"host"}},
		{"none", nil},
		{"NONE", nil},
		{"", nil},
		{"[2001:db8::1]:2222", []string{"2001:db8::1"}},
	}
	for _, tc := range cases {
		hops := parseJumpChain(tc.in)
		var got []string
		for _, h := range hops {
			got = append(got, h.Host)
		}
		if strings.Join(got, ",") != strings.Join(tc.hosts, ",") {
			t.Errorf("parseJumpChain(%q) = %v, want %v", tc.in, got, tc.hosts)
		}
	}
	if h := parseJumpChain("bob@jump:2200"); len(h) != 1 || h[0].User != "bob" || h[0].Port != "2200" {
		t.Errorf("parseJumpChain lost the user or port: %+v", h)
	}
}

func TestIdentityFileChecks(t *testing.T) {
	dir := t.TempDir()
	good := writeFile(t, dir, "keys/good", "not a key\n")
	loose := writeFile(t, dir, "keys/loose", "not a key\n")
	if err := os.Chmod(good, 0o600); err != nil {
		t.Fatal(err)
	}
	if err := os.Chmod(loose, 0o644); err != nil {
		t.Fatal(err)
	}
	missing := filepath.Join(dir, "keys", "gone")
	cfgPath := writeFile(t, dir, "config", strings.Join([]string{
		"Host a",
		"    IdentityFile " + good,
		"Host b",
		"    IdentityFile " + loose,
		"Host c",
		"    IdentityFile " + missing,
	}, "\n")+"\n")
	fs := Check(loadCfg(t, dir, cfgPath), Query{LocalUser: "localuser"})

	perm := findingsWithRule(fs, "identity-permissions")
	if len(perm) != 1 {
		t.Fatalf("got %d permission findings, want 1 (only the 0644 key): %v", len(perm), fs)
	}
	if !strings.Contains(perm[0].Message, "0644") || !strings.Contains(perm[0].Message, "loose") {
		t.Errorf("permission finding is wrong: %s", perm[0].Message)
	}
	miss := findingsWithRule(fs, "identity-missing")
	if len(miss) != 1 {
		t.Fatalf("got %d missing-key findings, want 1: %v", len(miss), fs)
	}
	if !strings.Contains(miss[0].Message, "gone") {
		t.Errorf("missing-key finding is wrong: %s", miss[0].Message)
	}
}

func TestWeakSettings(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config", strings.Join([]string{
		"Host risky",
		"    StrictHostKeyChecking no",
		"    UserKnownHostsFile /dev/null",
		"    ForwardAgent yes",
		"    Ciphers aes256-ctr,3des-cbc",
	}, "\n")+"\n")
	fs := Check(loadCfg(t, dir, cfgPath), Query{LocalUser: "localuser"})
	if n := len(findingsWithRule(fs, "weak-setting")); n != 3 {
		t.Errorf("got %d weak-setting findings, want 3: %v", n, fs)
	}
	alg := findingsWithRule(fs, "weak-algorithm")
	if len(alg) != 1 {
		t.Fatalf("got %d weak-algorithm findings, want 1", len(alg))
	}
	if !strings.Contains(joinFinding(alg[0]), "3des-cbc") {
		t.Errorf("weak-algorithm finding does not name 3des-cbc: %s", joinFinding(alg[0]))
	}
}

func TestWildcardUserAndPort(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config",
		"Host *\n    User root\n    Port 2222\nHost db-*.internal\n    User postgres\n")
	fs := Check(loadCfg(t, dir, cfgPath), Query{LocalUser: "localuser"})
	if n := len(findingsWithRule(fs, "wildcard-user")); n != 1 {
		t.Errorf("got %d wildcard-user findings, want 1 (a partial wildcard is legitimate)", n)
	}
	if n := len(findingsWithRule(fs, "wildcard-port")); n != 1 {
		t.Errorf("got %d wildcard-port findings, want 1", n)
	}
}

func TestUnknownKeywordSuggestion(t *testing.T) {
	cases := []struct {
		unknown string
		want    string
	}{
		{"Compresion", "Compression"},
		{"Hostnam", "HostName"},
		{"IdentitiyFile", "IdentityFile"},
		{"ProxyJmup", "ProxyJump"},
		{"Prot", "Port"},
		{"StrictHostKeyCheck", "StrictHostKeyChecking"},
		{"CompletelyUnrelatedNonsenseKeyword", ""},
	}
	for _, tc := range cases {
		if got := suggestKeyword(tc.unknown); got != tc.want {
			t.Errorf("suggestKeyword(%q) = %q, want %q", tc.unknown, got, tc.want)
		}
	}

	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config", "Host a\n    Compresion yes\n")
	fs := findingsWithRule(Check(loadCfg(t, dir, cfgPath), Query{LocalUser: "localuser"}), "unknown-keyword")
	if len(fs) != 1 {
		t.Fatalf("got %d unknown-keyword findings, want 1", len(fs))
	}
	if !strings.Contains(joinFinding(fs[0]), "did you mean Compression?") {
		t.Errorf("finding does not suggest a spelling: %s", joinFinding(fs[0]))
	}
}

func TestIgnoreUnknownSilencesTheWarning(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config",
		"IgnoreUnknown Nonsense*\nHost a\n    NonsenseKeyword yes\n")
	fs := Check(loadCfg(t, dir, cfgPath), Query{LocalUser: "localuser"})
	if n := len(findingsWithRule(fs, "unknown-keyword")); n != 0 {
		t.Errorf("IgnoreUnknown did not silence the unknown keyword")
	}
	if n := len(findingsWithRule(fs, "unknown-keyword-ignored")); n != 1 {
		t.Errorf("the ignored keyword was not noted")
	}
}

func TestEditDistance(t *testing.T) {
	cases := []struct {
		a, b string
		want int
	}{
		{"", "", 0},
		{"abc", "abc", 0},
		{"abc", "abd", 1},
		{"abc", "", 3},
		{"kitten", "sitting", 3},
		{"port", "prot", 2},
	}
	for _, tc := range cases {
		if got := editDistance(tc.a, tc.b); got != tc.want {
			t.Errorf("editDistance(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
		}
	}
}

func TestLineContinuationIsReported(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config",
		"Host a\n    ProxyCommand /usr/bin/nc \\\n        %h %p\n")
	cfg := loadCfg(t, dir, cfgPath)
	fs := findingsWithRule(Check(cfg, Query{LocalUser: "localuser"}), "line-continuation")
	if len(fs) != 1 {
		t.Fatalf("a trailing backslash was not reported: %v", cfg.Issues)
	}
}

// ---------------------------------------------------------------------------
// redaction
// ---------------------------------------------------------------------------

func TestRedaction(t *testing.T) {
	cases := []struct {
		in     string
		redact bool
	}{
		{"-----BEGIN OPENSSH PRIVATE KEY-----", true},
		{"-----BEGIN RSA PRIVATE KEY-----", true},
		{"---- BEGIN PRIVATE KEY ----", true},
		{"b3BlbnNzaC1rZXktdjEAAAAA PRIVATE KEY-----", true},
		{"~/.ssh/id_ed25519", false},
		{"bastion.example.com", false},
	}
	for _, tc := range cases {
		got := redact(tc.in)
		if tc.redact && got != "[redacted: private key material]" {
			t.Errorf("redact(%q) = %q, want it redacted", tc.in, got)
		}
		if !tc.redact && got != tc.in {
			t.Errorf("redact(%q) = %q, want it untouched", tc.in, got)
		}
	}
}

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

func TestBuildGraphAndSVG(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config", strings.Join([]string{
		"Host web1",
		"    HostName web1.example.com",
		"    ProxyJump bastion",
		"Host bastion",
		"    HostName bastion.example.com",
		"Host loopa",
		"    ProxyJump loopb",
		"Host loopb",
		"    ProxyJump loopa",
	}, "\n")+"\n")
	g := BuildGraph(loadCfg(t, dir, cfgPath), Query{LocalUser: "localuser"})

	if len(g.Edges) != 3 {
		t.Fatalf("got %d edges, want 3: %+v", len(g.Edges), g.Edges)
	}
	byName := map[string]GraphNode{}
	for _, n := range g.Nodes {
		byName[n.Name] = n
	}
	if !byName["loopa"].InCycle || !byName["loopb"].InCycle {
		t.Errorf("the looping hosts were not marked: %+v", g.Nodes)
	}
	if byName["web1"].InCycle || byName["bastion"].InCycle {
		t.Errorf("hosts outside the loop were marked as cyclic: %+v", g.Nodes)
	}
	if byName["bastion"].Level >= byName["web1"].Level {
		t.Errorf("the jump host must sit at a lower level than the host it serves")
	}

	svg := RenderSVG(g, "ProxyJump topology")

	t.Run("well formed XML", func(t *testing.T) {
		dec := xml.NewDecoder(strings.NewReader(svg))
		depth := 0
		for {
			tok, err := dec.Token()
			if err == io.EOF {
				break
			}
			if err != nil {
				t.Fatalf("SVG is not well-formed XML: %v", err)
			}
			switch tok.(type) {
			case xml.StartElement:
				depth++
			case xml.EndElement:
				depth--
			}
		}
		if depth != 0 {
			t.Errorf("unbalanced elements, depth ended at %d", depth)
		}
	})

	t.Run("has the expected content", func(t *testing.T) {
		for _, want := range []string{
			`<svg xmlns="http://www.w3.org/2000/svg"`,
			"viewBox=",
			"web1.example.com",
			"bastion",
			"marker-end=",
			"</svg>",
		} {
			if !strings.Contains(svg, want) {
				t.Errorf("SVG does not contain %q", want)
			}
		}
	})

	t.Run("text is escaped", func(t *testing.T) {
		escaped := RenderSVG(&Graph{Nodes: []GraphNode{{Name: `a<b>&"c`, Declared: true}}}, "t & t")
		if strings.Contains(escaped, "a<b>") {
			t.Errorf("node text was not XML-escaped")
		}
		if _, err := xml.NewDecoder(strings.NewReader(escaped)).Token(); err != nil {
			t.Fatalf("escaped SVG is not parseable: %v", err)
		}
	})
}

// ---------------------------------------------------------------------------
// hosts listing
// ---------------------------------------------------------------------------

func TestConcreteHostsAndTarget(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeFile(t, dir, "config", strings.Join([]string{
		"Host *.internal",
		"    User svc",
		"Host web1 web2",
		"    HostName %h.example.com",
		"    Port 2200",
		"Host !nope",
		"    User x",
	}, "\n")+"\n")
	cfg := loadCfg(t, dir, cfgPath)

	got := concreteHosts(cfg)
	want := []string{"web1", "web2"}
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Errorf("concreteHosts = %v, want %v", got, want)
	}

	q := Query{Host: "web1", LocalUser: "localuser"}
	target := TargetOf(Resolve(cfg, q), q)
	if target.HostName != "web1.example.com" {
		t.Errorf("HostName = %q, want web1.example.com (%%h expanded)", target.HostName)
	}
	if target.Port != "2200" || target.User != "localuser" {
		t.Errorf("target = %s, want localuser@web1.example.com:2200", target)
	}
	if target.UserSource == "" || !strings.Contains(target.UserSource, "local account") {
		t.Errorf("user source = %q, want it to say the local account was used", target.UserSource)
	}
}

func TestPortLooksValid(t *testing.T) {
	cases := map[string]bool{"22": true, "65535": true, "0": false, "65536": false, "abc": false, "": false}
	for in, want := range cases {
		if got := portLooksValid(in); got != want {
			t.Errorf("portLooksValid(%q) = %v, want %v", in, got, want)
		}
	}
}

// ---------------------------------------------------------------------------
// reorderFlags
// ---------------------------------------------------------------------------

func TestReorderFlags(t *testing.T) {
	cases := []struct {
		in, want []string
	}{
		{[]string{"web1", "--json"}, []string{"--json", "web1"}},
		{[]string{"web1", "--config", "f"}, []string{"--config", "f", "web1"}},
		{[]string{"--config", "f", "web1"}, []string{"--config", "f", "web1"}},
		{[]string{"web1", "Port", "--json"}, []string{"--json", "web1", "Port"}},
	}
	for _, tc := range cases {
		got := reorderFlags(tc.in, valueFlags)
		if strings.Join(got, " ") != strings.Join(tc.want, " ") {
			t.Errorf("reorderFlags(%v) = %v, want %v", tc.in, got, tc.want)
		}
	}
}
