// Package sshconf parses ~/.ssh/config (following Include directives) into a
// flat list of connectable Host entries. It is strictly read-only: nothing in
// this package ever writes to the ssh config tree.
//
// A small hand-written parser is used instead of a third-party library so that
// two things the ssh_config format does not natively support are available:
//   - the "# tag: a, b" comment convention placed above a Host block, and
//   - full control over Include expansion order for stable, fast startup.
package sshconf

import (
	"bufio"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

// Host is a single connectable ssh alias with the preview fields dial surfaces.
// It intentionally holds only what the picker needs; connecting is done by
// exec'ing `ssh <Alias>` so the real ssh client re-resolves everything.
type Host struct {
	Alias     string
	HostName  string
	User      string
	Port      string
	ProxyJump string
	Tags      []string
	Source    string // config file this entry was declared in
}

// maxIncludeDepth guards against pathological or cyclic Include chains.
const maxIncludeDepth = 16

// DefaultConfigPath returns ~/.ssh/config for the current user.
func DefaultConfigPath() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(home, ".ssh", "config"), nil
}

// Parse reads the config at path, follows Include directives, and returns the
// concrete hosts in declaration order. A missing top-level config is not an
// error: it yields an empty list so a fresh machine still launches cleanly.
func Parse(path string) ([]Host, error) {
	sshDir := filepath.Dir(path)
	p := &parser{sshDir: sshDir, seen: map[string]bool{}}
	if err := p.parseFile(path, 0); err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	out := make([]Host, len(p.hosts))
	for i, h := range p.hosts {
		out[i] = *h
	}
	return out, nil
}

type parser struct {
	sshDir string
	seen   map[string]bool // include cycle guard, by absolute path
	hosts  []*Host         // pointers so block updates stay valid across appends
}

func (p *parser) parseFile(path string, depth int) error {
	if depth > maxIncludeDepth {
		return nil
	}
	abs, err := filepath.Abs(path)
	if err != nil {
		abs = path
	}
	if p.seen[abs] {
		return nil // already parsed; avoid include cycles
	}
	p.seen[abs] = true

	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()

	return p.parseReader(f, path, depth)
}

func (p *parser) parseReader(r io.Reader, source string, depth int) error {
	sc := bufio.NewScanner(r)
	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)

	var pendingTags []string // tags from a "# tag:" comment awaiting the next Host
	var block []*Host        // all aliases in the current Host block

	// applyKV sets a keyword/value pair on every host in the current block.
	applyKV := func(keyword, value string) {
		for _, h := range block {
			switch strings.ToLower(keyword) {
			case "hostname":
				h.HostName = value
			case "user":
				h.User = value
			case "port":
				h.Port = value
			case "proxyjump":
				h.ProxyJump = value
			}
		}
	}

	for sc.Scan() {
		raw := sc.Text()
		line := strings.TrimSpace(raw)
		if line == "" {
			continue
		}

		if strings.HasPrefix(line, "#") {
			if tags, ok := parseTagComment(line); ok {
				pendingTags = append(pendingTags, tags...)
			}
			continue
		}

		keyword, value := splitKeyword(line)
		if keyword == "" {
			continue
		}

		switch strings.ToLower(keyword) {
		case "host":
			block = nil
			for _, alias := range strings.Fields(value) {
				if isPattern(alias) {
					continue // wildcard/negated patterns aren't connect targets
				}
				h := &Host{Alias: alias, Source: source, Tags: append([]string(nil), pendingTags...)}
				p.hosts = append(p.hosts, h)
				block = append(block, h)
			}
			pendingTags = nil

		case "include":
			// Include is resolved relative to ~/.ssh and may contain globs.
			for _, pat := range strings.Fields(value) {
				for _, inc := range p.expandInclude(pat) {
					_ = p.parseFile(inc, depth+1)
				}
			}
			pendingTags = nil

		case "match":
			// Match blocks are conditional; dial does not evaluate conditions.
			// End the current block so stray keywords don't attach to a host.
			block = nil
			pendingTags = nil

		default:
			if len(block) > 0 {
				applyKV(keyword, value)
			}
		}
	}
	return sc.Err()
}

// expandInclude resolves an Include pattern to concrete file paths. Relative
// patterns are anchored at ~/.ssh, matching OpenSSH behaviour.
func (p *parser) expandInclude(pattern string) []string {
	pattern = expandTilde(pattern)
	if !filepath.IsAbs(pattern) {
		pattern = filepath.Join(p.sshDir, pattern)
	}
	matches, err := filepath.Glob(pattern)
	if err != nil || len(matches) == 0 {
		return nil
	}
	sort.Strings(matches) // deterministic order across machines
	// Filter out directories; only include regular files.
	var files []string
	for _, m := range matches {
		if fi, err := os.Stat(m); err == nil && !fi.IsDir() {
			files = append(files, m)
		}
	}
	return files
}

// parseTagComment recognises the "# tag: prod, riedel" convention and returns
// the individual tags. It is lenient about spacing and the tag/tags spelling.
func parseTagComment(line string) ([]string, bool) {
	body := strings.TrimSpace(strings.TrimPrefix(line, "#"))
	lower := strings.ToLower(body)
	var rest string
	switch {
	case strings.HasPrefix(lower, "tags:"):
		rest = body[len("tags:"):]
	case strings.HasPrefix(lower, "tag:"):
		rest = body[len("tag:"):]
	default:
		return nil, false
	}
	var tags []string
	for _, t := range strings.Split(rest, ",") {
		if t = strings.TrimSpace(t); t != "" {
			tags = append(tags, t)
		}
	}
	return tags, len(tags) > 0
}

// splitKeyword splits an ssh_config line into keyword and value. ssh_config
// allows "Key Value" and "Key = Value"; both are handled here.
func splitKeyword(line string) (string, string) {
	// Strip trailing inline comments only when clearly separated.
	if i := strings.Index(line, " #"); i >= 0 {
		// keep '#' inside values rare; safe to drop trailing comment
		line = strings.TrimSpace(line[:i])
	}
	if eq := strings.IndexByte(line, '='); eq >= 0 && !strings.ContainsAny(line[:eq], " \t") {
		return strings.TrimSpace(line[:eq]), unquote(strings.TrimSpace(line[eq+1:]))
	}
	fields := strings.Fields(line)
	if len(fields) == 0 {
		return "", ""
	}
	if len(fields) == 1 {
		return fields[0], ""
	}
	// Support "Key = Value" where '=' is its own token.
	if fields[1] == "=" {
		return fields[0], unquote(strings.TrimSpace(strings.Join(fields[2:], " ")))
	}
	return fields[0], unquote(strings.TrimSpace(strings.Join(fields[1:], " ")))
}

func unquote(s string) string {
	if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
		return s[1 : len(s)-1]
	}
	return s
}

// isPattern reports whether an alias is a wildcard/negation pattern rather than
// a concrete connectable host.
func isPattern(alias string) bool {
	return strings.ContainsAny(alias, "*?!")
}

func expandTilde(path string) string {
	if path == "~" || strings.HasPrefix(path, "~/") {
		if home, err := os.UserHomeDir(); err == nil {
			if path == "~" {
				return home
			}
			return filepath.Join(home, path[2:])
		}
	}
	return path
}
