package gobackend

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
)

// Native ID3v2 editor for MP3 files. Unlike the ffmpeg remux path, this
// rewrites only the tag block and preserves every foreign frame it does not
// control (POPM ratings, SYLT synced lyrics, CHAP/CTOC chapters, custom TXXX
// from other taggers). The audio bytes are streamed through untouched, and the
// whole file is published via temp+fsync+rename so a crash never corrupts it.

// id3RawFrame is a frame preserved verbatim (payload already de-unsynced and
// stripped of grouping/data-length prefixes, so it re-serializes with flags 0).
type id3RawFrame struct {
	id      string
	payload []byte
}

// id3v22FrameIDs maps ID3v2.2 3-char IDs to their v2.4 equivalents.
var id3v22FrameIDs = map[string]string{
	"TT2": "TIT2", "TP1": "TPE1", "TP2": "TPE2", "TAL": "TALB",
	"TYE": "TDRC", "TCO": "TCON", "TRK": "TRCK", "TPA": "TPOS",
	"TCM": "TCOM", "TPB": "TPUB", "TCR": "TCOP", "TXX": "TXXX",
	"ULT": "USLT", "COM": "COMM",
}

// readMP3ID3v2Frames parses the leading ID3v2 tag of an MP3 into raw frames
// and returns the offset where the audio data begins. A file without an ID3v2
// tag yields no frames and audioStart 0. Compressed/encrypted frames cannot be
// re-serialized safely and are dropped.
func readMP3ID3v2Frames(f *os.File) (frames []id3RawFrame, audioStart int64, err error) {
	header := make([]byte, 10)
	n, err := f.ReadAt(header, 0)
	if err != nil && err != io.EOF {
		return nil, 0, err
	}
	if n < 10 || string(header[0:3]) != "ID3" {
		return nil, 0, nil
	}

	majorVersion := header[3]
	flags := header[5]
	tagUnsync := (flags & 0x80) != 0
	extendedHeader := (flags & 0x40) != 0
	footerPresent := (flags & 0x10) != 0

	size := synchsafeDecode(header[6:10])
	if size <= 0 {
		return nil, 0, fmt.Errorf("invalid ID3v2 tag size")
	}
	audioStart = int64(10 + size)
	if footerPresent {
		audioStart += 10
	}

	tagData := make([]byte, size)
	if _, err := f.ReadAt(tagData, 10); err != nil {
		return nil, 0, fmt.Errorf("truncated ID3v2 tag: %w", err)
	}
	if footerPresent && len(tagData) >= 10 {
		footerStart := len(tagData) - 10
		if string(tagData[footerStart:footerStart+3]) == "3DI" {
			tagData = tagData[:footerStart]
		}
	}
	if extendedHeader {
		if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) {
			tagData = tagData[skip:]
		}
	}

	if majorVersion == 2 {
		return parseID3v22RawFrames(tagData, tagUnsync), audioStart, nil
	}
	return parseID3v2xRawFrames(tagData, majorVersion, tagUnsync), audioStart, nil
}

func parseID3v22RawFrames(data []byte, tagUnsync bool) []id3RawFrame {
	var frames []id3RawFrame
	pos := 0
	for pos+6 < len(data) {
		id := string(data[pos : pos+3])
		if id[0] == 0 {
			break
		}
		size := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5])
		if size <= 0 || pos+6+size > len(data) {
			break
		}
		payload := data[pos+6 : pos+6+size]
		if tagUnsync {
			payload = removeUnsync(payload)
		}
		pos += 6 + size

		if id == "PIC" {
			if converted := convertPICToAPIC(payload); converted != nil {
				frames = append(frames, id3RawFrame{id: "APIC", payload: converted})
			}
			continue
		}
		mapped, ok := id3v22FrameIDs[id]
		if !ok {
			continue // no safe v2.4 equivalent
		}
		frames = append(frames, id3RawFrame{id: mapped, payload: append([]byte(nil), payload...)})
	}
	return frames
}

// convertPICToAPIC rewrites a v2.2 PIC payload (3-char image format) into the
// v2.3+ APIC layout (null-terminated MIME string). Returns nil when malformed.
func convertPICToAPIC(payload []byte) []byte {
	if len(payload) < 5 {
		return nil
	}
	mime := "image/jpeg"
	if strings.EqualFold(string(payload[1:4]), "PNG") {
		mime = "image/png"
	}
	out := []byte{payload[0]}
	out = append(out, []byte(mime)...)
	out = append(out, 0x00)
	out = append(out, payload[4:]...) // picture type + description + data
	return out
}

