package app

import (
	"encoding/json"
	"sort"
	"strings"

	"charm.land/lipgloss/v2"
)

// cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval
// parse. NO LLM: it shell-lexes the proposed command, classifies binaries against a
// known-command table, and mechanically flags the constructs that widen blast radius
// (sudo, rm -rf, pipe-to-shell, redirects outside the workspace, network binaries,
// base64/eval indicators). Unparseable input is itself a red flag, never a silent gap.
//
// It is pure and replay-stable: same preview + workspaceRoot → same analysis, no
// environment reads. The optional model annotation (§5.2) is deliberately NOT here —
// the spec says ship layer 1 and live with it first.

// segSeverity ranks a token's danger. The renderer maps these onto the T0–T4 tier
// ramp for preattentive severity (§8): plain → info → warn → danger.
type segSeverity int

const (
	segPlain  segSeverity = iota
	segInfo               // notable: a network-touching binary
	segWarn               // elevated: redirect outside workspace, base64/eval, bare pipe
	segDanger             // high: sudo, recursive delete, pipe-to-shell, unparseable
)

func maxSev(a, b segSeverity) segSeverity {
	if a > b {
		return a
	}
	return b
}

// cmdFlag is one mechanically-detected concern, rendered as a chip above the command.
type cmdFlag struct {
	label  string
	detail string
	sev    segSeverity
}

// cmdToken is one display token of the reconstructed command line, tagged with the
// worst severity it triggered so the renderer can color it.
type cmdToken struct {
	text string
	sev  segSeverity
}

// cmdAnalysis is the deterministic reading of a command approval: the reconstructed
// argv as severity-tagged tokens plus the deduped, worst-first flag list.
type cmdAnalysis struct {
	tokens []cmdToken
	flags  []cmdFlag
}

// worst returns the highest severity found anywhere in the analysis.
func (a cmdAnalysis) worst() segSeverity {
	w := segPlain
	for _, f := range a.flags {
		w = maxSev(w, f.sev)
	}
	return w
}

// addFlag inserts a flag, deduped by label (keeping the highest severity seen).
func (a *cmdAnalysis) addFlag(f cmdFlag) {
	for i := range a.flags {
		if a.flags[i].label == f.label {
			a.flags[i].sev = maxSev(a.flags[i].sev, f.sev)
			return
		}
	}
	a.flags = append(a.flags, f)
}

// --- known-command tables -------------------------------------------------------

// privilege escalators — running anything through one of these steps outside the
// sandbox's authority, so the command is flagged regardless of what it then runs.
var privilegeBins = map[string]bool{
	"sudo": true, "doas": true, "pkexec": true, "su": true,
}

// command wrappers that are transparent for "command position" — the token after
// them is still the effective command (so `sudo rm -rf` flags the rm, not just sudo).
var commandWrappers = map[string]bool{
	"sudo": true, "doas": true, "pkexec": true, "env": true, "nohup": true,
	"nice": true, "ionice": true, "time": true, "timeout": true, "xargs": true,
	"stdbuf": true, "setsid": true,
}

// shell interpreters — a `-c` payload here is re-tokenized so dangers hidden inside
// the script string (pipes, redirects, fetch-and-run) are still surfaced.
var shellBins = map[string]bool{
	"sh": true, "bash": true, "zsh": true, "dash": true, "ksh": true, "fish": true,
}

// network-touching binaries — move data across the trust boundary.
var networkBins = map[string]bool{
	"curl": true, "wget": true, "nc": true, "ncat": true, "netcat": true,
	"ssh": true, "scp": true, "sftp": true, "telnet": true, "ftp": true,
	"rsync": true, "socat": true,
}

// decode / arbitrary-exec primitives — common obfuscation building blocks.
var evalBins = map[string]bool{
	"eval": true, "base64": true, "xxd": true, "exec": true,
}

// separators that end a simple command within a tokenized line.
func isSeparator(tok string) bool {
	switch tok {
	case "|", "|&", "||", "&&", "&", ";":
		return true
	}
	return false
}

