package main

import (
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
)

// ---------------------------------------------------------------------------
// Static analysis
// ---------------------------------------------------------------------------

// Finding is one problem reported by `check`.
type Finding struct {
	Severity string   `json:"severity"` // error, warning, info
	Rule     string   `json:"rule"`
	File     string   `json:"file,omitempty"`
	Line     int      `json:"line,omitempty"`
	Message  string   `json:"message"`
	Detail   []string `json:"detail,omitempty"`
}

const (
	sevError = "error"
	sevWarn  = "warning"
	sevInfo  = "info"
)

type checker struct {
	cfg      *Config
	q        Query
	findings []Finding
}

func (c *checker) add(sev, rule, file string, line int, msg string, detail ...string) {
	c.findings = append(c.findings, Finding{
		Severity: sev, Rule: rule, File: file, Line: line,
		Message: redact(msg), Detail: redactAll(detail),
	})
}

// Check runs every analysis and returns the findings in a stable order.
func Check(cfg *Config, q Query) []Finding {
	c := &checker{cfg: cfg, q: q}
	c.parseIssues()
	c.duplicatePatterns()
	c.uppercasePatterns()
	c.unknownKeywords()
	c.wildcardIdentity()
	c.weakSettings()
	c.shadowing()
	c.includeLeaks()
	c.identityFiles()
	c.proxyJumps()
	c.unevaluatedMatches()

	sort.SliceStable(c.findings, func(i, j int) bool {
		a, b := c.findings[i], c.findings[j]
		if a.File != b.File {
			return a.File < b.File
		}
		if a.Line != b.Line {
			return a.Line < b.Line
		}
		if a.Rule != b.Rule {
			return a.Rule < b.Rule
		}
		return a.Message < b.Message
	})
	return c.findings
}

// parseIssues lifts problems found while reading the files into findings.
func (c *checker) parseIssues() {
	for _, is := range c.cfg.Issues {
		c.add(is.Severity, is.Rule, is.File, is.Line, is.Message)
	}
}

// duplicatePatterns finds the same Host pattern declared more than once. The
// second block is not merged with the first: because of first-obtained-wins,
// only settings the first block does not mention can still take effect there.
func (c *checker) duplicatePatterns() {
	type place struct {
		file string
		line int
	}
	seen := map[string][]place{}
	var order []string
	for _, hp := range hostPatterns(c.cfg) {
		if _, ok := seen[hp.Pattern]; !ok {
			order = append(order, hp.Pattern)
		}
		seen[hp.Pattern] = append(seen[hp.Pattern], place{hp.File, hp.Line})
	}
	sort.Strings(order)
	for _, pat := range order {
		ps := seen[pat]
		if len(ps) < 2 {
			continue
		}
		var detail []string
		for _, p := range ps[1:] {
			detail = append(detail, fmt.Sprintf("also declared at %s:%d", p.file, p.line))
		}
		detail = append(detail,
			"the blocks are not merged: any keyword set in the first block wins, "+
				"and the later block can only add keywords the first one omits")
		c.add(sevWarn, "duplicate-host-pattern", ps[0].file, ps[0].line,
			fmt.Sprintf("host pattern %q is declared in %d separate Host blocks", pat, len(ps)),
			detail...)
	}
}

// uppercasePatterns finds Host patterns that can never match, because ssh
// lowercases the target host before matching but not the pattern.
func (c *checker) uppercasePatterns() {
	for _, hp := range hostPatterns(c.cfg) {
		p := strings.TrimPrefix(hp.Pattern, "!")
		if p == strings.ToLower(p) {
			continue
		}
		c.add(sevWarn, "uppercase-host-pattern", hp.File, hp.Line,
			fmt.Sprintf("host pattern %q contains uppercase letters and can never match", hp.Pattern),
			"ssh lowercases the host you type on the command line but uses the "+
				"pattern from the file verbatim, so an uppercase pattern is dead code",
			fmt.Sprintf("write it as %q", strings.ToLower(hp.Pattern)))
	}
}

