// 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 entities

import (
	"strings"
	"unicode"
)

// Result is the parsed shape of a CLI query string from the perspective
// of the learning subsystem. Each field carries a distinct semantic role:
//
//   - Entities: identity-bearing tokens (proper names, place names, etc).
//     Used by match validation in the recall path: a learning whose
//     Entities don't overlap with the query's Entities is treated as a
//     mismatch even when non-entity Jaccard is high.
//
//   - Tickers: CLI-specific identifier-shaped tokens, registered via
//     Config.RegisterTickerPattern. Kept separate from Entities so
//     downstream code can distinguish "user typed the literal id" from
//     "user typed an entity name."
//
//   - NonEntityTokens: lowercase content tokens left over after entities,
//     tickers, and stopwords are removed. Used for token-set Jaccard
//     against the corresponding field on stored learnings. Sorted by the
//     caller for stable comparison.
type Result struct {
	Entities        []string
	Tickers         []string
	NonEntityTokens []string
}

// Extract pulls entity / ticker / non-entity tokens out of a query string
// according to the provided Config. Passing nil cfg uses a Config with
// the default stopword list and no ticker patterns.
//
// Rules, in order of precedence:
//
//  1. Token matches any Config.tickerPatterns -> Tickers
//  2. Token is ALL-CAPS, length >= 2, alphanumeric -> Entities
//  3. Token starts with uppercase letter:
//     a. At position 0 AND its lowercase form is a stopword -> drop
//     b. Otherwise -> Entities
//  4. Token's lowercase form is a stopword -> drop
//  5. Otherwise -> NonEntityTokens (lowercased)
//
// Punctuation surrounding tokens (.,?!:;'") is stripped before
// classification. Internal hyphens and underscores are preserved so
// tickers and slugs survive whitespace-tokenization.
func Extract(query string, cfg *Config) Result {
	if cfg == nil {
		cfg = NewConfig()
	}
	rawTokens := strings.Fields(query)
	result := Result{}

	for i, raw := range rawTokens {
		tok := trimPunct(raw)
		if tok == "" {
			continue
		}

		// 1. Ticker pattern -- highest precedence so a slug doesn't get
		//    mis-treated as a capitalized non-stopword.
		if cfg.matchesTicker(tok) {
			result.Tickers = append(result.Tickers, tok)
			continue
		}

		lower := strings.ToLower(tok)

		// 2. ALL-CAPS alphanumeric of length >= 2 -> entity, BUT
		//    explicit stopword registration overrides.
		if len(tok) >= 2 && isAllCaps(tok) {
			if cfg.isStopword(lower) {
				continue
			}
			result.Entities = append(result.Entities, tok)
			continue
		}

		// 3. Capitalized (first letter upper, has at least one lowercase).
		if isCapitalized(tok) {
			// 3a. Sentence-initial capitalized stopword: drop.
			if i == 0 && cfg.isStopword(lower) {
				continue
			}
			// 3b. Mid-sentence capitalization or non-stopword leading
			//     word: treat as entity.
			result.Entities = append(result.Entities, tok)
			continue
		}

		// 4. Stopword filter for lowercase tokens.
		if cfg.isStopword(lower) {
			continue
		}

		// 5. Everything else is a non-entity content token.
		result.NonEntityTokens = append(result.NonEntityTokens, lower)
	}

	return result
}

// trimPunct strips a small set of sentence-punctuation characters from
// both ends of a token. Internal hyphens and underscores are preserved
// because they are load-bearing in tickers and slugs.
func trimPunct(s string) string {
	return strings.TrimFunc(s, func(r rune) bool {
		switch r {
		case '.', ',', '?', '!', ':', ';', '\'', '"', '(', ')', '[', ']', '{', '}':
			return true
		}
		return false
	})
}

// isAllCaps reports whether every letter in s is uppercase. Non-letter
// runes (digits, hyphens) are allowed and don't disqualify; pure-digit
// tokens return false because they aren't entities.
func isAllCaps(s string) bool {
	hasLetter := false
	for _, r := range s {
		if unicode.IsLetter(r) {
			hasLetter = true
			if !unicode.IsUpper(r) {
				return false
			}
		}
	}
	return hasLetter
}

// isCapitalized reports whether the first letter rune of s is uppercase
// AND there is at least one lowercase letter somewhere in s. The second
// condition distinguishes "Alpha" from "ALPHA" (ALL-CAPS handled above).
func isCapitalized(s string) bool {
	firstUpper := false
	hasLower := false
	for i, r := range s {
		if i == 0 {
			if unicode.IsLetter(r) && unicode.IsUpper(r) {
				firstUpper = true
				continue
			}
			return false
		}
		if unicode.IsLetter(r) && unicode.IsLower(r) {
			hasLower = true
		}
	}
	return firstUpper && hasLower
}