func parseID3v2xRawFrames(data []byte, version byte, tagUnsync bool) []id3RawFrame {
	var frames []id3RawFrame
	pos := 0
	for pos+10 < len(data) {
		id := string(data[pos : pos+4])
		if id[0] == 0 {
			break
		}
		var size int
		if version == 4 {
			size = synchsafeDecode(data[pos+4 : pos+8])
		} else {
			size = int(data[pos+4])<<24 | int(data[pos+5])<<16 | int(data[pos+6])<<8 | int(data[pos+7])
		}
		if size <= 0 || pos+10+size > len(data) {
			break
		}
		payload := data[pos+10 : pos+10+size]
		formatFlags := data[pos+9]
		pos += 10 + size

		if version == 3 {
			if formatFlags&(0x80|0x40) != 0 { // compression | encryption
				continue
			}
			if formatFlags&0x20 != 0 { // grouping
				if len(payload) < 1 {
					continue
				}
				payload = payload[1:]
			}
			if tagUnsync {
				payload = removeUnsync(payload)
			}
		} else {
			if formatFlags&(0x08|0x04) != 0 { // compression | encryption
				continue
			}
			if formatFlags&0x40 != 0 { // grouping
				if len(payload) < 1 {
					continue
				}
				payload = payload[1:]
			}
			if formatFlags&0x01 != 0 { // data length indicator
				if len(payload) < 4 {
					continue
				}
				payload = payload[4:]
			}
			if formatFlags&0x02 != 0 || tagUnsync { // frame unsync
				payload = removeUnsync(payload)
			}
		}
		frames = append(frames, id3RawFrame{id: id, payload: append([]byte(nil), payload...)})
	}
	return frames
}

func id3TextPayload(value string) []byte {
	return append([]byte{0x03}, []byte(value)...) // UTF-8
}

func id3LangTextPayload(text string) []byte {
	payload := []byte{0x03}
	payload = append(payload, []byte("eng")...)
	payload = append(payload, 0x00) // empty description
	payload = append(payload, []byte(text)...)
	return payload
}

func id3TXXXPayload(desc, value string) []byte {
	payload := []byte{0x03}
	payload = append(payload, []byte(desc)...)
	payload = append(payload, 0x00)
	payload = append(payload, []byte(value)...)
	return payload
}

// firstFrameText returns the decoded text of the first frame with the given ID.
func firstFrameText(frames []id3RawFrame, id string) string {
	for _, fr := range frames {
		if fr.id == id {
			return firstTextValue(extractTextFrame(fr.payload))
		}
	}
	return ""
}