// --- analysis -------------------------------------------------------------------

// analyzeCommand is the entry point: it normalizes the approval preview into a
// command line, lexes it, and classifies every token. workspaceRoot scopes the
// "redirect outside workspace" check (empty → any absolute redirect is outside).
func analyzeCommand(preview, workspaceRoot string) cmdAnalysis {
	cmd := commandLineFromPreview(preview)
	tokens, ok := shellSplit(cmd)
	var a cmdAnalysis
	if len(tokens) == 0 {
		// Nothing tokenizable — surface the raw text verbatim, flagged. A command we
		// cannot read is more dangerous than one we can, not less.
		raw := strings.TrimSpace(preview)
		if raw == "" {
			raw = "(empty command)"
		}
		a.tokens = []cmdToken{{text: raw, sev: segDanger}}
		a.addFlag(cmdFlag{"unparseable", "command could not be tokenized", segDanger})
		return a
	}
	sev := make([]segSeverity, len(tokens))
	if !ok {
		a.addFlag(cmdFlag{"unparseable", "unterminated quote — argv is ambiguous", segDanger})
		sev[len(sev)-1] = segDanger
	}
	a.scan(tokens, sev, workspaceRoot)
	a.tokens = make([]cmdToken, len(tokens))
	for i, tk := range tokens {
		a.tokens[i] = cmdToken{text: tk, sev: sev[i]}
	}
	return a
}

// scan classifies tokens in place, raising sev[i] and appending flags. It runs on
// the top-level argv and recursively on a shell `-c` payload.
func (a *cmdAnalysis) scan(tokens []string, sev []segSeverity, workspaceRoot string) {
	for i, tk := range tokens {
		base := baseName(tk)
		cmdPos := isCommandPosition(tokens, i)

		switch {
		case cmdPos && privilegeBins[base]:
			sev[i] = maxSev(sev[i], segDanger)
			a.addFlag(cmdFlag{base, "runs with elevated privileges", segDanger})
		case cmdPos && networkBins[base]:
			sev[i] = maxSev(sev[i], segInfo)
			a.addFlag(cmdFlag{"network", base + " reaches across the network", segInfo})
		case cmdPos && evalBins[base]:
			sev[i] = maxSev(sev[i], segWarn)
			a.addFlag(cmdFlag{base, "decode/eval primitive", segWarn})
		}

		if cmdPos && base == "rm" && hasRecursiveDelete(tokens[i+1:]) {
			sev[i] = maxSev(sev[i], segDanger)
			a.addFlag(cmdFlag{"rm -rf", "recursive delete", segDanger})
		}

		switch {
		case tk == "|" || tk == "|&":
			// Resolve what the pipe feeds, skipping wrappers/flags so `| sudo sh`
			// and `| sh` both read as piping output straight into a shell.
			if j := effectiveCmdIndex(tokens, i+1); j >= 0 && shellBins[baseName(tokens[j])] {
				sev[i] = maxSev(sev[i], segDanger)
				sev[j] = maxSev(sev[j], segDanger)
				a.addFlag(cmdFlag{"pipe→shell", "output piped straight into a shell", segDanger})
			}
		case tk == ">" || tk == ">>":
			if i+1 < len(tokens) && isOutsideWorkspace(tokens[i+1], workspaceRoot) {
				sev[i] = maxSev(sev[i], segWarn)
				sev[i+1] = maxSev(sev[i+1], segWarn)
				a.addFlag(cmdFlag{"redirect", "writes outside the workspace: " + tokens[i+1], segWarn})
			}
		}

		if cmdPos && shellBins[base] {
			a.scanShellPayload(tokens, i, sev, workspaceRoot)
		}
	}
}