// unknownKeywords finds keywords ssh does not know, honouring IgnoreUnknown.
func (c *checker) unknownKeywords() {
	var ignorePatterns []string
	for _, n := range c.cfg.Nodes {
		if n.Kind == KindKeyword && n.Key == "ignoreunknown" {
			ignorePatterns = append(ignorePatterns, n.Args...)
		}
	}
	ignored := func(kw string) bool {
		for _, pat := range ignorePatterns {
			if matchPatternList(strings.ToLower(kw), strings.ToLower(pat), true) == patternPositive {
				return true
			}
		}
		return false
	}
	for _, n := range c.cfg.Nodes {
		if n.Kind != KindKeyword {
			continue
		}
		if dep, ok := deprecatedKeywords[n.Key]; ok {
			c.add(sevWarn, "deprecated-keyword", n.File, n.Line,
				fmt.Sprintf("%s is obsolete: %s", n.Keyword, dep))
			continue
		}
		if isKnownKeyword(n.Key) {
			continue
		}
		if ignored(n.Keyword) {
			c.add(sevInfo, "unknown-keyword-ignored", n.File, n.Line,
				fmt.Sprintf("unknown keyword %q is covered by IgnoreUnknown, so ssh stays quiet", n.Keyword))
			continue
		}
		msg := fmt.Sprintf("unknown keyword %q; ssh will refuse to start", n.Keyword)
		var detail []string
		if s := suggestKeyword(n.Keyword); s != "" {
			detail = append(detail, fmt.Sprintf("did you mean %s?", s))
		}
		detail = append(detail, "add it to IgnoreUnknown if it is meant for another tool")
		c.add(sevError, "unknown-keyword", n.File, n.Line, msg, detail...)
	}
}

// wildcardIdentity flags User and Port set on the catch-all block, which
// silently applies to every host you ever connect to. A partial wildcard such
// as `Host db-*.internal` is a deliberate grouping and is left alone.
func (c *checker) wildcardIdentity() {
	inWildcard := false
	var blockText, blockFile string
	var blockLine int
	for _, n := range c.cfg.Nodes {
		switch n.Kind {
		case KindHost:
			inWildcard = false
			for _, p := range n.Patterns {
				if p == "*" || p == "*.*" {
					inWildcard = true
				}
			}
			blockText, blockFile, blockLine = n.Text, n.File, n.Line
		case KindMatch:
			inWildcard = false
		case KindKeyword:
			if !inWildcard {
				continue
			}
			switch n.Key {
			case "user", "port":
				c.add(sevWarn, "wildcard-"+n.Key, n.File, n.Line,
					fmt.Sprintf("%s %s is set on the wildcard block %q at %s:%d",
						n.Keyword, n.Value(), blockText, blockFile, blockLine),
					"because it matches everything AND comes first, this value wins for "+
						"every host, including hosts whose own block sets "+n.Keyword,
					"if it was meant for one host, move it into that host's block")
			}
		}
	}
}

// weakSettings flags settings that quietly weaken security.
func (c *checker) weakSettings() {
	for _, n := range c.cfg.Nodes {
		if n.Kind != KindKeyword {
			continue
		}
		val := strings.ToLower(n.Value())
		for _, r := range weakRules {
			if r.Key != n.Key {
				continue
			}
			if r.Value != "" && !strings.Contains(val, r.Value) {
				continue
			}
			c.add(r.Severity, "weak-setting", n.File, n.Line,
				fmt.Sprintf("%s %s: %s", n.Keyword, n.Value(), r.Message))
		}
		if weakAlgorithmKeywords[n.Key] {
			var found []string
			for _, item := range strings.Split(strings.TrimLeft(val, "+-^"), ",") {
				item = strings.TrimSpace(item)
				if why, ok := weakAlgorithms[item]; ok {
					found = append(found, fmt.Sprintf("%s (%s)", item, why))
				}
			}
			if len(found) > 0 {
				sort.Strings(found)
				c.add(sevWarn, "weak-algorithm", n.File, n.Line,
					fmt.Sprintf("%s lists obsolete algorithms", n.Keyword), found...)
			}
		}
	}
}

