// Package llm — text helpers shared by the subprocess CLI providers.
//
// The `claudecli` and `codex` providers both shell out to an external
// coding-agent binary that has no native structured-output mechanism.
// They get the expand / rerank / verify / tool-call JSON shapes the
// same way: append a JSON-Schema instruction to the prompt, then
// extract the first balanced JSON value out of a possibly chatty
// response. That logic is provider-neutral, so it lives here once
// rather than being copied into each provider package.
package llm

import (
	"encoding/json"
	"strings"
	"unicode/utf8"
)

// AppendSchemaInstruction tacks a "respond with exactly this JSON
// shape" rider onto a system / instruction prompt. The CLI providers
// have no native structured-output flag, so this — paired with
// ExtractJSON on the response — is how the structured shapes are
// enforced. Returns the prompt unchanged for ShapeFreeform (or if the
// schema fails to marshal, which the hand-built schemas never do).
func AppendSchemaInstruction(prompt string, shape JSONShape, tools []ToolSpec) string {
	schema := JSONSchemaFor(shape, tools)
	if schema == nil {
		return prompt
	}
	raw, err := json.Marshal(schema)
	if err != nil {
		return prompt
	}
	rider := "Respond with a single JSON object that conforms exactly to this JSON Schema:\n" +
		string(raw) +
		"\nOutput ONLY the JSON object — no prose, no commentary, no markdown fences."
	if strings.TrimSpace(prompt) == "" {
		return rider
	}
	return prompt + "\n\n" + rider
}

// ExtractJSON pulls the first balanced JSON object or array out of
// text. CLI agents sometimes wrap responses in markdown fences or
// surround them with prose ("Sure, here you go:\n{...}"); this helper
// finds and verifies the JSON payload so the assist passes don't choke
// on chatty completions. ok is false when no valid JSON value is
// present.
func ExtractJSON(text string) (string, bool) {
	if c, ok := tryUnmarshalJSON(text); ok {
		return c, true
	}
	if stripped, changed := stripJSONFences(text); changed {
		if c, ok := tryUnmarshalJSON(stripped); ok {
			return c, true
		}
		text = stripped
	}
	// Scan for the first balanced JSON object/array.
	for i := 0; i < len(text); i++ {
		c := text[i]
		if c != '{' && c != '[' {
			continue
		}
		end, ok := balancedJSONEnd(text, i)
		if !ok {
			continue
		}
		candidate := text[i : end+1]
		if c, ok := tryUnmarshalJSON(candidate); ok {
			return c, true
		}
	}
	return "", false
}

// Snippet truncates a stderr / stdout blob for inclusion in an error
// message. Operates on runes so multi-byte characters at the cut point
// stay intact.
func Snippet(b []byte) string {
	const max = 300
	s := strings.TrimSpace(string(b))
	if utf8.RuneCountInString(s) <= max {
		return s
	}
	count := 0
	for i := range s {
		count++
		if count > max {
			return s[:i] + "…"
		}
	}
	return s
}

// stripJSONFences removes a single ``` or ```json wrapper.
func stripJSONFences(text string) (string, bool) {
	t := strings.TrimSpace(text)
	if !strings.HasPrefix(t, "```") {
		return text, false
	}
	_, body, ok := strings.Cut(t, "\n")
	if !ok {
		return text, false
	}
	if i := strings.LastIndex(body, "```"); i >= 0 {
		body = body[:i]
	}
	return strings.TrimSpace(body), true
}

// tryUnmarshalJSON returns the trimmed candidate if it parses as JSON.
func tryUnmarshalJSON(s string) (string, bool) {
	trimmed := strings.TrimSpace(s)
	if trimmed == "" {
		return "", false
	}
	var v any
	if err := json.Unmarshal([]byte(trimmed), &v); err != nil {
		return "", false
	}
	return trimmed, true
}

// balancedJSONEnd returns the index of the closing brace/bracket that
// balances the opener at start. Tracks string literals so quoted
// braces don't throw the depth count off.
func balancedJSONEnd(text string, start int) (int, bool) {
	open := text[start]
	var closer byte
	switch open {
	case '{':
		closer = '}'
	case '[':
		closer = ']'
	default:
		return 0, false
	}
	depth := 0
	inString := false
	escape := false
	for i := start; i < len(text); i++ {
		c := text[i]
		if inString {
			if escape {
				escape = false
				continue
			}
			switch c {
			case '\\':
				escape = true
			case '"':
				inString = false
			}
			continue
		}
		switch c {
		case '"':
			inString = true
		case open:
			depth++
		case closer:
			depth--
			if depth == 0 {
				return i, true
			}
		}
	}
	return 0, false
}
