package compat

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"regexp"
	"slices"
	"strings"
	"time"

	thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)

const metadataSystemPrompt = `You extract structured metadata from short notes.
Return only valid JSON matching this schema:
{
  "people": ["string"],
  "action_items": ["string"],
  "dates_mentioned": ["string"],
  "topics": ["string"],
  "type": "observation|task|idea|reference|person_note",
  "source": "string"
}
Rules:
- Keep arrays concise.
- Use lowercase for type.
- If unsure, prefer "observation".
- Do not include any text outside the JSON object.`

// Client is a low-level OpenAI-compatible HTTP client. It knows nothing about
// role chains, fallbacks, or health — those concerns belong to ai.Runner. Each
// method takes the model name per-call so a single Client instance can service
// many different models on the same base URL.
type Client struct {
	name       string
	baseURL    string
	apiKey     string
	headers    map[string]string
	httpClient *http.Client
	log        *slog.Logger
}

type Config struct {
	Name       string
	BaseURL    string
	APIKey     string
	Headers    map[string]string
	HTTPClient *http.Client
	Log        *slog.Logger
}

// MetadataOptions control a single ExtractMetadataWith call.
type MetadataOptions struct {
	Model            string
	Temperature      float64
	LogConversations bool
}

// SummarizeOptions control a single SummarizeWith call.
type SummarizeOptions struct {
	Model       string
	Temperature float64
}

type embeddingsRequest struct {
	Input string `json:"input"`
	Model string `json:"model"`
}

type embeddingsResponse struct {
	Data []struct {
		Embedding []float32 `json:"embedding"`
	} `json:"data"`
	Error *providerError `json:"error,omitempty"`
}

type chatCompletionsRequest struct {
	Model          string        `json:"model"`
	Temperature    float64       `json:"temperature,omitempty"`
	ResponseFormat *responseType `json:"response_format,omitempty"`
	Stream         *bool         `json:"stream,omitempty"`
	Messages       []chatMessage `json:"messages"`
}

type responseType struct {
	Type string `json:"type"`
}

type chatMessage struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type chatCompletionsResponse struct {
	Choices []struct {
		Message responseChatMessage `json:"message"`
		Text    string              `json:"text,omitempty"`
	} `json:"choices"`
	Error *providerError `json:"error,omitempty"`
}

type chatCompletionsChunk struct {
	Choices []struct {
		Delta   responseChatMessage `json:"delta"`
		Message responseChatMessage `json:"message"`
		Text    string              `json:"text,omitempty"`
	} `json:"choices"`
	Error *providerError `json:"error,omitempty"`
}

type responseChatMessage struct {
	Role             string          `json:"role"`
	Content          json.RawMessage `json:"content"`
	ReasoningContent string          `json:"reasoning_content,omitempty"`
}

type providerError struct {
	Message string `json:"message"`
	Type    string `json:"type,omitempty"`
}

const maxMetadataAttempts = 3

// ErrEmptyResponse and ErrNoJSONObject are sentinel errors callers can inspect
// to classify metadata failures (e.g. bump empty-response health counters).
var (
	ErrEmptyResponse = errors.New("metadata empty response")
	ErrNoJSONObject  = errors.New("metadata response contains no JSON object")
)

func New(cfg Config) *Client {
	return &Client{
		name:       cfg.Name,
		baseURL:    cfg.BaseURL,
		apiKey:     cfg.APIKey,
		headers:    cfg.Headers,
		httpClient: cfg.HTTPClient,
		log:        cfg.Log,
	}
}

func (c *Client) Name() string { return c.name }