// shadowing is the headline analysis: a broad block placed before a specific
// one steals keywords from it, because the first value obtained wins.
func (c *checker) shadowing() {
	type victim struct {
		thiefFile  string
		thiefLine  int
		thiefBlock string
		victFile   string
		victLine   int
		victBlock  string
	}
	stolen := map[victim]map[string]string{}
	var order []victim

	for _, host := range concreteHosts(c.cfg) {
		q := c.q
		q.Host = host
		res := Resolve(c.cfg, q)
		for _, d := range res.Decls {
			if d.Status != StatusIgnored {
				continue
			}
			winners := res.GetAll(d.Key)
			if len(winners) == 0 {
				continue
			}
			w := winners[0]
			if !blockIsBroaderThan(w.Block, d.Block) {
				continue
			}
			v := victim{
				thiefFile: w.BlockFile, thiefLine: w.BlockLine, thiefBlock: w.Block,
				victFile: d.BlockFile, victLine: d.BlockLine, victBlock: d.Block,
			}
			if stolen[v] == nil {
				stolen[v] = map[string]string{}
				order = append(order, v)
			}
			stolen[v][canonicalKeyword(d.Key)] = fmt.Sprintf(
				"%s %s at line %d is dead; ssh uses %s from %s:%d instead",
				canonicalKeyword(d.Key), redact(d.Value), d.Line, redact(w.Value), w.File, w.Line)
		}
	}

	sort.Slice(order, func(i, j int) bool {
		if order[i].thiefFile != order[j].thiefFile {
			return order[i].thiefFile < order[j].thiefFile
		}
		if order[i].thiefLine != order[j].thiefLine {
			return order[i].thiefLine < order[j].thiefLine
		}
		return order[i].victLine < order[j].victLine
	})

	for _, v := range order {
		var kws []string
		for k := range stolen[v] {
			kws = append(kws, k)
		}
		sort.Strings(kws)
		detail := []string{
			fmt.Sprintf("the broader block %q at %s:%d comes first, and ssh keeps the "+
				"FIRST value it obtains for a keyword, not the most specific one",
				v.thiefBlock, v.thiefFile, v.thiefLine),
		}
		for _, k := range kws {
			detail = append(detail, stolen[v][k])
		}
		detail = append(detail,
			"fix it by moving the broad block to the END of the file, which is where "+
				"ssh_config defaults belong")
		c.add(sevError, "shadowed-block", v.victFile, v.victLine,
			fmt.Sprintf("%q is shadowed by an earlier broader block; it never gets its own %s",
				v.victBlock, strings.Join(kws, ", ")),
			detail...)
	}
}

// blockIsBroaderThan reports whether the thief block's patterns are wildcards
// while the victim's are not: that is the accidental-shadowing shape.
func blockIsBroaderThan(thief, victimBlock string) bool {
	if thief == victimBlock {
		return false
	}
	tf := strings.Fields(thief)
	vf := strings.Fields(victimBlock)
	if len(tf) < 2 || len(vf) < 2 {
		return false
	}
	if !strings.EqualFold(tf[0], "host") || !strings.EqualFold(vf[0], "host") {
		return false
	}
	thiefWild := false
	for _, p := range tf[1:] {
		if isWildcard(p) {
			thiefWild = true
		}
	}
	victimWild := false
	for _, p := range vf[1:] {
		if isWildcard(p) {
			victimWild = true
		}
	}
	return thiefWild && !victimWild
}

