package autotag

import (
	"regexp"
	"strings"
	"unicode"
)

// levenshtein returns the Levenshtein edit distance between a and
// b, operating on runes so multi-byte characters count as one edit.
// Allocates a single O(min(len)) scratch slice.
func levenshtein(a, b string) int {
	ra := []rune(a)
	rb := []rune(b)

	if len(ra) == 0 {
		return len(rb)
	}

	if len(rb) == 0 {
		return len(ra)
	}

	if len(ra) > len(rb) {
		ra, rb = rb, ra
	}

	prev := make([]int, len(ra)+1)
	for i := range prev {
		prev[i] = i
	}

	for j := 1; j <= len(rb); j++ {
		curr0 := prev[0]
		prev[0] = j

		for i := 1; i <= len(ra); i++ {
			cost := 1
			if ra[i-1] == rb[j-1] {
				cost = 0
			}

			newVal := min3(
				prev[i]+1,   // deletion
				prev[i-1]+1, // insertion
				curr0+cost,  // substitution
			)
			curr0 = prev[i]
			prev[i] = newVal
		}
	}

	return prev[len(ra)]
}

func min3(a, b, c int) int {
	if a < b {
		if a < c {
			return a
		}

		return c
	}

	if b < c {
		return b
	}

	return c
}

// sdPattern couples a regexp with the weight its removal carries in
// stringDist: when deleting the matched portion from both strings
// shrinks their distance, the recovered distance is re-added at
// `weight` instead of counting fully.  Weight 0 makes a difference
// in that portion free (known-cosmetic); higher weights make it
// cheap but not free.  Modeled on beets' SD_PATTERNS.
type sdPattern struct {
	re     *regexp.Regexp
	weight float64
}

// sdPatterns are applied in order; earlier patterns claim their
// portion of the string first.  Known qualifiers (whitelist) are
// free; generic parenthetical / bracketed content, featured-artist
// credits, leading articles, and part suffixes are de-weighted.
var sdPatterns = []sdPattern{
	{qualifierPattern, 0.0},
	{dashQualifierPattern, 0.0},
	{regexp.MustCompile(`^the `), 0.1},
	{regexp.MustCompile(`\b(featuring|feat\.?|ft\.?)[ :].*$`), 0.1},
	{regexp.MustCompile(`\(.*?\)`), 0.3},
	{regexp.MustCompile(`\[.*?\]`), 0.3},
	{regexp.MustCompile(`(, )?\b(pt\.|part) .+$`), 0.2},
}

// sdEndWords are articles that user tags sometimes rotate to the
// end with a comma: "Beatles, The" ≡ "The Beatles".
var sdEndWords = []string{"the", "a", "an"}

// stringDistBasic is the normalized edit distance between the two
// strings reduced to lowercase alphanumerics, in [0, 1].  Inputs
// are assumed to be lowercased and ASCII-folded already.
func stringDistBasic(a, b string) float64 {
	a = alnumOnly(a)
	b = alnumOnly(b)

	if a == "" && b == "" {
		return 0.0
	}

	longest := max(len(a), len(b))

	return float64(levenshtein(a, b)) / float64(longest)
}

// alnumOnly strips everything but letters and digits.
func alnumOnly(s string) string {
	var b strings.Builder

	b.Grow(len(s))

	for _, r := range s {
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			b.WriteRune(r)
		}
	}

	return b.String()
}

// stringDist returns an "intuitive" distance between two titles or
// artist credits, in [0, 1].  It is a normalized edit distance with
// tweaks reflecting how music metadata actually differs (ported
// from beets' string_dist):
//
//   - accents transliterated, case ignored
//   - "X, The" rotated back to "The X" (same for "A"/"An")
//   - "&" ≡ "and"
//   - known qualifier suffixes ("(Remastered 2009)", "- Radio Edit")
//     are free; unknown parenthesized/bracketed content, featured-
//     artist credits, leading articles, and "Part N" suffixes are
//     de-weighted rather than counting as full edits
func stringDist(a, b string) float64 {
	a = strings.ToLower(asciiFold(a))
	b = strings.ToLower(asciiFold(b))

	a = rotateEndWord(a)
	b = rotateEndWord(b)

	a = strings.ReplaceAll(a, "&", " and ")
	b = strings.ReplaceAll(b, "&", " and ")

	base := stringDistBasic(a, b)
	penalty := 0.0

	for _, p := range sdPatterns {
		ca := p.re.ReplaceAllString(a, "")
		cb := p.re.ReplaceAllString(b, "")

		if ca == a && cb == b {
			continue
		}

		// The pattern was present: measure how much of the distance
		// it accounted for and re-add that share at reduced weight.
		caseDist := stringDistBasic(ca, cb)

		delta := base - caseDist
		if delta <= 0 {
			continue
		}

		a, b = ca, cb
		base = caseDist
		penalty += p.weight * delta
	}

	return base + penalty
}