// EmbedWith generates an embedding for the given input using model.
func (c *Client) EmbedWith(ctx context.Context, model, input string) ([]float32, error) {
	input = strings.TrimSpace(input)
	if input == "" {
		return nil, fmt.Errorf("%s embed: input must not be empty", c.name)
	}
	if strings.TrimSpace(model) == "" {
		return nil, fmt.Errorf("%s embed: model is required", c.name)
	}

	var resp embeddingsResponse
	err := c.doJSON(ctx, "/embeddings", embeddingsRequest{Input: input, Model: model}, &resp)
	if err != nil {
		return nil, err
	}
	if resp.Error != nil {
		return nil, fmt.Errorf("%s embed error: %s", c.name, resp.Error.Message)
	}
	if len(resp.Data) == 0 {
		return nil, fmt.Errorf("%s embed: no embedding returned", c.name)
	}
	return resp.Data[0].Embedding, nil
}

// ExtractMetadataWith extracts structured metadata for input using opts.Model.
// Returns compat.ErrEmptyResponse / ErrNoJSONObject wrapped when the model
// produces unusable output so callers can classify the failure.
func (c *Client) ExtractMetadataWith(ctx context.Context, opts MetadataOptions, input string) (thoughttypes.ThoughtMetadata, error) {
	input = strings.TrimSpace(input)
	if input == "" {
		return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s extract metadata: input must not be empty", c.name)
	}
	if strings.TrimSpace(opts.Model) == "" {
		return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s extract metadata: model is required", c.name)
	}

	stream := true
	req := chatCompletionsRequest{
		Model:          opts.Model,
		Temperature:    opts.Temperature,
		ResponseFormat: &responseType{Type: "json_object"},
		Stream:         &stream,
		Messages: []chatMessage{
			{Role: "system", Content: metadataSystemPrompt},
			{Role: "user", Content: input},
		},
	}

	metadata, err := c.extractMetadataWithRequest(ctx, req, input, opts)
	if err == nil || !shouldRetryWithoutJSONMode(err) {
		return metadata, err
	}

	if c.log != nil {
		c.log.Warn("metadata json mode failed, retrying without response_format",
			slog.String("provider", c.name),
			slog.String("model", opts.Model),
			slog.String("error", err.Error()),
		)
	}

	req.ResponseFormat = nil
	return c.extractMetadataWithRequest(ctx, req, input, opts)
}

func (c *Client) extractMetadataWithRequest(ctx context.Context, req chatCompletionsRequest, input string, opts MetadataOptions) (thoughttypes.ThoughtMetadata, error) {
	var lastErr error
	for attempt := 1; attempt <= maxMetadataAttempts; attempt++ {
		if opts.LogConversations && c.log != nil {
			c.log.Info("metadata conversation request",
				slog.String("provider", c.name),
				slog.String("model", opts.Model),
				slog.Int("attempt", attempt),
				slog.String("system", metadataSystemPrompt),
				slog.String("input", input),
			)
		}

		resp, err := c.doChatCompletions(ctx, req)
		if err != nil {
			return thoughttypes.ThoughtMetadata{}, err
		}
		if resp.Error != nil {
			return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata error: %s", c.name, resp.Error.Message)
		}
		if len(resp.Choices) == 0 {
			return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: no choices returned", c.name)
		}

		rawResponse := extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text)

		if opts.LogConversations && c.log != nil {
			c.log.Info("metadata conversation response",
				slog.String("provider", c.name),
				slog.String("model", opts.Model),
				slog.Int("attempt", attempt),
				slog.String("response", rawResponse),
			)
		}

		metadataText := strings.TrimSpace(rawResponse)
		metadataText = stripThinkingBlocks(metadataText)
		metadataText = stripCodeFence(metadataText)
		metadataText = extractJSONObject(metadataText)
		if metadataText == "" {
			lastErr = fmt.Errorf("%s metadata: %w", c.name, ErrNoJSONObject)
			if strings.TrimSpace(rawResponse) == "" && attempt < maxMetadataAttempts && ctx.Err() == nil {
				lastErr = fmt.Errorf("%s metadata: %w", c.name, ErrEmptyResponse)
				if c.log != nil {
					c.log.Warn("metadata response empty, waiting and retrying",
						slog.String("provider", c.name),
						slog.String("model", opts.Model),
						slog.Int("attempt", attempt+1),
					)
				}
				if err := sleepMetadataRetry(ctx, attempt); err != nil {
					return thoughttypes.ThoughtMetadata{}, err
				}
				continue
			}
			if strings.TrimSpace(rawResponse) == "" {
				lastErr = fmt.Errorf("%s metadata: %w", c.name, ErrEmptyResponse)
			}
			return thoughttypes.ThoughtMetadata{}, lastErr
		}

		var metadata thoughttypes.ThoughtMetadata
		if err := json.Unmarshal([]byte(metadataText), &metadata); err != nil {
			lastErr = fmt.Errorf("%s metadata: parse json: %w", c.name, err)
			return thoughttypes.ThoughtMetadata{}, lastErr
		}

		return metadata, nil
	}

	if lastErr != nil {
		return thoughttypes.ThoughtMetadata{}, lastErr
	}
	return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: %w", c.name, ErrNoJSONObject)
}

