package personagen

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"strings"
	"time"

	"{{GO_MODULE}}/pkg/persona"
	"{{GO_MODULE}}/pkg/textgen"
)

// generatePersonaSpec runs the 5-stage LLM pipeline to produce a complete PersonaSpec.
// Each stage builds on the output of the previous one.
func generatePersonaSpec(ctx context.Context, tg *textgen.Manager, seed SeedParams, logger *slog.Logger) (*persona.PersonaSpec, error) {
	logger = logger.With("op", "generatePersonaSpec")

	// Stage 1: Core identity (name, demographics, occupation, locations)
	logger.Info("specgen stage 1: generating core identity")
	identity, err := genIdentity(ctx, tg, seed)
	if err != nil {
		return nil, fmt.Errorf("stage 1 identity: %w", err)
	}

	// Stage 2: Psychology (HEXACO, attachment, values)
	logger.Info("specgen stage 2: generating psychology")
	psych, err := genPsychology(ctx, tg, identity)
	if err != nil {
		return nil, fmt.Errorf("stage 2 psychology: %w", err)
	}

	// Stage 3: Lifestyle (interests, fashion context, vacation style)
	logger.Info("specgen stage 3: generating lifestyle")
	lifestyle, err := genLifestyle(ctx, tg, identity, psych)
	if err != nil {
		return nil, fmt.Errorf("stage 3 lifestyle: %w", err)
	}

	// Stage 4: Visual DNA (face, body, voice characteristics)
	logger.Info("specgen stage 4: generating visual DNA")
	dna, err := genDNA(ctx, tg, identity, lifestyle)
	if err != nil {
		return nil, fmt.Errorf("stage 4 visual DNA: %w", err)
	}

	// Stage 5: Populate image matrix with lifestyle-derived outfits and scenes.
	logger.Info("specgen stage 5: populating image matrix")
	imageMatrix := populateImageMatrix(persona.DefaultImageMatrix(), lifestyle)

	tier := inferGenerationTier(seed.Description)
	spec := &persona.PersonaSpec{
		ID:             generateID(),
		CreatedAt:      now(),
		DNA:            dna,
		Name:           identity.Name,
		Psychology:     *psych,
		Lifestyle:      *lifestyle,
		ImageMatrix:    imageMatrix,
		Videos:         persona.DefaultVideoMatrix(),
		GenerationTier: tier,
		Attractiveness: inferAttractiveness(tier),
	}

	logger.Info("specgen complete", "persona_id", spec.ID)
	return spec, nil
}

// ── Internal LLM response structs ──────────────────────────────────────────

type identityLLMResponse struct {
	FirstName   string `json:"first_name"`
	LastName    string `json:"last_name"`
	Nickname    string `json:"nickname,omitempty"`
	Age         int    `json:"age"`
	Gender      string `json:"gender"`
	Ethnicity   string `json:"ethnicity"`
	Nationality string `json:"nationality"`
	Sexuality   string `json:"sexuality,omitempty"`
	Occupation  string `json:"occupation,omitempty"`
	BirthCity   string `json:"birth_city,omitempty"`
	CurrentCity string `json:"current_city,omitempty"`
}

type hexacoScoreLLM struct {
	Score                  int    `json:"score"`
	BehavioralImplications string `json:"behavioral_implications,omitempty"`
}

type psychLLMResponse struct {
	HonestyHumility   hexacoScoreLLM `json:"honesty_humility"`
	Emotionality      hexacoScoreLLM `json:"emotionality"`
	Extraversion      hexacoScoreLLM `json:"extraversion"`
	Agreeableness     hexacoScoreLLM `json:"agreeableness"`
	Conscientiousness hexacoScoreLLM `json:"conscientiousness"`
	Openness          hexacoScoreLLM `json:"openness"`
	AttachmentPrimary string         `json:"attachment_primary"`
	AttachmentPattern string         `json:"attachment_pattern,omitempty"`
	CoreValues        []string       `json:"core_values,omitempty"`
	LifePhilosophy    string         `json:"life_philosophy,omitempty"`
}

type lifestyleLLMResponse struct {
	CreativeInterests     []string `json:"creative_interests"`
	ActiveInterests       []string `json:"active_interests"`
	SocialInterests       []string `json:"social_interests"`
	IntellectualInterests []string `json:"intellectual_interests"`
	LifestyleInterests    []string `json:"lifestyle_interests"`
	FashionPrimary        string   `json:"fashion_primary"`
	FashionSecondary      string   `json:"fashion_secondary,omitempty"`
	FashionSignature      []string `json:"fashion_signature_details,omitempty"`
	VacationPrimary       string   `json:"vacation_primary"`
	VacationActivities    []string `json:"vacation_activities,omitempty"`
	DreamDestinations     []string `json:"dream_destinations,omitempty"`
}

