// Package claudecli is the Claude Code CLI llm.Provider.
//
// It is pure Go — available in every build, no `-tags llama` needed.
// Inference is delegated to the user's locally installed `claude`
// binary, which reuses the user's Claude Code subscription instead of
// requiring an Anthropic API key. Each Complete call spawns one
// `claude -p` subprocess: the conversation is flattened to text, the
// system prompt is forwarded via --append-system-prompt, and the
// prompt text is fed on stdin so very large contexts don't trip
// ARG_MAX.
//
// Structured output (the expand / rerank / verify shapes and the
// agent tool-call shape) is obtained by appending a JSON-Schema
// instruction to the system prompt and parsing the first valid JSON
// object out of the response — the CLI has no native structured-
// output mechanism. That schema-rider + JSON-extraction logic is
// shared with the `codex` provider; it lives in llm.AppendSchema-
// Instruction / llm.ExtractJSON. The agent tool-loop itself uses the
// *emulated* protocol: tool calls and results travel as plain text
// turns, so a single llm.Message shape works across all providers.
package claudecli

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"os/exec"
	"strings"
	"time"

	"github.com/zzet/gortex/internal/llm"
)

// defaultTimeout caps one Complete call when the user hasn't set
// claudecli.timeout_seconds in config. Claude Code CLI startup plus
// one model round-trip is comfortably under 120s for the small
// prompts the assist/agent loop emits.
const defaultTimeout = 120 * time.Second

// Provider implements llm.Provider against the `claude` CLI.
type Provider struct {
	binary  string
	model   string
	extra   []string
	timeout time.Duration
}

var _ llm.Provider = (*Provider)(nil)

// New constructs the Claude CLI provider. It verifies the binary is
// reachable on $PATH (or as an absolute path) so misconfiguration
// surfaces at startup, not on the first Complete call.
func New(cfg llm.ClaudeCLIConfig) (llm.Provider, error) {
	bin := strings.TrimSpace(cfg.Binary)
	if bin == "" {
		bin = "claude"
	}
	resolved, err := exec.LookPath(bin)
	if err != nil {
		return nil, fmt.Errorf("claudecli: binary %q not found on PATH: %w", bin, err)
	}
	timeout := defaultTimeout
	if cfg.TimeoutSeconds > 0 {
		timeout = time.Duration(cfg.TimeoutSeconds) * time.Second
	}
	return &Provider{
		binary:  resolved,
		model:   strings.TrimSpace(cfg.Model),
		extra:   append([]string(nil), cfg.Args...),
		timeout: timeout,
	}, nil
}

// Name implements llm.Provider.
func (p *Provider) Name() string { return "claudecli" }

// Close is a no-op — every Complete spawns a fresh subprocess; there
// is no long-lived connection or model handle to release.
func (p *Provider) Close() error { return nil }

// Complete implements llm.Provider. It runs one `claude -p`
// subprocess: the system messages are joined and forwarded via
// --append-system-prompt, every other message is flattened into a
// chat-style prompt that is piped on stdin, and stdout is captured
// as the model's text. For structured shapes the schema is injected
// into the system prompt and the first balanced JSON object is
// extracted from the response.
func (p *Provider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.CompletionResponse, error) {
	system, prompt := flatten(req.Messages)
	structured := req.Shape != llm.ShapeFreeform
	if structured {
		system = llm.AppendSchemaInstruction(system, req.Shape, req.Tools)
	}

	args := []string{"--print", "--output-format", "text"}
	if p.model != "" {
		args = append(args, "--model", p.model)
	}
	// --max-turns pins the agent loop inside Claude Code to a single
	// turn — every llm.Provider caller assumes one single-shot
	// response. The per-response token cap (req.MaxTokens) is
	// best-effort: the CLI exposes no equivalent flag, so we lean on
	// the model's own behaviour given a short system prompt.
	args = append(args, "--max-turns", "1")
	if system != "" {
		args = append(args, "--append-system-prompt", system)
	}
	args = append(args, p.extra...)

	runCtx := ctx
	if p.timeout > 0 {
		var cancel context.CancelFunc
		runCtx, cancel = context.WithTimeout(ctx, p.timeout)
		defer cancel()
	}

	cmd := exec.CommandContext(runCtx, p.binary, args...)
	cmd.Stdin = strings.NewReader(prompt)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	if err := cmd.Run(); err != nil {
		// Distinguish a context-timeout from an exec failure so the
		// agent loop can log something meaningful.
		if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
			return llm.CompletionResponse{}, fmt.Errorf("claudecli: timed out after %s: %s", p.timeout, llm.Snippet(stderr.Bytes()))
		}
		if msg := llm.Snippet(stderr.Bytes()); msg != "" {
			return llm.CompletionResponse{}, fmt.Errorf("claudecli: %w: %s", err, msg)
		}
		return llm.CompletionResponse{}, fmt.Errorf("claudecli: %w", err)
	}

	text := strings.TrimSpace(stdout.String())
	if text == "" {
		return llm.CompletionResponse{}, errors.New("claudecli: empty response from CLI")
	}
	if structured {
		extracted, ok := llm.ExtractJSON(text)
		if !ok {
			return llm.CompletionResponse{}, fmt.Errorf("claudecli: response carried no JSON: %s", llm.Snippet([]byte(text)))
		}
		text = extracted
	}
	return llm.CompletionResponse{Text: text}, nil
}

// flatten splits the conversation into a system block (every
// RoleSystem message joined with a blank line) and a chat-style
// prompt (every other message rendered as "User:" / "Assistant:" /
// "Tool result:" turns). The CLI takes the system part via
// --append-system-prompt and reads the prompt part from stdin. Using
// stdin avoids the ARG_MAX ceiling on long contexts.
func flatten(in []llm.Message) (system, prompt string) {
	var sys []string
	var b strings.Builder
	turns := 0
	for _, m := range in {
		switch m.Role {
		case llm.RoleSystem:
			if s := strings.TrimSpace(m.Content); s != "" {
				sys = append(sys, s)
			}
		case llm.RoleAssistant:
			if turns > 0 {
				b.WriteString("\n\n")
			}
			b.WriteString("Assistant: ")
			b.WriteString(m.Content)
			turns++
		case llm.RoleTool:
			if turns > 0 {
				b.WriteString("\n\n")
			}
			b.WriteString(renderToolResult(m))
			turns++
		default:
			if turns > 0 {
				b.WriteString("\n\n")
			}
			b.WriteString("User: ")
			b.WriteString(m.Content)
			turns++
		}
	}
	return strings.Join(sys, "\n\n"), b.String()
}

func renderToolResult(m llm.Message) string {
	if m.ToolName != "" {
		return "Tool result (" + m.ToolName + "):\n" + m.Content
	}
	return "Tool result:\n" + m.Content
}