// SummarizeWith runs a chat-completion summarisation using opts.Model.
func (c *Client) SummarizeWith(ctx context.Context, opts SummarizeOptions, systemPrompt, userPrompt string) (string, error) {
	if strings.TrimSpace(opts.Model) == "" {
		return "", fmt.Errorf("%s summarize: model is required", c.name)
	}
	req := chatCompletionsRequest{
		Model:       opts.Model,
		Temperature: opts.Temperature,
		Messages: []chatMessage{
			{Role: "system", Content: systemPrompt},
			{Role: "user", Content: userPrompt},
		},
	}

	var resp chatCompletionsResponse
	if err := c.doJSON(ctx, "/chat/completions", req, &resp); err != nil {
		return "", err
	}
	if resp.Error != nil {
		return "", fmt.Errorf("%s summarize error: %s", c.name, resp.Error.Message)
	}
	if len(resp.Choices) == 0 {
		return "", fmt.Errorf("%s summarize: no choices returned", c.name)
	}

	return extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text), nil
}

// IsPermanentModelError reports whether err indicates the model itself is
// invalid or missing (vs. a transient outage). Runners use this to mark a
// target unhealthy for longer.
func IsPermanentModelError(err error) bool {
	if err == nil {
		return false
	}
	lower := strings.ToLower(err.Error())
	for _, marker := range []string{
		"invalid model name",
		"model_not_found",
		"model not found",
		"unknown model",
		"no such model",
		"does not exist",
	} {
		if strings.Contains(lower, marker) {
			return true
		}
	}
	return false
}

// HeuristicMetadataFromInput produces best-effort metadata from the note text
// when every model in the chain has failed. Exported so ai.Runner can use it.
func HeuristicMetadataFromInput(input string) thoughttypes.ThoughtMetadata {
	text := strings.TrimSpace(input)
	lower := strings.ToLower(text)

	metadata := thoughttypes.ThoughtMetadata{
		People:         heuristicPeople(text),
		ActionItems:    heuristicActionItems(text),
		DatesMentioned: heuristicDates(text),
		Topics:         heuristicTopics(lower),
		Type:           heuristicType(lower),
	}
	if len(metadata.Topics) == 0 {
		metadata.Topics = []string{"uncategorized"}
	}
	if metadata.Type == "" {
		metadata.Type = "observation"
	}
	return metadata
}