type dnaLLMResponse struct {
	// Face
	FaceShape     string `json:"face_shape"`
	BoneStructure string `json:"bone_structure"`
	Jawline       string `json:"jawline"`
	Cheekbones    string `json:"cheekbones"`
	EyeShape      string `json:"eye_shape"`
	EyeColor      string `json:"eye_color"`
	EyeSpacing    string `json:"eye_spacing"`
	EyeSize       string `json:"eye_size"`
	NoseShape     string `json:"nose_shape"`
	NoseBridge    string `json:"nose_bridge"`
	NoseTip       string `json:"nose_tip"`
	LipShape      string `json:"lip_shape"`
	LipFullness   string `json:"lip_fullness"`
	SmileType     string `json:"smile_type"`
	BrowShape     string `json:"brow_shape"`
	BrowThickness string `json:"brow_thickness"`
	SkinTone      string `json:"skin_tone"`
	SkinUndertone string `json:"skin_undertone"`
	SkinTexture   string `json:"skin_texture"`
	HairColor     string `json:"hair_color"`
	HairTexture   string `json:"hair_texture"`
	HairLength    string `json:"hair_length"`
	HairThickness string `json:"hair_thickness"`
	// Body
	HeightCM         int     `json:"height_cm"`
	Build            string  `json:"build"`
	BodyFatPercent   int     `json:"body_fat_percent"`
	MuscleDefinition string  `json:"muscle_definition"`
	ShoulderWidth    string  `json:"shoulder_width"`
	HipWidth         string  `json:"hip_width"`
	WHRatio          float64 `json:"wh_ratio"`
	LegLength        string  `json:"leg_length"`
	TorsoLength      string  `json:"torso_length"`
	BustSize         string  `json:"bust_size,omitempty"`
	Posture          string  `json:"posture"`
	// Voice
	Pitch          string `json:"pitch"`
	PitchRange     string `json:"pitch_range"`
	Tone           string `json:"tone"`
	Timbre         string `json:"timbre"`
	Accent         string `json:"accent"`
	AccentStrength string `json:"accent_strength"`
	Cadence        string `json:"cadence"`
	RhythmPattern  string `json:"rhythm_pattern"`
	Volume         string `json:"volume"`
	Clarity        string `json:"clarity"`
	Expressiveness string `json:"expressiveness"`
}

// ── Stage implementations ──────────────────────────────────────────────────

func genIdentity(ctx context.Context, tg *textgen.Manager, seed SeedParams) (*persona.CoreIdentity, error) {
	validGenders := `"woman", "man", "non_binary"`
	validEthnicities := `"east_asian", "south_asian", "southeast_asian", "african", "hispanic", "middle_eastern", "caucasian", "mixed"`

	prompt := fmt.Sprintf(`You are generating a synthetic persona profile. Given this seed:
Description: %q
Gender: %q
Name override (empty = generate one): %q

Return ONLY a JSON object with these exact fields:
{
  "first_name": "string (culturally appropriate given ethnicity)",
  "last_name": "string",
  "nickname": "string or empty",
  "age": number (18-35),
  "gender": one of [%s],
  "ethnicity": one of [%s],
  "nationality": "string (country name)",
  "sexuality": "string (e.g. heterosexual, bisexual)",
  "occupation": "string (job title or role)",
  "birth_city": "string",
  "current_city": "string (where they live now)"
}

Ensure name fits the ethnicity and nationality. Return ONLY valid JSON.`,
		seed.Description, seed.Gender, seed.Name, validGenders, validEthnicities)

	resp, err := tg.GenerateText(ctx, textgen.TextRequest{
		Prompt:      prompt,
		MaxTokens:   400,
		Temperature: 0.8,
		Timeout:     30 * time.Second,
	})
	if err != nil {
		return nil, fmt.Errorf("LLM call: %w", err)
	}

	var r identityLLMResponse
	if err := parseLLMJSON(resp.Text, &r); err != nil {
		return nil, fmt.Errorf("parsing identity response: %w", err)
	}

	// Apply name override from seed if provided.
	firstName, lastName := r.FirstName, r.LastName
	if seed.Name != "" {
		parts := strings.Fields(seed.Name)
		if len(parts) > 0 {
			firstName = parts[0]
		}
		if len(parts) > 1 {
			lastName = strings.Join(parts[1:], " ")
		}
	}

	return &persona.CoreIdentity{
		Name: persona.NameSpec{
			First:       firstName,
			Last:        lastName,
			Nickname:    r.Nickname,
			DisplayName: firstName,
		},
		Age:         r.Age,
		Gender:      persona.GenderIdentity(r.Gender),
		Ethnicity:   persona.EthnicityCode(r.Ethnicity),
		Nationality: r.Nationality,
		Sexuality:   r.Sexuality,
		Occupation:  r.Occupation,
		BirthCity:   r.BirthCity,
		CurrentCity: r.CurrentCity,
	}, nil
}

