// Package steamvdf is a small, tolerant parser for Valve's text KeyValues
// ("VDF") format, sized for exactly two panel jobs:
//
//  1. Reading the installed buildid + beta branch out of a SteamCMD
//     appmanifest_<appid>.acf in an instance volume.
//  2. Reading the per-branch latest buildids out of `steamcmd
//     +app_info_print <appid>` output.
//
// Tolerant means: unquoted tokens are accepted, // comments are skipped,
// stray junk lines around the top-level object (SteamCMD banner output)
// are ignored, and duplicate keys take last-wins. It is NOT a general
// binary-VDF or #include-aware parser.
package steamvdf

import (
	"fmt"
	"strings"
)

// KV is one parsed VDF object: values are either string or KV.
type KV map[string]any

// Get walks a path of nested keys (case-insensitive, matching Steam's own
// behaviour) and returns the string value at the leaf, or "" if any hop
// is missing or is an object where a string was expected.
func (kv KV) Get(path ...string) string {
	cur := kv
	for i, key := range path {
		v, ok := lookup(cur, key)
		if !ok {
			return ""
		}
		if i == len(path)-1 {
			s, _ := v.(string)
			return s
		}
		next, ok := v.(KV)
		if !ok {
			return ""
		}
		cur = next
	}
	return ""
}

// Obj walks a path and returns the nested object at the leaf, or nil.
func (kv KV) Obj(path ...string) KV {
	cur := kv
	for _, key := range path {
		v, ok := lookup(cur, key)
		if !ok {
			return nil
		}
		next, ok := v.(KV)
		if !ok {
			return nil
		}
		cur = next
	}
	return cur
}

func lookup(kv KV, key string) (any, bool) {
	if v, ok := kv[key]; ok {
		return v, true
	}
	for k, v := range kv {
		if strings.EqualFold(k, key) {
			return v, true
		}
	}
	return nil, false
}

// Parse parses VDF text into a KV. The top-level KV maps the root key(s)
// to their objects (an ACF parses to {"AppState": {...}}). Junk lines
// outside the first root object (SteamCMD banners, progress lines) are
// skipped as long as they don't contain an unbalanced brace-quote mix
// that swallows real content — in practice SteamCMD noise never does.
func Parse(src string) (KV, error) {
	toks := tokenize(stripLeadingNoise(src))
	root := KV{}
	i := 0
	for i < len(toks) {
		// Expect: key, then either "{" (object) or value.
		if toks[i].kind == tokOpen || toks[i].kind == tokClose {
			// Stray brace at top level — skip it (tolerance).
			i++
			continue
		}
		key := toks[i].text
		i++
		if i >= len(toks) {
			break // trailing bare token (noise) — ignore
		}
		switch toks[i].kind {
		case tokOpen:
			obj, n, err := parseObject(toks, i+1)
			if err != nil {
				return nil, err
			}
			root[key] = obj
			i = n
		case tokStr:
			root[key] = toks[i].text
			i++
		default:
			i++ // key followed by "}" — noise, skip
		}
	}
	return root, nil
}

func parseObject(toks []token, i int) (KV, int, error) {
	obj := KV{}
	for i < len(toks) {
		switch toks[i].kind {
		case tokClose:
			return obj, i + 1, nil
		case tokOpen:
			return nil, i, fmt.Errorf("steamvdf: unexpected '{' without a key")
		}
		key := toks[i].text
		i++
		if i >= len(toks) {
			return nil, i, fmt.Errorf("steamvdf: truncated after key %q", key)
		}
		switch toks[i].kind {
		case tokOpen:
			sub, n, err := parseObject(toks, i+1)
			if err != nil {
				return nil, n, err
			}
			obj[key] = sub
			i = n
		case tokStr:
			obj[key] = toks[i].text
			i++
		case tokClose:
			// key with no value right before '}' — tolerate, drop key.
			return obj, i + 1, nil
		}
	}
	return nil, i, fmt.Errorf("steamvdf: unterminated object")
}

// stripLeadingNoise drops SteamCMD banner/progress lines that precede the
// actual VDF (e.g. "Loading Steam API...OK", "AppID : 294420, change
// number : …"). Those lines tokenize into stray strings that misalign
// key/value pairing. The real document starts at the first line that is
// a lone quoted key whose next non-blank line opens a brace.
func stripLeadingNoise(src string) string {
	lines := strings.Split(src, "\n")
	for i, line := range lines {
		t := strings.TrimSpace(line)
		if len(t) < 2 || t[0] != '"' || t[len(t)-1] != '"' || strings.Count(t, `"`) != 2 {
			continue
		}
		for j := i + 1; j < len(lines); j++ {
			nt := strings.TrimSpace(lines[j])
			if nt == "" {
				continue
			}
			if strings.HasPrefix(nt, "{") {
				return strings.Join(lines[i:], "\n")
			}
			break
		}
	}
	return src // no root-key/brace pattern found — parse as-is
}