// rotateEndWord undoes sort-style article rotation: "beatles, the"
// → "the beatles".  Input must be lowercased.
func rotateEndWord(s string) string {
	for _, w := range sdEndWords {
		suffix := ", " + w
		if strings.HasSuffix(s, suffix) {
			return w + " " + s[:len(s)-len(suffix)]
		}
	}

	return s
}

// TitleSimilarity exposes titleSimilarity for callers outside the
// package that compare music metadata strings and should get the same
// answer the tagger would.  The download pipeline uses it to match
// candidate filenames against an expected tracklist — Soulseek and
// torrent results carry paths, not tags, so filename comparison is the
// only signal available before the bytes arrive.
func TitleSimilarity(a, b string) float64 {
	return titleSimilarity(a, b)
}

// titleSimilarity returns a score in [0, 1] from stringDist.  1.0
// means identical after normalization, 0.0 means fully dissimilar.
func titleSimilarity(a, b string) float64 {
	if strings.TrimSpace(a) == "" && strings.TrimSpace(b) == "" {
		return 1.0
	}

	sim := 1.0 - stringDist(a, b)
	if sim < 0 {
		return 0.0
	}

	return sim
}

// Scoring weights for the per-track distance function. Local reads
// only — never written at runtime, so no mutex.  Values stay small
// so a future tuning pass can nudge them without rescaling.
const (
	weightTitle       = 0.60
	weightLength      = 0.30
	weightTrackNumber = 0.10

	// Length deltas at or below lengthExactMs score 1.0 — matches
	// the frontend's "subtle drift" threshold so anything the UI
	// hides also doesn't count against the score.  MB recording
	// lengths routinely differ from file durations by a few seconds
	// (encoder padding, different masters), so the grace band is
	// deliberately wider than perceptual accuracy; Picard tolerates
	// up to 30 s linearly and beets grants a flat 10 s grace.  Past
	// the threshold the penalty scales with delta / candidateMs
	// (i.e. percentage of candidate-track length): a 5 s delta on a
	// 4 min track is small, the same delta on a 30 s interlude is
	// huge.  At lengthFullyWrongPct of candidate length the score
	// hits zero; beyond that it stays clamped to zero.
	lengthExactMs       int64   = 5000
	lengthFullyWrongPct float64 = 0.20

	// A title below titleReject has too little signal for this
	// alignment to count as matched.
	titleReject = 0.60
)

// lengthScore returns 1.0 for deltas <= lengthExactMs, 0.0 for
// deltas >= lengthFullyWrongPct of the candidate length, linear
// in delta-as-percentage-of-candidate-length between.  When
// either side is zero (unknown), returns 0.5 so length is treated
// as neutral.
func lengthScore(localMs, candidateMs int64) float64 {
	if localMs <= 0 || candidateMs <= 0 {
		return 0.5
	}

	delta := localMs - candidateMs
	if delta < 0 {
		delta = -delta
	}

	if delta <= lengthExactMs {
		return 1.0
	}

	pct := float64(delta) / float64(candidateMs)
	if pct >= lengthFullyWrongPct {
		return 0.0
	}

	return 1.0 - pct/lengthFullyWrongPct
}

// trackNumberOK reports whether the local track number agrees with
// the candidate position (0 = unknown, never a match).
func trackNumberOK(local LocalTrack, cand CandidateTrack) bool {
	return local.TrackNumber > 0 && local.TrackNumber == cand.Position
}

// combineTrackScore folds the per-track components into one score.
// Split out so AlignTracks can compute the components once per pair
// and still share the exact formula with trackDistance.
func combineTrackScore(title, length float64, numberOK bool) float64 {
	var trackOK float64
	if numberOK {
		trackOK = 1.0
	}

	return title*weightTitle + length*weightLength + trackOK*weightTrackNumber
}

// trackDistance scores how well one local track aligns with one
// candidate track.  Higher is better.  Caller decides what to do
// with the result — this function has no threshold.
func trackDistance(local LocalTrack, cand CandidateTrack) float64 {
	return combineTrackScore(
		titleSimilarity(local.Title, cand.Title),
		lengthScore(local.LengthMillis, cand.LengthMillis),
		trackNumberOK(local, cand),
	)
}
