// Copyright 2026 printing-press-golden and contributors. Licensed under Apache-2.0. See LICENSE.
// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.

package patterns

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"sort"
	"strings"
	"time"

	"learn-loop-example-pp-cli/internal/learn/lookups"
)

// DefaultJaccardMin matches the recall-side floor. Applying the same
// threshold for pattern matching keeps the merged result list
// coherent: direct hits and pattern hits both pass through the same
// non-entity Jaccard gate before substitution + verification.
const DefaultJaccardMin = 0.6

// Hit is the pattern-side analogue of recall.Hit. Apply returns one
// Hit per substituted-and-verified candidate. The CLI's recall
// integration translates these into recall.Hit values before merging.
//
// We don't import recall.Hit directly because that would create an
// import cycle (recall.go calls into patterns.Apply). The translation
// is cheap and lossy in a useful direction: the recall layer adds its
// own per-hit metadata (warnings, last_observed_at lookups against the
// learning row) that the pattern layer doesn't have.
type Hit struct {
	ResourceID       string
	ResourceType     string
	Venue            string
	Confidence       int
	MatchScore       float64
	EntityMatch      string
	ResourceEntities []string
	Source           string
	PatternID        int64
	LastObservedAt   *time.Time
	// Meta carries structured diagnostic reasons when a pattern matched
	// textually but failed verification (substitution miss, resource
	// not in store, etc.). Empty on success.
	Meta map[string]string
}

// Opts tunes Apply behavior. Zero-value defaults:
//
//	JaccardMin    -> DefaultJaccardMin (0.6)
//	Limit         -> 10
//	NoVerify      -> false (verify candidates against resources table)
//
// AdditionalKinds is the CLI-supplied list of table-backed lookup
// kinds (e.g., from LearnConfig.EntityLookupSeeds) the pattern engine
// should try ahead of the built-in computed kinds when a template's
// entity_kind doesn't have a direct slot in the template string.
type Opts struct {
	JaccardMin      float64
	Limit           int
	NoVerify        bool
	AdditionalKinds []string
}

// Apply walks search_patterns, finds patterns whose query_template
// matches the live query (via the same non-entity normalized form +
// Jaccard threshold the direct recall path uses), substitutes the live
// query's entity via lookups.Lookup, and verifies each substituted
// candidate exists in the resources table (or matches a prefix LIKE
// search for the prefix strategy). Returns the verified hits.
//
// queryEntities is the case-preserving entity slice extracted from the
// live query by the caller (typically via learn.Normalize). The caller
// passes it in to avoid re-running the extractor: Apply is invoked
// from recall.Recall which already computed entities once.
//
// nonEntityNormalized is the sorted lowercased non-entity token string
// the recall path computes. It's the same key
// search_patterns.query_template stores (after extraction templated
// the entity slot out).
//
// db must be non-nil. ctx is honored on every db.Query call so a
// caller can bound the apply pass alongside the recall query itself.
func Apply(ctx context.Context, db *sql.DB, query, nonEntityNormalized string, queryEntities []string, opts Opts) ([]Hit, error) {
	if db == nil {
		return nil, errors.New("patterns.Apply: db is nil")
	}
	if len(queryEntities) == 0 {
		return nil, nil
	}

	jMin := opts.JaccardMin
	if jMin == 0 {
		jMin = DefaultJaccardMin
	}
	if jMin < 0 {
		jMin = 0
	}
	limit := opts.Limit
	if limit <= 0 {
		limit = 10
	}

	rows, err := db.QueryContext(ctx, `SELECT id, query_template, resource_template, resource_type,
			COALESCE(venue, ''), strategy, entity_kind, confidence, source, created_at, last_observed_at
		FROM search_patterns`)
	if err != nil {
		return nil, fmt.Errorf("patterns.Apply query: %w", err)
	}
	defer rows.Close()

	queryTokens := strings.Fields(nonEntityNormalized)
	hits := make([]Hit, 0)

	allKinds := make([]string, 0, len(opts.AdditionalKinds)+len(candidateKinds))
	allKinds = append(allKinds, opts.AdditionalKinds...)
	allKinds = append(allKinds, candidateKinds...)

	for rows.Next() {
		var (
			id            int64
			queryTemplate string
			resourceTmpl  string
			resourceType  string
			venue         string
			strategy      string
			entityKind    string
			confidence    int
			source        string
			createdAt     time.Time
			lastObserved  sql.NullTime
		)
		if err := rows.Scan(&id, &queryTemplate, &resourceTmpl, &resourceType, &venue,
			&strategy, &entityKind, &confidence, &source, &createdAt, &lastObserved); err != nil {
			return nil, fmt.Errorf("patterns.Apply scan: %w", err)
		}

		tmplTokens := nonPlaceholderTokens(strings.Fields(queryTemplate))
		score := jaccard(queryTokens, tmplTokens)
		if score < jMin {
			continue
		}

		var hit Hit
		matched := false
		for _, ent := range queryEntities {
			candidate, ok := substituteCandidate(db, resourceTmpl, entityKind, ent, allKinds)
			if !ok {
				continue
			}
			h, verified := verifyCandidate(ctx, db, candidate, resourceType, strategy, opts.NoVerify)
			if !verified {
				continue
			}
			h.Venue = venue
			h.Confidence = confidence
			h.MatchScore = score
			h.EntityMatch = "exact" // substitution binding guarantees this
			h.Source = "pattern"
			h.PatternID = id
			if lastObserved.Valid {
				t := lastObserved.Time
				h.LastObservedAt = &t
			}
			hit = h
			matched = true
			break
		}
		if !matched {
			continue
		}
		hits = append(hits, hit)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("patterns.Apply rows: %w", err)
	}

	sort.SliceStable(hits, func(i, j int) bool {
		if hits[i].MatchScore != hits[j].MatchScore {
			return hits[i].MatchScore > hits[j].MatchScore
		}
		if hits[i].Confidence != hits[j].Confidence {
			return hits[i].Confidence > hits[j].Confidence
		}
		ai := time.Time{}
		aj := time.Time{}
		if hits[i].LastObservedAt != nil {
			ai = *hits[i].LastObservedAt
		}
		if hits[j].LastObservedAt != nil {
			aj = *hits[j].LastObservedAt
		}
		return ai.After(aj)
	})

	if len(hits) > limit {
		hits = hits[:limit]
	}
	return hits, nil
}