func genPsychology(ctx context.Context, tg *textgen.Manager, identity *persona.CoreIdentity) (*persona.Psychology, error) {
	prompt := fmt.Sprintf(`Given this persona:
Name: %s %s, Age: %d, Gender: %s, Occupation: %s

Generate a HEXACO personality profile. Score each dimension 1-10 (1=very low, 10=very high).
Valid attachment styles: "secure", "anxious", "avoidant", "disorganized"

Return ONLY a JSON object:
{
  "honesty_humility": {"score": number, "behavioral_implications": "string"},
  "emotionality": {"score": number, "behavioral_implications": "string"},
  "extraversion": {"score": number, "behavioral_implications": "string"},
  "agreeableness": {"score": number, "behavioral_implications": "string"},
  "conscientiousness": {"score": number, "behavioral_implications": "string"},
  "openness": {"score": number, "behavioral_implications": "string"},
  "attachment_primary": "string",
  "attachment_pattern": "string (1-2 sentences)",
  "core_values": ["value1", "value2", "value3", "value4", "value5"],
  "life_philosophy": "string (one guiding principle)"
}`,
		identity.Name.First, identity.Name.Last, identity.Age, identity.Gender, identity.Occupation)

	resp, err := tg.GenerateText(ctx, textgen.TextRequest{
		Prompt:      prompt,
		MaxTokens:   600,
		Temperature: 0.7,
		Timeout:     30 * time.Second,
	})
	if err != nil {
		return nil, fmt.Errorf("LLM call: %w", err)
	}

	var r psychLLMResponse
	if err := parseLLMJSON(resp.Text, &r); err != nil {
		return nil, fmt.Errorf("parsing psychology response: %w", err)
	}

	toTraitScore := func(s hexacoScoreLLM) persona.TraitScore {
		return persona.TraitScore{Score: s.Score, BehavioralImplications: s.BehavioralImplications}
	}

	return &persona.Psychology{
		HEXACO: persona.HEXACOProfile{
			HonestyHumility:   toTraitScore(r.HonestyHumility),
			Emotionality:      toTraitScore(r.Emotionality),
			Extraversion:      toTraitScore(r.Extraversion),
			Agreeableness:     toTraitScore(r.Agreeableness),
			Conscientiousness: toTraitScore(r.Conscientiousness),
			Openness:          toTraitScore(r.Openness),
		},
		Attachment: persona.AttachmentStyle{
			Primary: r.AttachmentPrimary,
			Pattern: r.AttachmentPattern,
		},
		Values: persona.Values{
			Core:           r.CoreValues,
			LifePhilosophy: r.LifePhilosophy,
		},
	}, nil
}