func (c *Client) doJSON(ctx context.Context, path string, requestBody any, dest any) error {
	body, err := json.Marshal(requestBody)
	if err != nil {
		return fmt.Errorf("%s request marshal: %w", c.name, err)
	}

	const maxAttempts = 3

	var lastErr error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.baseURL, "/")+path, bytes.NewReader(body))
		if err != nil {
			return fmt.Errorf("%s build request: %w", c.name, err)
		}

		req.Header.Set("Authorization", "Bearer "+c.apiKey)
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set("Accept", "application/json")
		for key, value := range c.headers {
			if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" {
				continue
			}
			req.Header.Set(key, value)
		}

		resp, err := c.httpClient.Do(req)
		if err != nil {
			lastErr = fmt.Errorf("%s request failed: %w", c.name, err)
			if attempt < maxAttempts && ctx.Err() == nil && isRetryableError(err) {
				if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil {
					return retryErr
				}
				continue
			}
			return lastErr
		}

		payload, readErr := io.ReadAll(resp.Body)
		_ = resp.Body.Close()
		if readErr != nil {
			lastErr = fmt.Errorf("%s read response: %w", c.name, readErr)
			if attempt < maxAttempts {
				if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil {
					return retryErr
				}
				continue
			}
			return lastErr
		}

		if resp.StatusCode >= http.StatusBadRequest {
			lastErr = fmt.Errorf("%s request failed with status %d: %s", c.name, resp.StatusCode, strings.TrimSpace(string(payload)))
			if attempt < maxAttempts && isRetryableStatus(resp.StatusCode) {
				if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil {
					return retryErr
				}
				continue
			}
			return lastErr
		}

		if err := json.Unmarshal(payload, dest); err != nil {
			if c.log != nil {
				c.log.Debug("provider response body", slog.String("provider", c.name), slog.String("body", string(payload)))
			}
			return fmt.Errorf("%s decode response: %w", c.name, err)
		}

		return nil
	}

	return lastErr
}

func (c *Client) doChatCompletions(ctx context.Context, reqBody chatCompletionsRequest) (chatCompletionsResponse, error) {
	var resp chatCompletionsResponse

	body, err := json.Marshal(reqBody)
	if err != nil {
		return resp, fmt.Errorf("%s request marshal: %w", c.name, err)
	}

	const maxAttempts = 3

	var lastErr error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.baseURL, "/")+"/chat/completions", bytes.NewReader(body))
		if err != nil {
			return resp, fmt.Errorf("%s build request: %w", c.name, err)
		}

		req.Header.Set("Authorization", "Bearer "+c.apiKey)
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set("Accept", "application/json, text/event-stream")
		for key, value := range c.headers {
			if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" {
				continue
			}
			req.Header.Set(key, value)
		}

		httpResp, err := c.httpClient.Do(req)
		if err != nil {
			lastErr = fmt.Errorf("%s request failed: %w", c.name, err)
			if attempt < maxAttempts && ctx.Err() == nil && isRetryableError(err) {
				if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil {
					return resp, retryErr
				}
				continue
			}
			return resp, lastErr
		}

		resp, err = c.decodeChatCompletionsResponse(httpResp)
		if err == nil {
			return resp, nil
		}

		lastErr = err
		if attempt < maxAttempts && (ctx.Err() == nil) && isRetryableChatResponseError(err) {
			if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil {
				return resp, retryErr
			}
			continue
		}
		return resp, lastErr
	}

	return resp, lastErr
}

func (c *Client) decodeChatCompletionsResponse(resp *http.Response) (chatCompletionsResponse, error) {
	defer func() {
		_ = resp.Body.Close()
	}()

	contentType := strings.ToLower(resp.Header.Get("Content-Type"))
	if strings.Contains(contentType, "text/event-stream") {
		streamResp, err := decodeStreamingChatCompletionsResponse(resp.Body)
		if err != nil {
			return chatCompletionsResponse{}, fmt.Errorf("%s read stream response: %w", c.name, err)
		}
		if resp.StatusCode >= http.StatusBadRequest {
			if streamResp.Error != nil {
				return chatCompletionsResponse{}, fmt.Errorf("%s request failed with status %d: %s", c.name, resp.StatusCode, streamResp.Error.Message)
			}
			return chatCompletionsResponse{}, fmt.Errorf("%s request failed with status %d", c.name, resp.StatusCode)
		}
		return streamResp, nil
	}

	payload, readErr := io.ReadAll(resp.Body)
	if readErr != nil {
		return chatCompletionsResponse{}, fmt.Errorf("%s read response: %w", c.name, readErr)
	}
	if resp.StatusCode >= http.StatusBadRequest {
		return chatCompletionsResponse{}, fmt.Errorf("%s request failed with status %d: %s", c.name, resp.StatusCode, strings.TrimSpace(string(payload)))
	}
	var decoded chatCompletionsResponse
	if err := json.Unmarshal(payload, &decoded); err != nil {
		if c.log != nil {
			c.log.Debug("provider response body", slog.String("provider", c.name), slog.String("body", string(payload)))
		}
		return chatCompletionsResponse{}, fmt.Errorf("%s decode response: %w", c.name, err)
	}
	return decoded, nil
}

