package main

import (
	"bufio"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

// ---------------------------------------------------------------------------
// The ssh_config parser
// ---------------------------------------------------------------------------
//
// A config file is flattened into an ordered stream of nodes. Include files
// are spliced in place, bracketed by IncludeStart/IncludeEnd markers so the
// resolver can reproduce OpenSSH's behaviour exactly: an Include inside a
// block that does not match is never read at all, and an included file that
// ends inside a Host block leaks that block into the including file.
//
// Nothing here interprets the config for a particular host. Resolution is a
// separate pass (resolve.go) so that one parse can answer many questions.

// NodeKind distinguishes the entries of the flattened stream.
type NodeKind int

const (
	// KindKeyword is an ordinary "Keyword value..." line.
	KindKeyword NodeKind = iota
	// KindHost is a "Host pattern..." line.
	KindHost
	// KindMatch is a "Match criteria..." line.
	KindMatch
	// KindIncludeStart marks the start of one included file.
	KindIncludeStart
	// KindIncludeEnd marks the end of one included file.
	KindIncludeEnd
)

// Criterion is one term of a Match line, e.g. "!host bastion.*".
type Criterion struct {
	Name    string `json:"name"`    // lowercased: host, user, exec, all, ...
	Negated bool   `json:"negated"` // written with a leading '!'
	Arg     string `json:"arg"`     // the comma-separated pattern list
}

// String renders the criterion the way it was written.
func (c Criterion) String() string {
	s := c.Name
	if c.Negated {
		s = "!" + s
	}
	if c.Arg != "" {
		s += " " + c.Arg
	}
	return s
}

// Node is one entry of the flattened configuration stream.
type Node struct {
	Kind     NodeKind
	File     string
	Line     int
	Keyword  string   // as written in the file
	Key      string   // lowercased keyword
	Args     []string // arguments after the keyword
	Patterns []string // Host patterns (KindHost)
	Criteria []Criterion
	Text     string // reconstructed source line, for display

	// IncFile is the file being entered or left (KindIncludeStart/End).
	IncFile string
	// IncLine is the line of the Include directive responsible.
	IncLine int
	// IncDirective is the file containing the Include directive.
	IncDirective string
}

// Value joins the arguments back into a single displayable value.
func (n *Node) Value() string { return strings.Join(n.Args, " ") }

// Issue is a problem found while reading the files themselves.
type Issue struct {
	Severity string `json:"severity"`
	Rule     string `json:"rule"`
	File     string `json:"file"`
	Line     int    `json:"line"`
	Message  string `json:"message"`
}

// Config is a parsed set of configuration files.
type Config struct {
	Roots  []string
	Files  []string
	Nodes  []*Node
	Issues []Issue
}

// LoadOptions controls path expansion while parsing.
type LoadOptions struct {
	// Home is the directory '~' expands to.
	Home string
	// BaseDir is the directory relative Include paths resolve against. When
	// empty it defaults to the directory of each root file, which is what
	// OpenSSH does for the standard ~/.ssh/config and /etc/ssh/ssh_config.
	BaseDir string
	// MaxDepth caps Include nesting. OpenSSH uses 16.
	MaxDepth int
}

const defaultMaxIncludeDepth = 16

type parser struct {
	opt   LoadOptions
	cfg   *Config
	seen  map[string]bool
	base  string
	stack []string
}

// LoadConfig parses the given root files in order. The nodes of every file are
// concatenated, which is how ssh treats the user config followed by the system
// config: the user file is read first, so it wins.
func LoadConfig(roots []string, opt LoadOptions) (*Config, error) {
	if opt.MaxDepth <= 0 {
		opt.MaxDepth = defaultMaxIncludeDepth
	}
	if opt.Home == "" {
		opt.Home = homeDir()
	}
	cfg := &Config{Roots: append([]string(nil), roots...)}
	p := &parser{opt: opt, cfg: cfg, seen: map[string]bool{}}
	for _, r := range roots {
		abs, err := filepath.Abs(r)
		if err != nil {
			abs = r
		}
		p.base = opt.BaseDir
		if p.base == "" {
			p.base = filepath.Dir(abs)
		}
		if err := p.parseFile(abs, 0); err != nil {
			return nil, err
		}
	}
	return cfg, nil
}

func (p *parser) issue(sev, rule, file string, line int, format string, args ...any) {
	p.cfg.Issues = append(p.cfg.Issues, Issue{
		Severity: sev, Rule: rule, File: file, Line: line,
		Message: fmt.Sprintf(format, args...),
	})
}

func (p *parser) parseFile(path string, depth int) error {
	f, err := os.Open(path)
	if err != nil {
		if depth == 0 {
			return err
		}
		p.issue("error", "include-unreadable", path, 0, "cannot read included file: %v", err)
		return nil
	}
	defer f.Close()

	p.cfg.Files = append(p.cfg.Files, path)

	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
	lineno := 0
	for sc.Scan() {
		lineno++
		raw := strings.TrimRight(sc.Text(), "\r")
		p.parseLine(raw, path, lineno, depth)
	}
	if err := sc.Err(); err != nil {
		p.issue("error", "unreadable", path, lineno, "read error: %v", err)
	}
	return nil
}

func (p *parser) parseLine(raw, path string, lineno, depth int) {
	fields, err := splitArgs(raw)
	if err != nil {
		p.issue("error", "bad-quoting", path, lineno, "%v: %s", err, strings.TrimSpace(raw))
		return
	}
	if len(fields) == 0 {
		return
	}

	// OpenSSH reads ssh_config one line at a time with fgets and never joins
	// lines, so a trailing backslash is NOT a continuation - it survives as a
	// literal backslash in the value. People write it anyway; say so.
	if strings.HasSuffix(strings.TrimRight(raw, " \t"), "\\") {
		p.issue("warning", "line-continuation", path, lineno,
			"line ends with a backslash: ssh_config has no line continuation, "+
				"so the backslash is part of the value and the next line is a "+
				"separate directive")
	}

	keyword, args := splitKeyword(fields)
	key := strings.ToLower(keyword)

	switch key {
	case "host":
		if len(args) == 0 {
			p.issue("error", "empty-host", path, lineno, "Host with no patterns")
			return
		}
		p.cfg.Nodes = append(p.cfg.Nodes, &Node{
			Kind: KindHost, File: path, Line: lineno,
			Keyword: keyword, Key: key, Args: args, Patterns: args,
			Text: "Host " + strings.Join(args, " "),
		})
	case "match":
		crit, err := parseMatch(args)
		if err != nil {
			p.issue("error", "bad-match", path, lineno, "%v", err)
			return
		}
		parts := make([]string, 0, len(crit))
		for _, c := range crit {
			parts = append(parts, c.String())
		}
		p.cfg.Nodes = append(p.cfg.Nodes, &Node{
			Kind: KindMatch, File: path, Line: lineno,
			Keyword: keyword, Key: key, Args: args, Criteria: crit,
			Text: "Match " + strings.Join(parts, " "),
		})
	case "include":
		if len(args) == 0 {
			p.issue("error", "empty-include", path, lineno, "Include with no path")
			return
		}
		p.doInclude(args, path, lineno, depth)
	default:
		p.cfg.Nodes = append(p.cfg.Nodes, &Node{
			Kind: KindKeyword, File: path, Line: lineno,
			Keyword: keyword, Key: key, Args: args,
			Text: keyword + " " + strings.Join(args, " "),
		})
	}
}

func (p *parser) doInclude(args []string, path string, lineno, depth int) {
	if depth+1 > p.opt.MaxDepth {
		p.issue("error", "include-depth", path, lineno,
			"Include nesting deeper than %d levels; refusing to descend further "+
				"(a file may be including itself)", p.opt.MaxDepth)
		return
	}
	for _, arg := range args {
		pattern := p.expandIncludePath(arg)
		matches, err := filepath.Glob(pattern)
		if err != nil {
			p.issue("error", "bad-include-glob", path, lineno,
				"Include %s: %v", arg, err)
			continue
		}
		if len(matches) == 0 {
			p.issue("info", "include-empty", path, lineno,
				"Include %s matched no files (ssh ignores this silently)", arg)
			continue
		}
		sort.Strings(matches)
		for _, m := range matches {
			info, err := os.Stat(m)
			if err == nil && info.IsDir() {
				continue // glob(3) can return directories; ssh cannot read them
			}
			p.cfg.Nodes = append(p.cfg.Nodes, &Node{
				Kind: KindIncludeStart, File: path, Line: lineno,
				IncFile: m, IncLine: lineno, IncDirective: path,
				Text: "Include " + arg,
			})
			if err := p.parseFile(m, depth+1); err != nil {
				p.issue("error", "include-unreadable", m, 0, "%v", err)
			}
			p.cfg.Nodes = append(p.cfg.Nodes, &Node{
				Kind: KindIncludeEnd, File: path, Line: lineno,
				IncFile: m, IncLine: lineno, IncDirective: path,
				Text: "end of " + m,
			})
		}
	}
}

// expandIncludePath applies OpenSSH's rules: '~' is the user's home, an
// absolute path is used as is, and anything else is relative to the directory
// holding the configuration being read (~/.ssh for the user config).
func (p *parser) expandIncludePath(arg string) string {
	if strings.HasPrefix(arg, "~") {
		return expandTilde(arg, p.opt.Home)
	}
	if filepath.IsAbs(arg) {
		return arg
	}
	return filepath.Join(p.base, arg)
}

func expandTilde(path, home string) string {
	if path == "~" {
		return home
	}
	if strings.HasPrefix(path, "~/") {
		return filepath.Join(home, path[2:])
	}
	// "~user/..." cannot be expanded without a passwd lookup for that user;
	// leave it alone rather than guessing.
	return path
}

func homeDir() string {
	if h, err := os.UserHomeDir(); err == nil && h != "" {
		return h
	}
	return ""
}

// ---------------------------------------------------------------------------
// Tokenising
// ---------------------------------------------------------------------------

var errUnterminatedQuote = errors.New("unterminated quote")

// splitArgs is a port of OpenSSH misc.c:argv_split() with terminate_on_comment
// set, which is how ssh_config lines are tokenised:
//
//   - whitespace separates arguments
//   - '#' starting an argument ends the line
//   - single and double quotes group text, including spaces
//   - a backslash escapes a quote, another backslash, or a space
//   - any other backslash is preserved literally
func splitArgs(s string) ([]string, error) {
	var out []string
	i := 0
	for i < len(s) {
		if s[i] == ' ' || s[i] == '\t' {
			i++
			continue
		}
		if s[i] == '#' {
			break
		}
		var quote byte
		var buf []byte
	arg:
		for ; i < len(s); i++ {
			c := s[i]
			switch {
			case c == '\\' && i+1 < len(s) &&
				(s[i+1] == '\'' || s[i+1] == '"' || s[i+1] == '\\' ||
					(quote == 0 && s[i+1] == ' ') ||
					(quote == '"' && s[i+1] == '"')):
				i++
				buf = append(buf, s[i])
			case quote == 0 && (c == ' ' || c == '\t'):
				break arg // end of this argument
			case quote == 0 && (c == '\'' || c == '"'):
				quote = c
			case quote != 0 && c == quote:
				quote = 0
			default:
				buf = append(buf, c)
			}
		}
		if quote != 0 {
			return nil, errUnterminatedQuote
		}
		out = append(out, string(buf))
	}
	return out, nil
}

// splitKeyword separates the keyword from its arguments, honouring the
// "Keyword=value", "Keyword = value" and "Keyword =value" spellings that ssh
// accepts. Only the separator immediately after the keyword is special: a
// later '=' (SetEnv FOO=bar) is left alone.
func splitKeyword(fields []string) (string, []string) {
	keyword := fields[0]
	args := append([]string(nil), fields[1:]...)
	if i := strings.IndexByte(keyword, '='); i >= 0 {
		rest := keyword[i+1:]
		keyword = keyword[:i]
		if rest != "" {
			args = append([]string{rest}, args...)
		}
		return keyword, args
	}
	if len(args) > 0 {
		if args[0] == "=" {
			return keyword, args[1:]
		}
		if strings.HasPrefix(args[0], "=") {
			rest := args[0][1:]
			if rest == "" {
				return keyword, args[1:]
			}
			args[0] = rest
		}
	}
	return keyword, args
}

// ---------------------------------------------------------------------------
// Match lines
// ---------------------------------------------------------------------------

// matchNeedsArg lists the Match criteria that take a pattern list.
var matchNeedsArg = map[string]bool{
	"host":         true,
	"originalhost": true,
	"user":         true,
	"localuser":    true,
	"exec":         true,
	"localnetwork": true,
	"tagged":       true,
}

// matchNoArg lists the Match criteria that stand alone.
var matchNoArg = map[string]bool{
	"all":       true,
	"canonical": true,
	"final":     true,
}

func parseMatch(args []string) ([]Criterion, error) {
	if len(args) == 0 {
		return nil, errors.New("Match with no criteria")
	}
	var out []Criterion
	for i := 0; i < len(args); i++ {
		tok := args[i]
		if tok == "" {
			continue
		}
		neg := false
		if strings.HasPrefix(tok, "!") {
			neg = true
			tok = tok[1:]
		}
		// "Match host=foo" is accepted by ssh because its tokeniser treats
		// '=' as a separator.
		var inlineArg string
		hasInline := false
		if j := strings.IndexByte(tok, '='); j >= 0 {
			inlineArg = tok[j+1:]
			tok = tok[:j]
			hasInline = true
		}
		name := strings.ToLower(tok)
		switch {
		case matchNoArg[name]:
			if hasInline && inlineArg != "" {
				return nil, fmt.Errorf("Match criterion %q takes no argument", name)
			}
			out = append(out, Criterion{Name: name, Negated: neg})
		case matchNeedsArg[name]:
			arg := inlineArg
			if !hasInline {
				if i+1 >= len(args) {
					return nil, fmt.Errorf("Match criterion %q needs an argument", name)
				}
				i++
				arg = args[i]
			}
			out = append(out, Criterion{Name: name, Negated: neg, Arg: arg})
		default:
			return nil, fmt.Errorf("unsupported Match criterion %q", tok)
		}
	}
	if len(out) == 0 {
		return nil, errors.New("Match with no criteria")
	}
	return out, nil
}