func genLifestyle(ctx context.Context, tg *textgen.Manager, identity *persona.CoreIdentity, psych *persona.Psychology) (*persona.Lifestyle, error) {
	fashionOptions := strings.Join(func() []string {
		names := persona.AllFashionContextNames()
		out := make([]string, len(names))
		for i, n := range names {
			out[i] = fmt.Sprintf("%q", n)
		}
		return out
	}(), ", ")

	prompt := fmt.Sprintf(`Given this persona:
Name: %s, Age: %d, Occupation: %s, Nationality: %s
Extraversion: %d/10, Openness: %d/10

Generate lifestyle profile. Valid fashion contexts: [%s]

Return ONLY a JSON object:
{
  "creative_interests": ["interest1", "interest2"] (2-4 items),
  "active_interests": ["interest1"] (1-3 items),
  "social_interests": ["interest1", "interest2"] (2-4 items),
  "intellectual_interests": ["interest1"] (1-3 items),
  "lifestyle_interests": ["interest1", "interest2"] (2-3 items),
  "fashion_primary": "one of the valid fashion context names",
  "fashion_secondary": "one of the valid fashion context names (different from primary)",
  "fashion_signature_details": ["detail1", "detail2"] (1-3 personal styling touches),
  "vacation_primary": "beach|city|adventure|luxury|cultural",
  "vacation_activities": ["activity1", "activity2"] (2-3 items),
  "dream_destinations": ["destination1", "destination2", "destination3"]
}`,
		identity.Name.First, identity.Age, identity.Occupation, identity.Nationality,
		psych.HEXACO.Extraversion.Score, psych.HEXACO.Openness.Score,
		fashionOptions)

	resp, err := tg.GenerateText(ctx, textgen.TextRequest{
		Prompt:      prompt,
		MaxTokens:   500,
		Temperature: 0.8,
		Timeout:     30 * time.Second,
	})
	if err != nil {
		return nil, fmt.Errorf("LLM call: %w", err)
	}

	var r lifestyleLLMResponse
	if err := parseLLMJSON(resp.Text, &r); err != nil {
		return nil, fmt.Errorf("parsing lifestyle response: %w", err)
	}

	return &persona.Lifestyle{
		Interests: persona.Interests{
			Creative:     r.CreativeInterests,
			Active:       r.ActiveInterests,
			Social:       r.SocialInterests,
			Intellectual: r.IntellectualInterests,
			Lifestyle:    r.LifestyleInterests,
		},
		FashionSense: persona.FashionSense{
			Primary:          persona.FashionContextName(r.FashionPrimary),
			Secondary:        persona.FashionContextName(r.FashionSecondary),
			SignatureDetails: r.FashionSignature,
		},
		VacationStyle: persona.VacationStyle{
			Primary:           r.VacationPrimary,
			Activities:        r.VacationActivities,
			DreamDestinations: r.DreamDestinations,
		},
	}, nil
}