func decodeStreamingChatCompletionsResponse(body io.Reader) (chatCompletionsResponse, error) {
	scanner := bufio.NewScanner(body)
	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)

	var eventLines []string
	var textBuilder strings.Builder
	var lastMessage responseChatMessage
	var streamErr *providerError

	flushEvent := func() error {
		if len(eventLines) == 0 {
			return nil
		}
		payload := strings.Join(eventLines, "\n")
		eventLines = eventLines[:0]

		payload = strings.TrimSpace(payload)
		if payload == "" {
			return nil
		}
		if payload == "[DONE]" {
			return nil
		}

		var chunk chatCompletionsChunk
		if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
			return err
		}
		if chunk.Error != nil {
			streamErr = chunk.Error
		}
		for _, choice := range chunk.Choices {
			if text := extractChoiceText(choice.Delta, choice.Text); text != "" {
				textBuilder.WriteString(text)
				lastMessage = choice.Delta
				continue
			}
			if text := extractChoiceText(choice.Message, choice.Text); text != "" {
				textBuilder.WriteString(text)
				lastMessage = choice.Message
				continue
			}
			if len(choice.Message.Content) > 0 || choice.Message.ReasoningContent != "" {
				lastMessage = choice.Message
			}
		}
		return nil
	}

	for scanner.Scan() {
		line := scanner.Text()
		if line == "" {
			if err := flushEvent(); err != nil {
				return chatCompletionsResponse{}, err
			}
			continue
		}
		if strings.HasPrefix(line, ":") {
			continue
		}
		if strings.HasPrefix(line, "data:") {
			eventLines = append(eventLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
		}
	}
	if err := scanner.Err(); err != nil {
		return chatCompletionsResponse{}, err
	}
	if err := flushEvent(); err != nil {
		return chatCompletionsResponse{}, err
	}

	content := textBuilder.String()
	if content != "" || len(lastMessage.Content) > 0 || lastMessage.ReasoningContent != "" {
		encoded, _ := json.Marshal(content)
		lastMessage.Content = json.RawMessage(encoded)
		return chatCompletionsResponse{
			Choices: []struct {
				Message responseChatMessage `json:"message"`
				Text    string              `json:"text,omitempty"`
			}{
				{Message: lastMessage, Text: content},
			},
			Error: streamErr,
		}, nil
	}

	return chatCompletionsResponse{Error: streamErr}, nil
}

func isRetryableChatResponseError(err error) bool {
	if err == nil {
		return false
	}
	if isRetryableError(err) {
		return true
	}

	lower := strings.ToLower(err.Error())
	return strings.Contains(lower, "read response") || strings.Contains(lower, "read stream response")
}

func extractJSONObject(s string) string {
	for start := 0; start < len(s); start++ {
		if s[start] != '{' {
			continue
		}
		depth := 0
		inString := false
		escaped := false
		for end := start; end < len(s); end++ {
			ch := s[end]
			if escaped {
				escaped = false
				continue
			}
			if ch == '\\' && inString {
				escaped = true
				continue
			}
			if ch == '"' {
				inString = !inString
				continue
			}
			if inString {
				continue
			}
			switch ch {
			case '{':
				depth++
			case '}':
				depth--
				if depth == 0 {
					candidate := s[start : end+1]
					if json.Valid([]byte(candidate)) {
						return candidate
					}
				}
			}
		}
	}
	return ""
}