// EditMP3Fields updates only the ID3v2 frames whose keys are explicitly present
// in the fields map (set-or-clear semantics, mirroring EditFlacFields) while
// preserving every other frame byte-for-byte. The tag is rewritten as ID3v2.4.
func EditMP3Fields(filePath string, fields map[string]string) error {
	f, err := os.Open(filePath)
	if err != nil {
		return err
	}
	frames, audioStart, err := readMP3ID3v2Frames(f)
	if err != nil {
		f.Close()
		return err
	}

	drop := map[string]bool{}
	var added []id3RawFrame

	setOrClear := func(frameID, value string, aliases ...string) {
		drop[frameID] = true
		for _, alias := range aliases {
			drop[alias] = true
		}
		if strings.TrimSpace(value) != "" {
			added = append(added, id3RawFrame{id: frameID, payload: id3TextPayload(value)})
		}
	}

	simpleKeys := []struct {
		fieldKey string
		frameID  string
		aliases  []string
	}{
		{"title", "TIT2", nil},
		{"artist", "TPE1", nil},
		{"album", "TALB", nil},
		{"album_artist", "TPE2", nil},
		{"date", "TDRC", []string{"TYER", "TDAT", "TIME"}},
		{"genre", "TCON", nil},
		{"label", "TPUB", nil},
		{"copyright", "TCOP", nil},
		{"composer", "TCOM", nil},
		{"isrc", "TSRC", nil},
	}
	for _, key := range simpleKeys {
		if v, ok := fields[key.fieldKey]; ok {
			setOrClear(key.frameID, v, key.aliases...)
		}
	}

	if v, ok := fields["comment"]; ok {
		drop["COMM"] = true
		if strings.TrimSpace(v) != "" {
			added = append(added, id3RawFrame{id: "COMM", payload: id3LangTextPayload(v)})
		}
	}
	if v, ok := fields["lyrics"]; ok {
		drop["USLT"] = true // synced SYLT frames are intentionally preserved
		if strings.TrimSpace(v) != "" {
			added = append(added, id3RawFrame{id: "USLT", payload: id3LangTextPayload(v)})
		}
	}

	// Track/disc numbers: merge with the current value when only one half is
	// edited, mirroring the FLAC editor's semantics.
	if hasMapKey(fields, "track_number") || hasMapKey(fields, "track_total") {
		num, total := parseIndexPair(firstFrameText(frames, "TRCK"))
		if v, ok := fields["track_number"]; ok {
			num = parsePositiveInt(v)
		}
		if v, ok := fields["track_total"]; ok {
			total = parsePositiveInt(v)
		}
		setOrClear("TRCK", formatIndexValue(num, total))
	}
	if hasMapKey(fields, "disc_number") || hasMapKey(fields, "disc_total") {
		num, total := parseIndexPair(firstFrameText(frames, "TPOS"))
		if v, ok := fields["disc_number"]; ok {
			num = parsePositiveInt(v)
		}
		if v, ok := fields["disc_total"]; ok {
			total = parsePositiveInt(v)
		}
		setOrClear("TPOS", formatIndexValue(num, total))
	}

	// ReplayGain lives in TXXX frames matched by description; only the edited
	// descriptions are dropped so foreign TXXX frames survive.
	dropTXXXDesc := map[string]bool{}
	for _, key := range []string{
		"replaygain_track_gain", "replaygain_track_peak",
		"replaygain_album_gain", "replaygain_album_peak",
	} {
		if v, ok := fields[key]; ok {
			desc := strings.ToUpper(key)
			dropTXXXDesc[desc] = true
			if strings.TrimSpace(v) != "" {
				added = append(added, id3RawFrame{id: "TXXX", payload: id3TXXXPayload(desc, v)})
			}
		}
	}

	// Release identity (advisory/type/barcode) also lives in TXXX frames so
	// any tagger can read it back; compilation uses the iTunes TCMP frame.
	txxxDescriptions := map[string]string{
		"explicit":   "ITUNESADVISORY",
		"album_type": "RELEASETYPE",
		"upc":        "BARCODE",
	}
	for fieldKey, desc := range txxxDescriptions {
		if v, ok := fields[fieldKey]; ok {
			dropTXXXDesc[desc] = true
			if strings.TrimSpace(v) != "" {
				added = append(added, id3RawFrame{id: "TXXX", payload: id3TXXXPayload(desc, v)})
			}
		}
	}
	if v, ok := fields["compilation"]; ok {
		setOrClear("TCMP", v)
	}

	coverPath := strings.TrimSpace(fields["cover_path"])
	if coverPath != "" {
		if coverData, err := os.ReadFile(coverPath); err == nil && len(coverData) > 0 {
			drop["APIC"] = true
			mime := http.DetectContentType(coverData)
			payload := []byte{0x03}
			payload = append(payload, []byte(mime)...)
			payload = append(payload, 0x00, 0x03, 0x00) // front cover, no description
			payload = append(payload, coverData...)
			added = append(added, id3RawFrame{id: "APIC", payload: payload})
		}
	}

	var kept []id3RawFrame
	for _, fr := range frames {
		if drop[fr.id] {
			continue
		}
		if fr.id == "TXXX" && len(dropTXXXDesc) > 0 {
			desc, _ := extractUserTextFrame(fr.payload)
			if dropTXXXDesc[strings.ToUpper(strings.TrimSpace(desc))] {
				continue
			}
		}
		kept = append(kept, fr)
	}
	kept = append(kept, added...)

	tag := serializeID3v24Tag(kept)
	err = spliceFileAtomic(filePath, f, tag, audioStart)
	f.Close() // harmless double close when the splice already closed it
	return err
}

func serializeID3v24Tag(frames []id3RawFrame) []byte {
	var body bytes.Buffer
	for _, fr := range frames {
		body.WriteString(fr.id)
		body.Write(synchsafeEncode(len(fr.payload)))
		body.Write([]byte{0, 0})
		body.Write(fr.payload)
	}
	const padding = 512
	var out bytes.Buffer
	out.WriteString("ID3")
	out.Write([]byte{0x04, 0x00, 0x00})
	out.Write(synchsafeEncode(body.Len() + padding))
	out.Write(body.Bytes())
	out.Write(make([]byte, padding))
	return out.Bytes()
}

// spliceFileAtomic writes head followed by src's bytes from tailStart onward to
// a sibling temp, fsyncs, and renames over filePath. On success src (the open
// original) is closed before the rename, which Windows requires.
func spliceFileAtomic(filePath string, src *os.File, head []byte, tailStart int64) error {
	tmpPath := filePath + ".tag.partial"
	os.Remove(tmpPath)
	tmp, err := os.Create(tmpPath)
	if err != nil {
		return err
	}
	cleanup := func() {
		tmp.Close()
		os.Remove(tmpPath)
	}
	if _, err := tmp.Write(head); err != nil {
		cleanup()
		return err
	}
	if _, err := src.Seek(tailStart, io.SeekStart); err != nil {
		cleanup()
		return err
	}
	if _, err := io.Copy(tmp, src); err != nil {
		cleanup()
		return err
	}
	if err := tmp.Sync(); err != nil {
		cleanup()
		return err
	}
	if err := tmp.Close(); err != nil {
		os.Remove(tmpPath)
		return err
	}
	// Release the source handle before replacing the file (required on Windows).
	src.Close()
	if err := os.Rename(tmpPath, filePath); err != nil {
		os.Remove(tmpPath)
		return err
	}
	syncDir(filepath.Dir(filePath))
	return nil
}