// substituteCandidate fills in the {entity:kind} slot of
// resource_template with lookups.Lookup(kind, entity). The slot may
// appear with a typed kind or as a bare {entity} bound to the
// pattern's default entity_kind.
func substituteCandidate(db *sql.DB, resourceTmpl, defaultKind, entity string, allKinds []string) (string, bool) {
	typedSlot := "{entity:" + defaultKind + "}"
	if strings.Contains(resourceTmpl, typedSlot) {
		v, ok, err := lookups.Lookup(db, defaultKind, entity)
		if err != nil || !ok || v == "" {
			return "", false
		}
		return strings.ReplaceAll(resourceTmpl, typedSlot, v), true
	}
	if strings.Contains(resourceTmpl, "{entity}") {
		v, ok, err := lookups.Lookup(db, defaultKind, entity)
		if err != nil || !ok || v == "" {
			return "", false
		}
		return strings.ReplaceAll(resourceTmpl, "{entity}", v), true
	}
	for _, kind := range allKinds {
		slot := "{entity:" + kind + "}"
		if !strings.Contains(resourceTmpl, slot) {
			continue
		}
		v, ok, err := lookups.Lookup(db, kind, entity)
		if err != nil || !ok || v == "" {
			return "", false
		}
		return strings.ReplaceAll(resourceTmpl, slot, v), true
	}
	return "", false
}

// verifyCandidate confirms the substituted candidate corresponds to a
// real resource. Strategy-dispatch as documented on the Strategy
// constants.
func verifyCandidate(ctx context.Context, db *sql.DB, candidate, resourceType, strategy string, noVerify bool) (Hit, bool) {
	switch strategy {
	case StrategySubstitute:
		if noVerify {
			return Hit{ResourceID: candidate, ResourceType: resourceType}, true
		}
		var got string
		err := db.QueryRowContext(ctx,
			`SELECT id FROM resources WHERE resource_type = ? AND id = ?`,
			resourceType, candidate,
		).Scan(&got)
		if err != nil {
			return Hit{}, false
		}
		return Hit{ResourceID: got, ResourceType: resourceType}, true

	case StrategySubstituteThenSearchPrefix:
		prefix := strings.TrimSuffix(candidate, "*")
		if noVerify {
			return Hit{ResourceID: prefix, ResourceType: resourceType}, true
		}
		var got string
		err := db.QueryRowContext(ctx,
			`SELECT id FROM resources WHERE resource_type = ? AND id LIKE ? LIMIT 1`,
			resourceType, prefix+"%",
		).Scan(&got)
		if err != nil {
			return Hit{}, false
		}
		return Hit{ResourceID: got, ResourceType: resourceType}, true

	default:
		return Hit{}, false
	}
}

func nonPlaceholderTokens(in []string) []string {
	out := make([]string, 0, len(in))
	for _, t := range in {
		if strings.HasPrefix(t, "{entity") {
			continue
		}
		out = append(out, t)
	}
	return out
}

func jaccard(a, b []string) float64 {
	setA := setOf(a)
	setB := setOf(b)
	if len(setA) == 0 || len(setB) == 0 {
		return 0
	}
	inter := 0
	for tok := range setA {
		if _, ok := setB[tok]; ok {
			inter++
		}
	}
	union := len(setA) + len(setB) - inter
	if union == 0 {
		return 0
	}
	return float64(inter) / float64(union)
}

func setOf(in []string) map[string]struct{} {
	out := make(map[string]struct{}, len(in))
	for _, t := range in {
		t = strings.ToLower(strings.TrimSpace(t))
		if t == "" {
			continue
		}
		out[t] = struct{}{}
	}
	return out
}