// includeLeaks finds included files that end while a Host or Match block is
// still open. ssh keeps a single "is the current block active" flag across the
// Include, so the block carries on into the including file: every directive
// after the Include, and every file included after it, silently applies only
// to hosts matching that trailing block.
func (c *checker) includeLeaks() {
	type frame struct {
		open  *Node // trailing block left open, nil when the file is balanced
		start *Node // the IncludeStart that opened this frame
	}
	stack := []frame{{}}
	for _, n := range c.cfg.Nodes {
		switch n.Kind {
		case KindIncludeStart:
			stack = append(stack, frame{start: n})
		case KindIncludeEnd:
			if len(stack) < 2 {
				continue
			}
			top := stack[len(stack)-1]
			stack = stack[:len(stack)-1]
			if top.open == nil {
				continue
			}
			// The leak propagates outwards as well.
			stack[len(stack)-1].open = top.open
			c.add(sevWarn, "include-leaks-block", n.IncDirective, n.IncLine,
				fmt.Sprintf("%s ends inside the block %q, which leaks back into %s",
					n.IncFile, top.open.Text, n.IncDirective),
				fmt.Sprintf("the block was opened at %s:%d and is never closed - "+
					"ssh_config blocks end only at the next Host/Match line or at the "+
					"end of ALL the files", top.open.File, top.open.Line),
				"everything after this Include - including any later included file - "+
					"is therefore only read for hosts matching that block",
				"fix it by ending the included file with a plain 'Host *' line, or by "+
					"moving the Include to the very end")
		case KindHost, KindMatch:
			stack[len(stack)-1].open = n
		}
	}
}

// identityFiles checks that the keys the config points at exist and are not
// readable by anyone else.
func (c *checker) identityFiles() {
	type keyRef struct {
		path string
		file string
		line int
	}
	seen := map[keyRef]bool{}
	var refs []keyRef

	collect := func(res *Result, key string) {
		for _, d := range res.GetAll(key) {
			for _, raw := range strings.Fields(d.Value) {
				r := keyRef{path: raw, file: d.File, line: d.Line}
				if !seen[r] {
					seen[r] = true
					refs = append(refs, r)
				}
			}
		}
	}
	hosts := concreteHosts(c.cfg)
	if len(hosts) == 0 {
		hosts = []string{"example.invalid"}
	}
	for _, host := range hosts {
		q := c.q
		q.Host = host
		res := Resolve(c.cfg, q)
		collect(res, "identityfile")
		collect(res, "certificatefile")
	}
	sort.Slice(refs, func(i, j int) bool {
		if refs[i].file != refs[j].file {
			return refs[i].file < refs[j].file
		}
		if refs[i].line != refs[j].line {
			return refs[i].line < refs[j].line
		}
		return refs[i].path < refs[j].path
	})

	for _, r := range refs {
		if strings.EqualFold(r.path, "none") {
			continue
		}
		p := expandTilde(r.path, c.homeDir())
		if strings.ContainsRune(p, '%') {
			p = expandTokens(p, "", c.q)
		}
		if strings.ContainsRune(p, '%') {
			c.add(sevInfo, "identity-unexpandable", r.file, r.line,
				fmt.Sprintf("cannot check %s: it still contains a percent token after expansion", r.path))
			continue
		}
		if !filepath.IsAbs(p) {
			// ssh resolves a bare relative identity path against the current
			// working directory of the ssh process, which is rarely what the
			// author meant.
			c.add(sevWarn, "identity-relative", r.file, r.line,
				fmt.Sprintf("IdentityFile %s is a relative path", r.path),
				"ssh resolves it against whatever directory you happen to run ssh "+
					"from, so it will work in one shell and fail in another")
			continue
		}
		info, err := os.Stat(p)
		if err != nil {
			if os.IsNotExist(err) {
				c.add(sevError, "identity-missing", r.file, r.line,
					fmt.Sprintf("IdentityFile %s does not exist (%s)", r.path, p),
					"ssh will skip it silently and fall back to whatever the agent offers")
			} else {
				c.add(sevWarn, "identity-unreadable", r.file, r.line,
					fmt.Sprintf("cannot stat %s: %v", p, err))
			}
			continue
		}
		if info.IsDir() {
			c.add(sevError, "identity-not-a-file", r.file, r.line,
				fmt.Sprintf("IdentityFile %s is a directory", r.path))
			continue
		}
		mode := info.Mode().Perm()
		if mode&0o077 != 0 {
			c.add(sevError, "identity-permissions", r.file, r.line,
				fmt.Sprintf("private key %s is mode %04o", p, mode),
				fmt.Sprintf("group/other have %s access; ssh refuses to use a key "+
					"other accounts can read", permWho(mode)),
				fmt.Sprintf("fix with: chmod 600 %s", p))
		}
	}
}

