package main

import (
	"fmt"
	"os"
	"sort"
	"strings"
)

// ---------------------------------------------------------------------------
// Resolution: what would ssh actually use for this host?
// ---------------------------------------------------------------------------
//
// THE RULE, and it is the one people get wrong: ssh_config is
// FIRST-OBTAINED-VALUE-WINS. The earliest matching declaration of a keyword is
// the one that takes effect; every later declaration of the same keyword is
// read, matched, and then discarded. A `Host *` block at the TOP of the file
// therefore beats the specific block for your host further down.
//
// The only exception is a small set of keywords that accumulate (IdentityFile,
// LocalForward, SetEnv, ...); see listKeywords in keywords.go.

// Status values recorded for every declaration seen.
const (
	StatusApplied     = "applied"     // this declaration is the effective value
	StatusAppended    = "appended"    // accumulating keyword, contributes a value
	StatusIgnored     = "ignored"     // matched, but an earlier value already won
	StatusNotMatched  = "not-matched" // the enclosing block did not match this host
	StatusNotReached  = "not-reached" // inside an Include that was never read
	StatusUnevaluated = "unevaluated" // enclosing Match could not be evaluated
)

// Decl is one keyword declaration, with the verdict on it.
type Decl struct {
	Keyword   string `json:"keyword"`
	Key       string `json:"key"`
	Value     string `json:"value"`
	File      string `json:"file"`
	Line      int    `json:"line"`
	Block     string `json:"block"`
	BlockFile string `json:"block_file"`
	BlockLine int    `json:"block_line"`
	Status    string `json:"status"`
	Reason    string `json:"reason,omitempty"`
}

// Where renders the source position as file:line.
func (d Decl) Where() string { return fmt.Sprintf("%s:%d", d.File, d.Line) }

// BlockWhere renders the enclosing block's source position.
func (d Decl) BlockWhere() string {
	if d.BlockLine == 0 {
		return "(top level)"
	}
	return fmt.Sprintf("%s:%d", d.BlockFile, d.BlockLine)
}

// MatchedBlock records a Host/Match block that applied to the query.
type MatchedBlock struct {
	Text string `json:"text"`
	File string `json:"file"`
	Line int    `json:"line"`
}

// Query is the question asked of the configuration.
type Query struct {
	Host      string // the host as typed on the ssh command line
	User      string // ssh -l, empty when not given
	LocalUser string // the local account name
	Tag       string // value of an earlier Tag directive, for Match tagged
}

// Result is everything learned about one host.
type Result struct {
	Query    Query
	Decls    []Decl            // every declaration seen, in file order
	winners  map[string][]Decl // effective values by lowercased keyword
	Blocks   []MatchedBlock    // blocks that matched, in order
	Unevals  []string          // Match blocks that could not be evaluated
	Warnings []string
}

// Get returns the effective value of a keyword, or "" when unset.
func (r *Result) Get(key string) string {
	d := r.winners[strings.ToLower(key)]
	if len(d) == 0 {
		return ""
	}
	return d[0].Value
}

// GetAll returns every effective value of a keyword (more than one only for
// accumulating keywords such as IdentityFile).
func (r *Result) GetAll(key string) []Decl { return r.winners[strings.ToLower(key)] }