func stripThinkingBlocks(s string) string {
	for _, tag := range []string{"think", "thinking"} {
		open := "<" + tag + ">"
		close := "</" + tag + ">"
		for {
			start := strings.Index(s, open)
			if start == -1 {
				break
			}
			end := strings.Index(s[start:], close)
			if end == -1 {
				s = s[:start]
				break
			}
			s = s[:start] + s[start+end+len(close):]
		}
	}
	return strings.TrimSpace(s)
}

func stripCodeFence(value string) string {
	value = strings.TrimSpace(value)
	if !strings.HasPrefix(value, "```") {
		return value
	}

	value = strings.TrimPrefix(value, "```json")
	value = strings.TrimPrefix(value, "```")
	value = strings.TrimSuffix(value, "```")
	return strings.TrimSpace(value)
}

func extractChoiceText(message responseChatMessage, fallbackText string) string {
	if text := strings.TrimSpace(extractMessageContent(message.Content)); text != "" {
		return text
	}
	if text := strings.TrimSpace(message.ReasoningContent); text != "" {
		return text
	}
	return strings.TrimSpace(fallbackText)
}

func extractMessageContent(raw json.RawMessage) string {
	raw = bytes.TrimSpace(raw)
	if len(raw) == 0 {
		return ""
	}

	var contentString string
	if err := json.Unmarshal(raw, &contentString); err == nil {
		return contentString
	}

	var contentArray []any
	if err := json.Unmarshal(raw, &contentArray); err == nil {
		parts := make([]string, 0, len(contentArray))
		for _, item := range contentArray {
			if text := strings.TrimSpace(extractTextFromAny(item)); text != "" {
				parts = append(parts, text)
			}
		}
		return strings.Join(parts, "\n")
	}

	var contentObject map[string]any
	if err := json.Unmarshal(raw, &contentObject); err == nil {
		return extractTextFromAny(contentObject)
	}

	return strings.TrimSpace(string(raw))
}

func extractTextFromAny(value any) string {
	switch typed := value.(type) {
	case string:
		return typed
	case []any:
		parts := make([]string, 0, len(typed))
		for _, item := range typed {
			if text := strings.TrimSpace(extractTextFromAny(item)); text != "" {
				parts = append(parts, text)
			}
		}
		return strings.Join(parts, "\n")
	case map[string]any:
		for _, key := range []string{"text", "output_text", "content", "value"} {
			if nested, ok := typed[key]; ok {
				if text := strings.TrimSpace(extractTextFromAny(nested)); text != "" {
					return text
				}
			}
		}
	}
	return ""
}

var (
	monthDatePattern = regexp.MustCompile(`(?i)\b\d{1,2}\s+(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{4}\b`)
	isoDatePattern   = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}\b`)
	wordPattern      = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_/-]{2,}`)
)

func heuristicType(lower string) string {
	switch {
	case strings.Contains(lower, "preferred name"), strings.Contains(lower, "personal profile"), strings.Contains(lower, "wife:"), strings.Contains(lower, "daughter:"), strings.Contains(lower, "born:"):
		return "person_note"
	case strings.Contains(lower, "todo"), strings.Contains(lower, "action item"), strings.Contains(lower, "need to"), strings.Contains(lower, "must "), strings.Contains(lower, "should "):
		return "task"
	case strings.Contains(lower, "idea"), strings.Contains(lower, "proposal"), strings.Contains(lower, "brainstorm"):
		return "idea"
	case strings.Contains(lower, "reference"), strings.Contains(lower, "rfc "), strings.Contains(lower, "docs"), strings.Contains(lower, "spec"):
		return "reference"
	default:
		return "observation"
	}
}