// scanShellPayload re-tokenizes a `<shell> -c <payload>` script and folds the worst
// severity it finds onto the payload token, so a fetch-and-run hidden in a quoted
// string still lights up the card.
func (a *cmdAnalysis) scanShellPayload(tokens []string, shellIdx int, sev []segSeverity, workspaceRoot string) {
	payload, idx := dashCPayload(tokens, shellIdx)
	if idx < 0 {
		return
	}
	inner, ok := shellSplit(payload)
	isev := make([]segSeverity, len(inner))
	a.scan(inner, isev, workspaceRoot)
	worst := segPlain
	for _, s := range isev {
		worst = maxSev(worst, s)
	}
	if !ok {
		worst = maxSev(worst, segDanger)
		a.addFlag(cmdFlag{"unparseable", "shell payload has an unterminated quote", segDanger})
	}
	sev[idx] = maxSev(sev[idx], worst)
}

// isCommandPosition reports whether tokens[i] is the program being invoked (vs an
// argument). True at the start, after a separator, or after a transparent wrapper.
func isCommandPosition(tokens []string, i int) bool {
	if i == 0 {
		return true
	}
	prev := tokens[i-1]
	return isSeparator(prev) || commandWrappers[baseName(prev)]
}

// effectiveCmdIndex returns the index of the program a pipe segment runs, skipping
// transparent wrappers (sudo, env, …) and their option flags. -1 if none before the
// next separator.
func effectiveCmdIndex(tokens []string, start int) int {
	for j := start; j < len(tokens); j++ {
		t := tokens[j]
		if isSeparator(t) {
			return -1
		}
		if commandWrappers[baseName(t)] || strings.HasPrefix(t, "-") {
			continue
		}
		return j
	}
	return -1
}

// hasRecursiveDelete reports whether the args to an rm (up to the next separator)
// request a recursive delete (-r/-R/--recursive, including bundled short flags).
func hasRecursiveDelete(args []string) bool {
	for _, a := range args {
		if isSeparator(a) {
			break
		}
		if a == "--recursive" {
			return true
		}
		if strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && strings.ContainsAny(a, "rR") {
			return true
		}
	}
	return false
}

// dashCPayload finds the `-c` script argument after a shell binary and returns it
// with its token index, or ("", -1) if there is none before the next separator.
func dashCPayload(tokens []string, shellIdx int) (string, int) {
	for j := shellIdx + 1; j < len(tokens); j++ {
		t := tokens[j]
		if isSeparator(t) {
			return "", -1
		}
		if t == "-c" || (strings.HasPrefix(t, "-") && !strings.HasPrefix(t, "--") && strings.Contains(t, "c")) {
			if j+1 < len(tokens) {
				return tokens[j+1], j + 1
			}
			return "", -1
		}
	}
	return "", -1
}

// isOutsideWorkspace reports whether a redirect target escapes the workspace. A
// relative path is assumed inside unless it climbs with ".."; an absolute path is
// outside unless it sits under root. /dev/null is always benign.
func isOutsideWorkspace(target, root string) bool {
	if target == "/dev/null" {
		return false
	}
	if strings.Contains(target, "..") {
		return true
	}
	if !strings.HasPrefix(target, "/") {
		return false
	}
	if root == "" {
		return true
	}
	root = strings.TrimSuffix(root, "/")
	return target != root && !strings.HasPrefix(target, root+"/")
}

// baseName strips any directory prefix so /usr/bin/curl classifies as curl.
func baseName(tok string) string {
	if i := strings.LastIndexByte(tok, '/'); i >= 0 {
		return tok[i+1:]
	}
	return tok
}

// --- lexing ---------------------------------------------------------------------

// commandLineFromPreview normalizes an approval preview to a command line. The
// shell tool's preview may arrive as the structured `{"argv":[...]}` form (older
// servers / replay) or as an already-rendered command line (current server); both
// resolve to the same string here.
func commandLineFromPreview(preview string) string {
	s := strings.TrimSpace(preview)
	if strings.HasPrefix(s, "{") {
		var obj struct {
			Argv []string `json:"argv"`
		}
		if err := json.Unmarshal([]byte(s), &obj); err == nil && len(obj.Argv) > 0 {
			return shellJoin(obj.Argv)
		}
	}
	return s
}

