package library

import (
	"io"
	"strconv"
	"strings"

	"github.com/dhowden/tag"
)

// genreDelimiter is what we join multi-value genres with on the way into
// tracks.genre. It has to be one of the characters the read side already splits
// on — internal/taste and internal/recommendation both split on [;,], as do
// browse.sql, recommendation.sql and discover.sql. Storing values joined with
// ";" means the entire fix lands in the scanner and no query changes.
const genreDelimiter = ";"

// extractGenres returns the genre values for a file, normalised and
// deduplicated, ready to be joined with genreDelimiter.
//
// fellBack reports that an ID3v2 file's genre frame could not be parsed and the
// value came from dhowden/tag instead. That path yields the old welded string,
// so it is worth logging — but it is still the best available answer, and
// degrading to it beats storing no genre at all.
func extractGenres(meta tag.Metadata, rs io.ReadSeeker) (genres []string, fellBack bool) {
	switch meta.Format() {
	case tag.ID3v2_2, tag.ID3v2_3, tag.ID3v2_4:
		values, err := readID3v2GenreValues(rs)
		if err == nil {
			return normaliseGenres(values), false
		}
		// No frame at all is the common case for untagged files, and
		// dhowden/tag will have nothing either — not worth flagging.
		fellBack = meta.Genre() != ""
	default:
		// Vorbis comments (FLAC/OGG/Opus) and MP4 atoms don't go through
		// dhowden's welding path, so its value is already a faithful read of
		// the primary genre. Multi-value handling for those containers is a
		// separate, unproven concern — see #2500.
	}
	return normaliseGenres([]string{meta.Genre()}), fellBack
}