// Keys returns the lowercased keywords that have an effective value, sorted.
func (r *Result) Keys() []string {
	keys := make([]string, 0, len(r.winners))
	for k := range r.winners {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

type blockCtx struct {
	text     string
	file     string
	line     int
	uneval   string // non-empty when the block could not be evaluated
	patterns []string
}

// Resolve walks the flattened node stream exactly as ssh walks the file and
// records the verdict on every declaration it passes.
func Resolve(cfg *Config, q Query) *Result {
	// ssh lowercases the host given on the command line before matching, but
	// leaves config patterns untouched. That is why `Host FOO` never matches.
	host := strings.ToLower(q.Host)
	if q.LocalUser == "" {
		q.LocalUser = localUserName()
	}

	res := &Result{Query: q, winners: map[string][]Decl{}}

	active := true
	skip := 0 // depth of Include files being skipped
	root := ""
	if len(cfg.Files) > 0 {
		root = cfg.Files[0]
	}
	cur := blockCtx{text: "(top level)", file: root, line: 0}
	// text is the block a line is written inside, which is not the same thing
	// as the block ssh is evaluating: inside a skipped Include, ssh never looks
	// at the Host lines at all. It is tracked purely so `explain` can say which
	// block a never-read declaration belongs to.
	text := cur

	for _, n := range cfg.Nodes {
		switch n.Kind {
		case KindIncludeStart:
			if skip > 0 {
				skip++
				continue
			}
			if !active {
				skip = 1
			}
		case KindIncludeEnd:
			if skip > 0 {
				skip--
			}
		case KindHost:
			text = blockCtx{text: n.Text, file: n.File, line: n.Line, patterns: n.Patterns}
			if skip > 0 {
				continue
			}
			ok, negBy := matchHostList(host, n.Patterns)
			active = ok
			cur = blockCtx{text: n.Text, file: n.File, line: n.Line, patterns: n.Patterns}
			if ok {
				res.Blocks = append(res.Blocks, MatchedBlock{Text: n.Text, File: n.File, Line: n.Line})
			} else if negBy != "" {
				cur.uneval = ""
				res.Warnings = append(res.Warnings, fmt.Sprintf(
					"%s:%d: block disabled by negated pattern %s", n.File, n.Line, negBy))
			}
		case KindMatch:
			text = blockCtx{text: n.Text, file: n.File, line: n.Line}
			if skip > 0 {
				continue
			}
			ok, uneval := evalMatch(n.Criteria, host, q, res)
			active = ok
			cur = blockCtx{text: n.Text, file: n.File, line: n.Line, uneval: uneval}
			if uneval != "" {
				active = false
				res.Unevals = append(res.Unevals, fmt.Sprintf(
					"%s:%d: %s (criterion %q is never evaluated - SSHDesk does not "+
						"run commands or canonicalise names, so the block is treated "+
						"as NOT matching)", n.File, n.Line, n.Text, uneval))
			} else if ok {
				res.Blocks = append(res.Blocks, MatchedBlock{Text: n.Text, File: n.File, Line: n.Line})
			}
		case KindKeyword:
			blk := cur
			if skip > 0 {
				blk = text
			}
			d := Decl{
				Keyword:   n.Keyword,
				Key:       n.Key,
				Value:     n.Value(),
				File:      n.File,
				Line:      n.Line,
				Block:     blk.text,
				BlockFile: blk.file,
				BlockLine: blk.line,
			}
			switch {
			case skip > 0:
				d.Status = StatusNotReached
				d.Reason = "inside an Include that ssh never reads for this host"
			case cur.uneval != "":
				d.Status = StatusUnevaluated
				d.Reason = "enclosing Match uses " + cur.uneval + ", which is not evaluated"
			case !active:
				d.Status = StatusNotMatched
				d.Reason = "block " + cur.text + " does not match " + q.Host
			case listKeywords[n.Key]:
				d.Status = StatusAppended
				d.Reason = "accumulating keyword: every matching block contributes"
				res.winners[n.Key] = append(res.winners[n.Key], d)
			default:
				if prev, ok := res.winners[n.Key]; ok && len(prev) > 0 {
					d.Status = StatusIgnored
					d.Reason = fmt.Sprintf(
						"first-obtained value already set at %s:%d (%s)",
						prev[0].File, prev[0].Line, prev[0].Block)
				} else {
					d.Status = StatusApplied
					d.Reason = "first matching declaration of " + n.Keyword +
						"; ssh keeps the first value it obtains"
					res.winners[n.Key] = []Decl{d}
				}
			}
			res.Decls = append(res.Decls, d)
		}
	}
	return res
}

// evalMatch evaluates a Match line. It returns whether the block is active and,
// when a criterion cannot be evaluated at all, the name of that criterion.
func evalMatch(crit []Criterion, host string, q Query, res *Result) (bool, string) {
	all := true
	for _, c := range crit {
		var ok bool
		switch c.Name {
		case "all":
			ok = true
		case "canonical", "final":
			// Both require the two-pass canonicalisation ssh does after DNS
			// lookups. SSHDesk resolves nothing, so it cannot honestly say.
			return false, c.Name
		case "exec":
			// Running a shell command to decide a config value is exactly the
			// thing a static analyser must not do.
			return false, "exec"
		case "localnetwork":
			return false, "localnetwork"
		case "host":
			// After any substitution by an already-effective HostName.
			target := host
			if hn := res.Get("hostname"); hn != "" {
				target = strings.ToLower(expandTokens(hn, host, q))
			}
			ok = matchPatternList(target, c.Arg, true) == patternPositive
		case "originalhost":
			ok = matchPatternList(host, c.Arg, true) == patternPositive
		case "user":
			user := q.User
			if user == "" {
				user = res.Get("user")
			}
			if user == "" {
				user = q.LocalUser
			}
			ok = matchPatternList(user, c.Arg, false) == patternPositive
		case "localuser":
			ok = matchPatternList(q.LocalUser, c.Arg, false) == patternPositive
		case "tagged":
			tag := q.Tag
			if tag == "" {
				tag = res.Get("tag")
			}
			if tag == "" {
				ok = false
			} else {
				ok = matchPatternList(tag, c.Arg, false) == patternPositive
			}
		default:
			return false, c.Name
		}
		if c.Negated {
			ok = !ok
		}
		if !ok {
			all = false
		}
	}
	return all, ""
}

// ---------------------------------------------------------------------------
// Percent tokens
// ---------------------------------------------------------------------------

// expandTokens expands the ssh_config percent escapes SSHDesk can know the
// answer to. %C is a hash of connection parameters and is left alone.
func expandTokens(s, host string, q Query) string {
	if !strings.ContainsRune(s, '%') {
		return s
	}
	user := q.User
	if user == "" {
		user = q.LocalUser
	}
	var b strings.Builder
	for i := 0; i < len(s); i++ {
		if s[i] != '%' || i+1 >= len(s) {
			b.WriteByte(s[i])
			continue
		}
		i++
		switch s[i] {
		case '%':
			b.WriteByte('%')
		case 'h', 'n':
			b.WriteString(host)
		case 'r':
			b.WriteString(user)
		case 'u':
			b.WriteString(q.LocalUser)
		case 'd':
			b.WriteString(homeDir())
		case 'l':
			b.WriteString(localHostname())
		case 'L':
			lh := localHostname()
			if i := strings.IndexByte(lh, '.'); i > 0 {
				lh = lh[:i]
			}
			b.WriteString(lh)
		default:
			b.WriteByte('%')
			b.WriteByte(s[i])
		}
	}
	return b.String()
}

func localHostname() string {
	h, err := os.Hostname()
	if err != nil {
		return "localhost"
	}
	return h
}

// ---------------------------------------------------------------------------
// Connection target
// ---------------------------------------------------------------------------

// Target is the user@hostname:port ssh would end up using.
type Target struct {
	User        string `json:"user"`
	UserSource  string `json:"user_source"`
	HostName    string `json:"hostname"`
	HostSource  string `json:"hostname_source"`
	Port        string `json:"port"`
	PortSource  string `json:"port_source"`
	ProxyJump   string `json:"proxy_jump,omitempty"`
	ProxySource string `json:"proxy_jump_source,omitempty"`
}

// String renders the target the way people write it.
func (t Target) String() string {
	return fmt.Sprintf("%s@%s:%s", t.User, t.HostName, t.Port)
}

// TargetOf combines the effective settings with ssh's built-in defaults.
func TargetOf(res *Result, q Query) Target {
	t := Target{}
	host := strings.ToLower(q.Host)

	if ds := res.GetAll("hostname"); len(ds) > 0 {
		t.HostName = expandTokens(ds[0].Value, host, q)
		t.HostSource = ds[0].Where()
	} else {
		t.HostName = q.Host
		t.HostSource = "(the name you typed)"
	}
	switch {
	case q.User != "":
		t.User = q.User
		t.UserSource = "(--user)"
	default:
		if ds := res.GetAll("user"); len(ds) > 0 {
			t.User = ds[0].Value
			t.UserSource = ds[0].Where()
		} else {
			t.User = res.Query.LocalUser
			t.UserSource = "(local account, ssh default)"
		}
	}
	if ds := res.GetAll("port"); len(ds) > 0 {
		t.Port = ds[0].Value
		t.PortSource = ds[0].Where()
	} else {
		t.Port = "22"
		t.PortSource = "(ssh default)"
	}
	if ds := res.GetAll("proxyjump"); len(ds) > 0 {
		t.ProxyJump = ds[0].Value
		t.ProxySource = ds[0].Where()
	}
	return t
}

// ---------------------------------------------------------------------------
// Hosts declared in the file
// ---------------------------------------------------------------------------

// HostPattern is one pattern from one Host line.
type HostPattern struct {
	Pattern string
	File    string
	Line    int
	Text    string
}

// hostPatterns returns every pattern of every Host line, in file order.
func hostPatterns(cfg *Config) []HostPattern {
	var out []HostPattern
	for _, n := range cfg.Nodes {
		if n.Kind != KindHost {
			continue
		}
		for _, p := range n.Patterns {
			out = append(out, HostPattern{Pattern: p, File: n.File, Line: n.Line, Text: n.Text})
		}
	}
	return out
}

// concreteHosts returns the sorted, de-duplicated set of host patterns that
// name exactly one host.
func concreteHosts(cfg *Config) []string {
	seen := map[string]bool{}
	var out []string
	for _, hp := range hostPatterns(cfg) {
		if !isConcreteHost(hp.Pattern) || seen[hp.Pattern] {
			continue
		}
		seen[hp.Pattern] = true
		out = append(out, hp.Pattern)
	}
	sort.Strings(out)
	return out
}