func permWho(mode os.FileMode) string {
	var parts []string
	if mode&0o040 != 0 || mode&0o004 != 0 {
		parts = append(parts, "read")
	}
	if mode&0o020 != 0 || mode&0o002 != 0 {
		parts = append(parts, "write")
	}
	if mode&0o010 != 0 || mode&0o001 != 0 {
		parts = append(parts, "execute")
	}
	if len(parts) == 0 {
		return "some"
	}
	return strings.Join(parts, "/")
}

func (c *checker) homeDir() string { return homeDir() }

// ---------------------------------------------------------------------------
// ProxyJump
// ---------------------------------------------------------------------------

// jumpHop is one element of a ProxyJump chain.
type jumpHop struct {
	Host string
	User string
	Port string
}

// parseJumpChain splits a ProxyJump value into hops. "none" disables jumping.
func parseJumpChain(v string) []jumpHop {
	v = strings.TrimSpace(v)
	if v == "" || strings.EqualFold(v, "none") {
		return nil
	}
	var out []jumpHop
	for _, part := range strings.Split(v, ",") {
		part = strings.TrimSpace(part)
		if part == "" {
			continue
		}
		h := jumpHop{}
		if i := strings.LastIndex(part, "@"); i >= 0 {
			h.User = part[:i]
			part = part[i+1:]
		}
		// Strip a port, taking care not to mangle a bare IPv6 literal.
		if strings.HasPrefix(part, "[") {
			if i := strings.Index(part, "]"); i >= 0 {
				rest := part[i+1:]
				part = part[1:i]
				if strings.HasPrefix(rest, ":") {
					h.Port = rest[1:]
				}
			}
		} else if i := strings.LastIndex(part, ":"); i >= 0 && strings.Count(part, ":") == 1 {
			h.Port = part[i+1:]
			part = part[:i]
		}
		h.Host = part
		if h.Host != "" {
			out = append(out, h)
		}
	}
	return out
}

// jumpChainFor returns the ProxyJump hops that apply to a host.
func jumpChainFor(cfg *Config, q Query, host string) []jumpHop {
	q.Host = host
	res := Resolve(cfg, q)
	ds := res.GetAll("proxyjump")
	if len(ds) == 0 {
		return nil
	}
	return parseJumpChain(ds[0].Value)
}

// jumpDecl returns the declaration a host's ProxyJump came from.
func jumpDecl(cfg *Config, q Query, host string) (Decl, bool) {
	q.Host = host
	res := Resolve(cfg, q)
	ds := res.GetAll("proxyjump")
	if len(ds) == 0 {
		return Decl{}, false
	}
	return ds[0], true
}

// hostIsDeclared reports whether any Host pattern matches the name, and
// whether the only thing that matched was a catch-all.
func hostIsDeclared(cfg *Config, name string) (declared, catchAllOnly bool) {
	lower := strings.ToLower(name)
	for _, hp := range hostPatterns(cfg) {
		p := hp.Pattern
		if strings.HasPrefix(p, "!") {
			continue
		}
		if !matchPattern(lower, p) {
			continue
		}
		if p == "*" {
			catchAllOnly = true
			continue
		}
		return true, false
	}
	return catchAllOnly, catchAllOnly
}