// shellSplit is a minimal POSIX-ish lexer: it splits on unquoted whitespace, honors
// single/double quotes and backslash escapes, and emits control operators (| || |&
// & && ; > >> <) as their own tokens even when not space-separated. ok is false on
// an unterminated quote — the caller treats that as a danger, not a parse to retry.
func shellSplit(s string) (tokens []string, ok bool) {
	var cur strings.Builder
	hasCur := false
	flush := func() {
		if hasCur {
			tokens = append(tokens, cur.String())
			cur.Reset()
			hasCur = false
		}
	}
	runes := []rune(s)
	for i := 0; i < len(runes); {
		c := runes[i]
		switch {
		case c == '\'':
			hasCur = true
			i++
			for i < len(runes) && runes[i] != '\'' {
				cur.WriteRune(runes[i])
				i++
			}
			if i >= len(runes) {
				flush()
				return tokens, false
			}
			i++
		case c == '"':
			hasCur = true
			i++
			for i < len(runes) && runes[i] != '"' {
				if runes[i] == '\\' && i+1 < len(runes) {
					i++
				}
				cur.WriteRune(runes[i])
				i++
			}
			if i >= len(runes) {
				flush()
				return tokens, false
			}
			i++
		case c == ' ' || c == '\t' || c == '\n' || c == '\r':
			flush()
			i++
		case c == '|' || c == '&' || c == ';' || c == '>' || c == '<':
			flush()
			op := string(c)
			if i+1 < len(runes) {
				switch two := string(c) + string(runes[i+1]); two {
				case "||", "&&", ">>", "|&":
					op, i = two, i+1
				}
			}
			tokens = append(tokens, op)
			i++
		case c == '\\' && i+1 < len(runes):
			hasCur = true
			cur.WriteRune(runes[i+1])
			i += 2
		default:
			hasCur = true
			cur.WriteRune(c)
			i++
		}
	}
	flush()
	return tokens, true
}

// shellJoin renders argv as a command line, single-quoting any token that needs it.
func shellJoin(argv []string) string {
	parts := make([]string, len(argv))
	for i, a := range argv {
		parts[i] = shellQuote(a)
	}
	return strings.Join(parts, " ")
}

const shellMeta = " \t\n'\"\\|&;<>(){}$`*?[]~#!"

// shellQuote wraps a token in single quotes when it contains whitespace or shell
// metacharacters, so the reconstructed line is unambiguous to read.
func shellQuote(tok string) string {
	if tok == "" {
		return "''"
	}
	if !strings.ContainsAny(tok, shellMeta) {
		return tok
	}
	return "'" + strings.ReplaceAll(tok, "'", `'\''`) + "'"
}

// sortedFlags returns the flags worst-first (stable within a severity) for chips.
func sortedFlags(flags []cmdFlag) []cmdFlag {
	out := make([]cmdFlag, len(flags))
	copy(out, flags)
	sort.SliceStable(out, func(i, j int) bool { return out[i].sev > out[j].sev })
	return out
}

// --- rendering ------------------------------------------------------------------

// isCommandApproval reports whether an approval should render as a command card
// rather than a diff/plain preview. The shell tool is the canonical producer; the
// JSON-argv shape is a defensive fallback for replayed/older previews.
func isCommandApproval(a *Approval) bool {
	if a == nil {
		return false
	}
	if a.ToolName == "shell" {
		return true
	}
	s := strings.TrimSpace(a.Preview)
	return strings.HasPrefix(s, "{") && strings.Contains(s, `"argv"`)
}

// pendingCommand returns the selected session's pending approval iff it is a
// command (so the fullscreen ^x view can render the card instead of a flat diff).
func (m Model) pendingCommand() *Approval {
	if s := m.session(m.selectedID); s != nil && s.Pending != nil && isCommandApproval(s.Pending) {
		return s.Pending
	}
	return nil
}