type tokKind int

const (
	tokStr tokKind = iota
	tokOpen
	tokClose
)

type token struct {
	kind tokKind
	text string
}

// tokenize splits VDF text into quoted/unquoted strings and braces.
// Handles \" escapes inside quoted strings and skips // line comments.
func tokenize(src string) []token {
	var toks []token
	i, n := 0, len(src)
	for i < n {
		c := src[i]
		switch {
		case c == ' ' || c == '\t' || c == '\r' || c == '\n':
			i++
		case c == '/' && i+1 < n && src[i+1] == '/':
			for i < n && src[i] != '\n' {
				i++
			}
		case c == '{':
			toks = append(toks, token{kind: tokOpen})
			i++
		case c == '}':
			toks = append(toks, token{kind: tokClose})
			i++
		case c == '"':
			i++
			var b strings.Builder
			for i < n && src[i] != '"' {
				if src[i] == '\\' && i+1 < n {
					i++
				}
				b.WriteByte(src[i])
				i++
			}
			i++ // closing quote
			toks = append(toks, token{kind: tokStr, text: b.String()})
		default:
			start := i
			for i < n && !strings.ContainsRune(" \t\r\n{}\"", rune(src[i])) {
				i++
			}
			toks = append(toks, token{kind: tokStr, text: src[start:i]})
		}
	}
	return toks
}

// AppManifest is the subset of appmanifest_<appid>.acf the panel cares
// about for update checks.
type AppManifest struct {
	AppID   string
	BuildID string
	// BetaKey is the Steam branch the install came from ("" = public).
	// SteamCMD records it under UserConfig/betakey (and sometimes
	// MountedConfig/betakey after a finished install).
	BetaKey string
	Name    string
}

// ParseAppManifest extracts buildid + betakey from ACF text.
func ParseAppManifest(src string) (*AppManifest, error) {
	kv, err := Parse(src)
	if err != nil {
		return nil, err
	}
	app := kv.Obj("AppState")
	if app == nil {
		return nil, fmt.Errorf("steamvdf: no AppState object in manifest")
	}
	m := &AppManifest{
		AppID:   app.Get("appid"),
		BuildID: app.Get("buildid"),
		Name:    app.Get("name"),
	}
	if bk := app.Get("UserConfig", "betakey"); bk != "" {
		m.BetaKey = bk
	} else if bk := app.Get("MountedConfig", "betakey"); bk != "" {
		m.BetaKey = bk
	}
	if m.BuildID == "" {
		return m, fmt.Errorf("steamvdf: manifest has no buildid")
	}
	return m, nil
}

// ParseAppInfoBranches extracts the depots→branches map (branch name →
// buildid) from `steamcmd +app_info_print <appid>` output. The output
// has SteamCMD banner/progress noise around a root object keyed by the
// numeric appid; the tolerant Parse skips the noise.
func ParseAppInfoBranches(src, appID string) (map[string]string, error) {
	kv, err := Parse(src)
	if err != nil {
		return nil, err
	}
	app := kv.Obj(appID)
	if app == nil {
		// Some SteamCMD builds print the root without us knowing the key
		// (e.g. when the caller passed "" or output nests differently).
		// Fall back to the first object that has depots/branches.
		for _, v := range kv {
			if o, ok := v.(KV); ok && o.Obj("depots", "branches") != nil {
				app = o
				break
			}
		}
	}
	if app == nil {
		return nil, fmt.Errorf("steamvdf: app %s not found in app_info output", appID)
	}
	branches := app.Obj("depots", "branches")
	if branches == nil {
		return nil, fmt.Errorf("steamvdf: no depots.branches in app_info for %s", appID)
	}
	out := map[string]string{}
	for name, v := range branches {
		if o, ok := v.(KV); ok {
			if bid := o.Get("buildid"); bid != "" {
				out[name] = bid
			}
		}
	}
	if len(out) == 0 {
		return nil, fmt.Errorf("steamvdf: branches map empty for %s", appID)
	}
	return out, nil
}