// proxyJumps walks the jump graph looking for undefined targets and cycles.
func (c *checker) proxyJumps() {
	reported := map[string]bool{}
	cycles := map[string]bool{}

	var walk func(host string, path []string, onPath map[string]bool)
	walk = func(host string, path []string, onPath map[string]bool) {
		hops := jumpChainFor(c.cfg, c.q, host)
		if len(hops) == 0 {
			return
		}
		d, _ := jumpDecl(c.cfg, c.q, host)
		for _, h := range hops {
			declared, catchAll := hostIsDeclared(c.cfg, h.Host)
			key := h.Host + "\x00" + d.Where()
			if !declared && !reported[key] {
				reported[key] = true
				c.add(sevWarn, "proxyjump-undefined", d.File, d.Line,
					fmt.Sprintf("ProxyJump target %q for %q has no Host block", h.Host, host),
					"ssh will use it as a literal hostname with default user and port; "+
						"if it was meant to be one of your aliases, the alias is misspelt")
			} else if catchAll && !reported[key] {
				reported[key] = true
				c.add(sevInfo, "proxyjump-catchall-only", d.File, d.Line,
					fmt.Sprintf("ProxyJump target %q for %q is only matched by the catch-all Host * block", h.Host, host))
			}
		}
		next := hops[0].Host
		if onPath[strings.ToLower(next)] {
			// Trim the run-up: only the part of the path from the repeated
			// host onwards is actually the loop.
			loop := append(append([]string{}, path...), next)
			for i, p := range loop {
				if strings.EqualFold(p, next) {
					loop = loop[i:]
					break
				}
			}
			sig := cycleSignature(loop)
			if !cycles[sig] {
				cycles[sig] = true
				c.add(sevError, "proxyjump-cycle", d.File, d.Line,
					fmt.Sprintf("ProxyJump cycle: %s", strings.Join(loop, " -> ")),
					"ssh would fork a new client for each hop forever until the "+
						"process or file-descriptor limit stops it")
			}
			return
		}
		if len(path) > 32 {
			return
		}
		onPath[strings.ToLower(next)] = true
		walk(next, append(path, next), onPath)
		delete(onPath, strings.ToLower(next))
	}

	for _, host := range concreteHosts(c.cfg) {
		onPath := map[string]bool{strings.ToLower(host): true}
		walk(host, []string{host}, onPath)
	}
}

// cycleSignature makes the same loop, entered at different points, compare
// equal so a cycle is reported once.
func cycleSignature(loop []string) string {
	if len(loop) < 2 {
		return strings.Join(loop, ">")
	}
	ring := loop[:len(loop)-1]
	norm := make([]string, len(ring))
	for i, r := range ring {
		norm[i] = strings.ToLower(r)
	}
	best := ""
	for i := range norm {
		rot := append(append([]string{}, norm[i:]...), norm[:i]...)
		s := strings.Join(rot, ">")
		if best == "" || s < best {
			best = s
		}
	}
	return best
}

// unevaluatedMatches reports Match blocks SSHDesk deliberately refuses to
// evaluate, so the reader knows the analysis skipped them.
func (c *checker) unevaluatedMatches() {
	for _, n := range c.cfg.Nodes {
		if n.Kind != KindMatch {
			continue
		}
		for _, cr := range n.Criteria {
			switch cr.Name {
			case "exec":
				c.add(sevInfo, "match-exec", n.File, n.Line,
					fmt.Sprintf("%s is never evaluated", n.Text),
					"SSHDesk does not run commands, so every keyword inside this block "+
						"is reported as unevaluated rather than guessed at")
			case "canonical", "final", "localnetwork":
				c.add(sevInfo, "match-unevaluated", n.File, n.Line,
					fmt.Sprintf("%s depends on %q, which SSHDesk does not evaluate", n.Text, cr.Name),
					"name canonicalisation and local network detection need DNS and "+
						"interface state that a static analyser has no business touching")
			}
		}
	}
}

// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------

// countBySeverity returns the number of errors, warnings and infos.
func countBySeverity(fs []Finding) (errs, warns, infos int) {
	for _, f := range fs {
		switch f.Severity {
		case sevError:
			errs++
		case sevWarn:
			warns++
		default:
			infos++
		}
	}
	return
}

// portLooksValid is used by the hosts listing to flag nonsense ports.
func portLooksValid(p string) bool {
	n, err := strconv.Atoi(p)
	return err == nil && n > 0 && n < 65536
}