// normaliseGenres expands each raw value, then drops case-insensitive
// duplicates while keeping the first spelling seen. Duplicates are common once
// numeric references are resolved: "(40)AlternRock" declares the same genre
// twice, and so does a file tagged both "Rock" and "rock".
func normaliseGenres(values []string) []string {
	out := make([]string, 0, len(values))
	seen := make(map[string]struct{}, len(values))
	for _, v := range values {
		for _, g := range normaliseGenreValue(v) {
			key := strings.ToLower(g)
			if _, dup := seen[key]; dup {
				continue
			}
			seen[key] = struct{}{}
			out = append(out, g)
		}
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

// normaliseGenreValue turns one raw tag value into zero or more genre names,
// resolving the ID3 numeric-reference syntax.
//
// A value may be:
//   - plain text ("Alternative Rock") — passed through
//   - a bare ID3v1 index ("17") — resolved to "Rock". This is what the spec
//     says a numeric TCON means, and what ffmpeg does. It is why the operator's
//     library showed genres like "4017" and "526617": several numeric values
//     welded together by the old reader.
//   - ID3v2.3 refinement syntax ("(17)", "(51)(39)", "(17)Hard Rock", "(RX)")
//     — each parenthesised index becomes its own genre, and trailing text
//     becomes one more.
//
// Values that are numeric but out of range carry no meaning as a label, so they
// are dropped rather than stored as digits.
func normaliseGenreValue(v string) []string {
	v = strings.TrimSpace(v)
	if v == "" {
		return nil
	}

	var out []string
	for strings.HasPrefix(v, "(") {
		// "((" is the spec's escape for a literal "(" — the rest is plain text.
		if strings.HasPrefix(v, "((") {
			return append(out, trueUpCasing(strings.TrimSpace(v[1:])))
		}
		end := strings.IndexByte(v, ')')
		if end < 0 {
			break
		}
		inner := strings.TrimSpace(v[1:end])
		switch {
		case strings.EqualFold(inner, "RX"):
			out = append(out, "Remix")
		case strings.EqualFold(inner, "CR"):
			out = append(out, "Cover")
		default:
			n, err := strconv.Atoi(inner)
			if err != nil {
				// Parenthesised but not a reference, e.g. "(Live)". Keep the
				// whole remainder as written.
				return append(out, trueUpCasing(v))
			}
			if name, ok := id3v1GenreName(n); ok {
				out = append(out, name)
			}
		}
		v = strings.TrimSpace(v[end+1:])
	}

	if v == "" {
		return out
	}
	if n, err := strconv.Atoi(v); err == nil {
		if name, ok := id3v1GenreName(n); ok {
			// Canonical table name, deliberately NOT re-cased — see id3v1Genres.
			return append(out, name)
		}
		return out
	}
	return append(out, trueUpCasing(v))
}

// genreAcronyms are tokens that belong in caps. Tag editors that title-case the
// whole genre field turn "EDM" into "Edm" and "UK Garage" into "Uk Garage", and
// the operator's library carries all of these (#2468).
//
// Matched case-INSENSITIVELY, so "edm", "Edm" and "EDM" all land on "EDM". That
// is a deliberate, narrow exception to the project's rule that genre case is
// exposed as the file says it: "Rock" and "rock" still stay separate rows,
// because folding those would be a judgement about labels. Fixing a token that
// is unambiguously an initialism is not — there is no genre named "Edm".
//
// Kept short and evidence-led. Add a token here only when a real library shows
// it damaged; a speculative list risks mangling a word that legitimately looks
// like an acronym.
var genreAcronyms = map[string]string{
	"edm": "EDM",
	"idm": "IDM",
	"aor": "AOR",
	"uk":  "UK",
	"us":  "US",
	"ebm": "EBM",
}

// contractionSuffixes are the word-endings that follow an apostrophe in normal
// English. Title-casing capitalises the letter after ANY non-letter, which is
// how "Children's Music" became "Children'S Music".
//
// Deliberately a fixed list rather than "lowercase whatever follows an
// apostrophe": that broader rule would break "O'Brien" and "D'Angelo", which are
// correctly capitalised after the apostrophe.
var contractionSuffixes = map[string]bool{
	"s": true, "t": true, "re": true, "ll": true, "ve": true, "d": true, "m": true,
}

// trueUpCasing repairs casing damage done by tag editors that title-case the
// genre field. It only ever changes case, never the letters — so it cannot
// silently turn one genre into a different one, which is what separates it from
// the label-remapping idea #2468 rejected.
func trueUpCasing(s string) string {
	if s == "" {
		return s
	}
	words := strings.Split(s, " ")
	for i, w := range words {
		if w == "" {
			continue
		}
		// Match the word's letter core, not the raw word, so surrounding
		// punctuation doesn't hide the acronym: "(Edm)" and "Edm," both need
		// fixing, and re-attaching the trimmed edges keeps them intact.
		lead, core, trail := splitWordCore(w)
		if fixed, ok := genreAcronyms[strings.ToLower(core)]; ok {
			words[i] = lead + fixed + trail
			continue
		}
		words[i] = fixApostrophe(w)
	}
	return strings.Join(words, " ")
}

// splitWordCore peels leading and trailing non-alphanumerics off a word.
// Interior punctuation stays in the core, so "Lo-Fi" and "R&B" are compared
// whole rather than being split into fragments that might match by accident.
func splitWordCore(w string) (lead, core, trail string) {
	isCore := func(r rune) bool {
		return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
	}
	start := 0
	for start < len(w) && !isCore(rune(w[start])) {
		start++
	}
	end := len(w)
	for end > start && !isCore(rune(w[end-1])) {
		end--
	}
	return w[:start], w[start:end], w[end:]
}

// fixApostrophe lowercases a capitalised contraction or possessive suffix:
// "Children'S" -> "Children's". Leaves "O'Brien" alone, since "Brien" is not a
// contraction suffix.
func fixApostrophe(w string) string {
	idx := strings.LastIndexByte(w, '\'')
	if idx <= 0 || idx == len(w)-1 {
		return w
	}
	suffix := w[idx+1:]
	if !contractionSuffixes[strings.ToLower(suffix)] {
		return w
	}
	return w[:idx+1] + strings.ToLower(suffix)
}

func id3v1GenreName(n int) (string, bool) {
	if n < 0 || n >= len(id3v1Genres) {
		return "", false
	}
	return id3v1Genres[n], true
}

// id3v1Genres is the ID3v1 genre index: entries 0-79 are the original list,
// 80-125 were added by Winamp, and 126-191 later still. Index is meaningful, so
// never reorder or remove an entry — a numeric tag written years ago resolves
// through this table by position.
//
// Entry 133 is "Afro-Punk"; the 1990s list used a slur there, and no file in
// practice depends on the original spelling.
var id3v1Genres = []string{
	"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
	"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
	"Rap", "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska",
	"Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient",
	"Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical",
	"Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise",
	"AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative",
	"Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", "Darkwave",
	"Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream",
	"Southern Rock", "Comedy", "Cult", "Gangsta", "Top 40", "Christian Rap",
	"Pop/Funk", "Jungle", "Native American", "Cabaret", "New Wave",
	"Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
	"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll",
	"Hard Rock", "Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion",
	"Bebob", "Latin", "Revival", "Celtic", "Bluegrass", "Avantgarde",
	"Gothic Rock", "Progressive Rock", "Psychedelic Rock", "Symphonic Rock",
	"Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour",
	"Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony",
	"Booty Bass", "Primus", "Porn Groove", "Satire", "Slow Jam", "Club",
	"Tango", "Samba", "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul",
	"Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House",
	"Dance Hall", "Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror",
	"Indie", "BritPop", "Afro-Punk", "Polsk Punk", "Beat",
	"Christian Gangsta Rap", "Heavy Metal", "Black Metal", "Crossover",
	"Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
	"Thrash Metal", "Anime", "JPop", "Synthpop", "Abstract", "Art Rock",
	"Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout", "Downtempo",
	"Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo",
	"Experimental", "Garage", "Global", "IDM", "Illbient", "Industro-Goth",
	"Jam Band", "Krautrock", "Leftfield", "Lounge", "Math Rock",
	"New Romantic", "Nu-Breakz", "Post-Punk", "Post-Rock", "Psytrance",
	"Shoegaze", "Space Rock", "Trop Rock", "World Music", "Neoclassical",
	"Audiobook", "Audio Theatre", "Neue Deutsche Welle", "Podcast",
	"Indie Rock", "G-Funk", "Dubstep", "Garage Rock", "Psybient",
}