func genDNA(ctx context.Context, tg *textgen.Manager, identity *persona.CoreIdentity, lifestyle *persona.Lifestyle) (*persona.DNA, error) {
	fashionCtx := persona.FashionContextFor(lifestyle.FashionSense.Primary)
	prompt := fmt.Sprintf(`Generate precise visual characteristics for:
Name: %s, Age: %d, Gender: %s, Ethnicity: %s, Nationality: %s
Fashion style: %s (%s)

All values MUST use exactly the allowed enum values listed below.

Return ONLY a JSON object (all fields required):
{
  "face_shape": "oval|heart|square|round|diamond|oblong",
  "bone_structure": "delicate|moderate|strong",
  "jawline": "soft|rounded|defined|angular|square",
  "cheekbones": "subtle|moderate|prominent|high",
  "eye_shape": "almond|round|hooded|monolid|upturned|downturned|deep_set",
  "eye_color": "dark_brown|brown|hazel|amber|green|blue|gray",
  "eye_spacing": "close_set|average|wide_set",
  "eye_size": "small|average|large",
  "nose_shape": "button|straight|wide|roman|aquiline",
  "nose_bridge": "low|moderate|high|bumped",
  "nose_tip": "rounded|pointed|upturned|bulbous",
  "lip_shape": "full|thin|bow|rounded|wide",
  "lip_fullness": "subtle|moderate|plump|voluptuous",
  "smile_type": "subtle|broad|asymmetric|gummy|closed",
  "brow_shape": "natural|arched|straight|rounded|angled",
  "brow_thickness": "thin|natural|thick|fluffy|bleached",
  "skin_tone": "fair|light|medium|olive|tan|brown|dark_brown|deep",
  "skin_undertone": "warm|cool|neutral",
  "skin_texture": "smooth|normal|textured|mature",
  "hair_color": "black|dark_brown|brown|light_brown|blonde|red|auburn|gray",
  "hair_texture": "straight|wavy|curly|coily|kinky",
  "hair_length": "pixie|short|chin|shoulder|mid_back|long|very_long",
  "hair_thickness": "fine|medium|thick",
  "height_cm": number (150-185),
  "build": "slender|athletic|curvy|muscular|average|plus_curvy|petite",
  "body_fat_percent": number (15-35),
  "muscle_definition": "none|subtle|moderate|defined|ripped",
  "shoulder_width": "narrow|average|broad",
  "hip_width": "narrow|average|wide",
  "wh_ratio": number (0.65-0.90),
  "leg_length": "short|proportional|long",
  "torso_length": "short|proportional|long",
  "bust_size": "small|medium|large|very_large",
  "posture": "upright|relaxed|confident|athletic",
  "pitch": "very_low|low|medium|high|very_high",
  "pitch_range": "narrow|moderate|wide",
  "tone": "warm|cool|neutral|rich|bright",
  "timbre": "clear|smooth|husky|breathy|rich|crisp",
  "accent": "north_american|british|australian|global_english|non_native",
  "accent_strength": "subtle|moderate|strong",
  "cadence": "very_slow|slow|medium|fast|very_fast",
  "rhythm_pattern": "steady|variable|dynamic",
  "volume": "soft|moderate|loud",
  "clarity": "crisp|natural|relaxed",
  "expressiveness": "monotone|moderate|expressive|animated"
}`,
		identity.Name.First, identity.Age, identity.Gender, identity.Ethnicity, identity.Nationality,
		fashionCtx.Name, fashionCtx.Description)

	resp, err := tg.GenerateText(ctx, textgen.TextRequest{
		Prompt:      prompt,
		MaxTokens:   900,
		Temperature: 0.6,
		Timeout:     45 * time.Second,
	})
	if err != nil {
		return nil, fmt.Errorf("LLM call: %w", err)
	}

	var r dnaLLMResponse
	if err := parseLLMJSON(resp.Text, &r); err != nil {
		return nil, fmt.Errorf("parsing DNA response: %w", err)
	}

	return &persona.DNA{
		Identity: persona.IdentityDNA{
			Ethnicity:       identity.Ethnicity,
			Age:             identity.Age,
			Gender:          identity.Gender,
			Nationality:     identity.Nationality,
			PrimaryHeritage: identity.Ethnicity,
		},
		Face: persona.FaceDNA{
			FaceShape:     persona.FaceShapeCategory(r.FaceShape),
			BoneStructure: persona.BoneStructureCategory(r.BoneStructure),
			Jawline:       persona.JawlineCategory(r.Jawline),
			Cheekbones:    persona.CheekbonesCategory(r.Cheekbones),
			EyeShape:      persona.EyeShapeCategory(r.EyeShape),
			EyeColor:      persona.EyeColorCategory(r.EyeColor),
			EyeSpacing:    persona.EyeSpacingCategory(r.EyeSpacing),
			EyeSize:       persona.EyeSizeCategory(r.EyeSize),
			NoseShape:     persona.NoseShapeCategory(r.NoseShape),
			NoseBridge:    persona.NoseBridgeCategory(r.NoseBridge),
			NoseTip:       persona.NoseTipCategory(r.NoseTip),
			LipShape:      persona.LipShapeCategory(r.LipShape),
			LipFullness:   persona.LipFullnessCategory(r.LipFullness),
			SmileType:     persona.SmileTypeCategory(r.SmileType),
			BrowShape:     persona.BrowShapeCategory(r.BrowShape),
			BrowThickness: persona.BrowThicknessCategory(r.BrowThickness),
			SkinTone:      persona.SkinToneCategory(r.SkinTone),
			SkinUndertone: persona.SkinUndertoneCategory(r.SkinUndertone),
			SkinTexture:   persona.SkinTextureCategory(r.SkinTexture),
			HairColor:     persona.HairColorCategory(r.HairColor),
			HairTexture:   persona.HairTextureCategory(r.HairTexture),
			HairLength:    persona.HairLengthCategory(r.HairLength),
			HairThickness: persona.HairThicknessCategory(r.HairThickness),
		},
		Body: persona.BodyDNA{
			Height:           persona.HeightCategoryFromCM(r.HeightCM),
			HeightCM:         r.HeightCM,
			Build:            persona.BodyBuildCategory(r.Build),
			BodyFatPercent:   r.BodyFatPercent,
			MuscleDefinition: persona.MuscleDefinitionCategory(r.MuscleDefinition),
			ShoulderWidth:    persona.ShoulderWidthCategory(r.ShoulderWidth),
			HipWidth:         persona.HipWidthCategory(r.HipWidth),
			WHRatio:          r.WHRatio,
			LegLength:        persona.LegLengthCategory(r.LegLength),
			TorsoLength:      persona.TorsoLengthCategory(r.TorsoLength),
			BustSize:         persona.BustSizeCategory(r.BustSize),
			PostureType:      persona.PostureCategory(r.Posture),
		},
		Voice: persona.VoiceDNA{
			Pitch:          persona.PitchCategory(r.Pitch),
			PitchRange:     persona.PitchRangeCategory(r.PitchRange),
			Tone:           persona.ToneCategory(r.Tone),
			Timbre:         persona.TimbreCategory(r.Timbre),
			Accent:         persona.AccentCategory(r.Accent),
			AccentStrength: persona.AccentStrengthCategory(r.AccentStrength),
			Cadence:        persona.CadenceCategory(r.Cadence),
			RhythmPattern:  persona.RhythmPatternCategory(r.RhythmPattern),
			Volume:         persona.VolumeCategory(r.Volume),
			Clarity:        persona.ClarityCategory(r.Clarity),
			Expressiveness: persona.ExpressivenessCategory(r.Expressiveness),
		},
	}, nil
}