// commandCardLines is the rendered body of a command approval: a worst-first row
// of severity chips, a blank, then the reconstructed command line with each token
// colored by its flag severity. width is the inner content width. The full slice is
// used both for sizing the band and (windowed) for the ^x fullscreen view, so the
// height math and the render never disagree.
func (m Model) commandCardLines(a *Approval, width int) []string {
	if width < 8 {
		width = 8
	}
	ws := ""
	if s := m.session(a.SessionID); s != nil {
		ws = s.WorkspaceRoot
	}
	an := analyzeCommand(a.Preview, ws)
	lines := m.chipLines(an.flags, width)
	if len(lines) > 0 {
		lines = append(lines, "")
	}
	return append(lines, m.commandLines(an.tokens, width)...)
}

// chipLines renders the flag chips, wrapped to width. With no flags it states the
// deterministic parse came back clean (so a quiet card reads as "checked", not "TODO").
func (m Model) chipLines(flags []cmdFlag, width int) []string {
	t := m.theme
	if len(flags) == 0 {
		return []string{t.span("✓ deterministic parse — nothing notable flagged", t.P.OK)}
	}
	chips := make([]string, 0, len(flags))
	for _, f := range sortedFlags(flags) {
		chips = append(chips, m.chip(f))
	}
	gap := t.span("  ", t.P.Bg)
	return wrapStyled(chips, gap, 2, width)
}

// chip renders one flag as a colored glyph+label badge (▲ for warn/danger, ● for info).
func (m Model) chip(f cmdFlag) string {
	t := m.theme
	glyph, fg := "●", t.P.Accent2
	switch f.sev {
	case segDanger:
		glyph, fg = "▲", t.P.Bad
	case segWarn:
		glyph, fg = "▲", t.P.Warn
	}
	return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Bold(f.sev == segDanger).Render(glyph + " " + f.label)
}

// commandLines lays out the reconstructed command line with a `$ ` prompt, wrapping
// to width with a two-space continuation indent. Each token is shell-quoted for an
// unambiguous read and colored by its severity. Overlong tokens are hard-split so
// the full command always survives (§3 — never truncate the command being approved).
func (m Model) commandLines(tokens []cmdToken, width int) []string {
	t := m.theme
	space := t.span(" ", t.P.Bg)
	indent := t.span("  ", t.P.Bg)
	prompt := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render("$ ")

	type seg struct {
		text string
		sev  segSeverity
	}
	var segs []seg
	for _, tk := range tokens {
		for _, chunk := range wrapLine(shellQuote(tk.text), width-2) {
			segs = append(segs, seg{chunk, tk.sev})
		}
	}

	lines := make([]string, 0, 2)
	cur, curW, atStart := prompt, 2, true
	for _, s := range segs {
		w := len([]rune(s.text))
		need := w
		if !atStart {
			need = w + 1
		}
		if !atStart && curW+need > width {
			lines = append(lines, cur)
			cur, curW, atStart = indent, 2, true
		}
		if !atStart {
			cur += space
			curW++
		}
		cur += m.cmdSpan(s.text, s.sev)
		curW += w
		atStart = false
	}
	return append(lines, cur)
}

// cmdSpan colors a command token by its severity (T0–T4 ramp reuse, §8).
func (m Model) cmdSpan(s string, sev segSeverity) string {
	t := m.theme
	switch sev {
	case segDanger:
		return lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Bold(true).Render(s)
	case segWarn:
		return lipgloss.NewStyle().Foreground(t.P.Warn).Background(t.P.Bg).Render(s)
	case segInfo:
		return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(s)
	default:
		return t.span(s, t.P.FgStrong)
	}
}

// wrapStyled packs already-styled items onto lines no wider than width, separated by
// gap (visible width gapW), starting a new line when the next item would overflow.
func wrapStyled(items []string, gap string, gapW, width int) []string {
	var lines []string
	cur, curW := "", 0
	for _, it := range items {
		w := lipgloss.Width(it)
		if cur == "" {
			cur, curW = it, w
			continue
		}
		if curW+gapW+w > width {
			lines = append(lines, cur)
			cur, curW = it, w
			continue
		}
		cur += gap + it
		curW += gapW + w
	}
	if cur != "" {
		lines = append(lines, cur)
	}
	return lines
}