func heuristicTopics(lower string) []string {
	candidates := []string{
		"mcp", "auth", "oauth", "api_keys", "token", "middleware", "postgres", "search", "embeddings", "metadata",
		"go", "server", "project", "memory", "claude", "automation", "calendar", "email", "atlassian", "n8n",
	}

	topics := make([]string, 0, 6)
	for _, topic := range candidates {
		if strings.Contains(lower, topic) {
			topics = append(topics, topic)
		}
		if len(topics) >= 6 {
			break
		}
	}

	if len(topics) > 0 {
		return topics
	}

	words := wordPattern.FindAllString(lower, -1)
	for _, w := range words {
		if len(w) < 4 {
			continue
		}
		if slices.Contains(topics, w) {
			continue
		}
		topics = append(topics, w)
		if len(topics) >= 4 {
			break
		}
	}
	return topics
}

func heuristicDates(text string) []string {
	values := make([]string, 0, 4)
	seen := map[string]struct{}{}

	for _, match := range monthDatePattern.FindAllString(text, -1) {
		key := strings.ToLower(match)
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		values = append(values, match)
	}

	for _, match := range isoDatePattern.FindAllString(text, -1) {
		key := strings.ToLower(match)
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		values = append(values, match)
	}

	return values
}

func heuristicPeople(text string) []string {
	lines := strings.Split(text, "\n")
	people := make([]string, 0, 4)
	seen := map[string]struct{}{}

	add := func(name string) {
		name = strings.TrimSpace(name)
		if name == "" {
			return
		}
		key := strings.ToLower(name)
		if _, ok := seen[key]; ok {
			return
		}
		seen[key] = struct{}{}
		people = append(people, name)
	}

	for _, line := range lines {
		l := strings.TrimSpace(line)
		l = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(l, "-"), "*"))
		if strings.Contains(strings.ToLower(l), "preferred name") && strings.Contains(l, " is ") {
			parts := strings.SplitN(l, " is ", 2)
			add(parts[0])
		}
		for _, marker := range []string{"Wife:", "Daughter:", "Son:", "Partner:", "Name:"} {
			if strings.HasPrefix(l, marker) {
				rest := strings.TrimSpace(strings.TrimPrefix(l, marker))
				if idx := strings.Index(rest, ","); idx > 0 {
					rest = rest[:idx]
				}
				add(rest)
			}
		}
	}

	return people
}

func heuristicActionItems(text string) []string {
	lines := strings.Split(text, "\n")
	items := make([]string, 0, 5)
	for _, line := range lines {
		l := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(line, "-"), "*"))
		if l == "" {
			continue
		}
		ll := strings.ToLower(l)
		if strings.Contains(ll, "todo") || strings.HasPrefix(ll, "fix ") || strings.HasPrefix(ll, "add ") || strings.HasPrefix(ll, "update ") || strings.HasPrefix(ll, "implement ") {
			items = append(items, l)
		}
		if len(items) >= 5 {
			break
		}
	}
	return items
}

func isRetryableStatus(status int) bool {
	switch status {
	case http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
		return true
	default:
		return false
	}
}

func isRetryableError(err error) bool {
	if err == nil {
		return false
	}
	if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
		return true
	}
	var netErr net.Error
	return errors.As(err, &netErr)
}

func shouldRetryWithoutJSONMode(err error) bool {
	if err == nil {
		return false
	}
	if errors.Is(err, ErrEmptyResponse) || errors.Is(err, ErrNoJSONObject) {
		return true
	}

	lower := strings.ToLower(err.Error())
	return strings.Contains(lower, "parse json")
}

func sleepRetry(ctx context.Context, attempt int, log *slog.Logger, provider string) error {
	delay := time.Duration(attempt*attempt) * 200 * time.Millisecond
	if log != nil {
		log.Warn("retrying provider request", slog.String("provider", provider), slog.Duration("delay", delay), slog.Int("attempt", attempt+1))
	}
	timer := time.NewTimer(delay)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return nil
	}
}

func sleepMetadataRetry(ctx context.Context, attempt int) error {
	delay := time.Duration(attempt) * 350 * time.Millisecond
	timer := time.NewTimer(delay)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return nil
	}
}