// populateImageMatrix assigns outfit and fashion context details to each image spec
// based on the persona's lifestyle. This is Stage 5 of the pipeline.
func populateImageMatrix(matrix []persona.ImageSpec, lifestyle *persona.Lifestyle) []persona.ImageSpec {
	if lifestyle == nil {
		return matrix
	}

	primaryCtx := persona.FashionContextFor(lifestyle.FashionSense.Primary)
	secondaryCtx := persona.FashionContextFor(lifestyle.FashionSense.Secondary)

	for i := range matrix {
		spec := &matrix[i]

		switch spec.ClothingState {
		case "fashion outfit", "complete fashion outfit", "full fashion outfit":
			spec.FashionContext = string(primaryCtx.Name)
			keyCount := min(3, len(primaryCtx.KeyPieces))
			if keyCount > 0 {
				spec.Outfit = strings.Join(primaryCtx.KeyPieces[:keyCount], ", ")
			}
			spec.Outfit += " — " + primaryCtx.Silhouette

		case "casual", "casual homewear", "casual streetwear":
			if secondaryCtx.Name != "" {
				spec.FashionContext = string(secondaryCtx.Name)
				if len(secondaryCtx.KeyPieces) > 0 {
					spec.Outfit = secondaryCtx.KeyPieces[0] + " casual look"
				}
			} else {
				spec.FashionContext = string(primaryCtx.Name)
				spec.Outfit = "casual everyday look"
			}

		case "athletic wear":
			spec.FashionContext = string(persona.FashionAthleisurePro)
			spec.Outfit = "fitted athletic set, performance fabric"

		case "cozy layered", "comfortable/cozy", "minimal/cozy":
			spec.FashionContext = string(persona.FashionLuxeLoungewear)
			spec.Outfit = "soft layered loungewear, neutral tones"

		case "off-shoulder":
			spec.FashionContext = string(primaryCtx.Name)
			spec.Outfit = "off-shoulder " + firstKeyPiece(primaryCtx)

		default:
			spec.FashionContext = string(primaryCtx.Name)
			spec.Outfit = firstKeyPiece(primaryCtx)
		}

		// Position 1 (anchor) gets the persona's signature styling detail.
		if spec.Position == 1 && len(lifestyle.FashionSense.SignatureDetails) > 0 {
			spec.Outfit += ", " + lifestyle.FashionSense.SignatureDetails[0]
		}
	}

	return matrix
}

// firstKeyPiece returns the first key piece from a fashion context, or empty string.
func firstKeyPiece(fc persona.FashionContext) string {
	if len(fc.KeyPieces) > 0 {
		return fc.KeyPieces[0]
	}
	return ""
}

// parseLLMJSON extracts and unmarshals JSON from an LLM text response.
// Handles both raw JSON and JSON wrapped in markdown code blocks.
func parseLLMJSON(text string, dst any) error {
	text = strings.TrimSpace(text)

	// Strip markdown code fences if present.
	if strings.HasPrefix(text, "```") {
		lines := strings.Split(text, "\n")
		if len(lines) >= 2 {
			inner := lines[1:]
			if len(inner) > 0 && strings.TrimSpace(inner[len(inner)-1]) == "```" {
				inner = inner[:len(inner)-1]
			}
			text = strings.Join(inner, "\n")
		}
	}

	// Extract outermost JSON object from potentially noisy LLM output.
	start := strings.Index(text, "{")
	end := strings.LastIndex(text, "}")
	if start >= 0 && end > start {
		text = text[start : end+1]
	}

	if err := json.Unmarshal([]byte(text), dst); err != nil {
		preview := text
		if len(preview) > 120 {
			preview = preview[:120] + "..."
		}
		return fmt.Errorf("JSON unmarshal: %w (preview: %s)", err, preview)
	}
	return nil
}
