package gobackend

import (
	"bytes"
	"encoding/binary"
	"fmt"
	"os"
	"strings"
)

// Native editor for the standard iTunes ilst atoms plus freeform tags, in one
// atomic pass. Unlike the ffmpeg remux path it preserves every atom it does
// not control, fixes stco/co64 chunk offsets when moov precedes mdat, and
// publishes via temp+fsync+rename.

// m4aTextAtoms maps editor field keys to iTunes text atom types.
var m4aTextAtoms = map[string]string{
	"title":        "\xa9nam",
	"artist":       "\xa9ART",
	"album":        "\xa9alb",
	"album_artist": "aART",
	"date":         "\xa9day",
	"genre":        "\xa9gen",
	"composer":     "\xa9wrt",
	"comment":      "\xa9cmt",
	"copyright":    "cprt",
	"lyrics":       "\xa9lyr",
}

func buildM4ADataAtom(dataType uint32, payload []byte) []byte {
	buf := make([]byte, 8+len(payload))
	binary.BigEndian.PutUint32(buf[0:4], dataType)
	copy(buf[8:], payload)
	return buildM4AAtom("data", buf)
}

func buildM4ATextAtom(typ, value string) []byte {
	return buildM4AAtom(typ, buildM4ADataAtom(1, []byte(value)))
}

func buildM4AIndexAtom(typ string, num, total int) []byte {
	size := 8 // trkn: pad(2) num(2) total(2) pad(2)
	if typ == "disk" {
		size = 6
	}
	payload := make([]byte, size)
	binary.BigEndian.PutUint16(payload[2:4], uint16(num))
	binary.BigEndian.PutUint16(payload[4:6], uint16(total))
	return buildM4AAtom(typ, buildM4ADataAtom(0, payload))
}

func buildM4ACoverAtom(coverData []byte) []byte {
	dataType := uint32(13) // JPEG
	if bytes.HasPrefix(coverData, []byte("\x89PNG")) {
		dataType = 14
	}
	return buildM4AAtom("covr", buildM4ADataAtom(dataType, coverData))
}

// buildM4AFlagAtom writes an iTunes boolean atom (cpil-style, data type 22)
// whose payload is 1 when set.
func buildM4AFlagAtom(typ string, set bool) []byte {
	payload := []byte{0}
	if set {
		payload[0] = 1
	}
	return buildM4AAtom(typ, buildM4ADataAtom(22, payload))
}

// buildM4AInt8Atom writes an iTunes 8-bit integer atom (rtng-style, data
// type 21).
func buildM4AInt8Atom(typ string, value int) []byte {
	payload := []byte{byte(value)}
	return buildM4AAtom(typ, buildM4ADataAtom(21, payload))
}

type m4aIlstLocation struct {
	moov mp4Box
	ilst mp4Box
	// ancestors whose size field must grow when ilst changes size; all start
	// before ilst so their offsets survive the splice.
	ancestors []mp4Box
}

// locateM4AIlstInBuf finds moov > [udta >] meta > ilst. The meta box is a full
// box, so its children start 4 bytes into the body.
func locateM4AIlstInBuf(data []byte) (m4aIlstLocation, bool) {
	n := int64(len(data))
	moov, ok := findChildMP4(data, 0, n, "moov")
	if !ok {
		return m4aIlstLocation{}, false
	}
	if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
		if meta, ok := findChildMP4(data, udta.body(), udta.end(), "meta"); ok {
			if ilst, ok := findChildMP4(data, meta.body()+4, meta.end(), "ilst"); ok {
				return m4aIlstLocation{moov: moov, ilst: ilst, ancestors: []mp4Box{moov, udta, meta}}, true
			}
		}
	}
	if meta, ok := findChildMP4(data, moov.body(), moov.end(), "meta"); ok {
		if ilst, ok := findChildMP4(data, meta.body()+4, meta.end(), "ilst"); ok {
			return m4aIlstLocation{moov: moov, ilst: ilst, ancestors: []mp4Box{moov, meta}}, true
		}
	}
	return m4aIlstLocation{}, false
}

// buildM4AMetaBox returns a meta full box containing the iTunes mdir hdlr and
// an empty ilst.
func buildM4AMetaBox() []byte {
	hdlrPayload := make([]byte, 25) // ver/flags + pre_defined + handler + 3x reserved + empty name
	copy(hdlrPayload[8:12], "mdir")
	copy(hdlrPayload[12:16], "appl")
	body := append([]byte{0, 0, 0, 0}, buildM4AAtom("hdlr", hdlrPayload)...)
	body = append(body, buildM4AAtom("ilst", nil)...)
	return buildM4AAtom("meta", body)
}

// ensureM4AIlstInBuf returns a buffer guaranteed to contain the
// moov>[udta>]meta>ilst chain, creating the missing tail at the end of the
// deepest existing ancestor. Chunk offsets are shifted for the inserted bytes;
// base is the buffer's absolute file offset (0 for a whole-file buffer) so the
// shift compares against the absolute positions stco/co64 entries hold.
func ensureM4AIlstInBuf(data []byte, base int64) ([]byte, m4aIlstLocation, error) {
	if loc, ok := locateM4AIlstInBuf(data); ok {
		return data, loc, nil
	}

	n := int64(len(data))
	moov, ok := findChildMP4(data, 0, n, "moov")
	if !ok {
		return nil, m4aIlstLocation{}, fmt.Errorf("moov not found")
	}

	var insertPos int64
	var insert []byte
	var grow []mp4Box

	if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
		if meta, ok := findChildMP4(data, udta.body(), udta.end(), "meta"); ok {
			insertPos = meta.end()
			insert = buildM4AAtom("ilst", nil)
			grow = []mp4Box{moov, udta, meta}
		} else {
			insertPos = udta.end()
			insert = buildM4AMetaBox()
			grow = []mp4Box{moov, udta}
		}
	} else {
		insertPos = moov.end()
		insert = buildM4AAtom("udta", buildM4AMetaBox())
		grow = []mp4Box{moov}
	}

	updated := make([]byte, 0, len(data)+len(insert))
	updated = append(updated, data[:insertPos]...)
	updated = append(updated, insert...)
	updated = append(updated, data[insertPos:]...)

	delta := int64(len(insert))
	for _, b := range grow {
		growBoxSize(updated, b, delta)
	}
	if newMoov, ok := findChildMP4(updated, 0, int64(len(updated)), "moov"); ok {
		shiftChunkOffsets(updated, newMoov, base+insertPos, delta)
	}

	loc, ok := locateM4AIlstInBuf(updated)
	if !ok {
		return nil, m4aIlstLocation{}, fmt.Errorf("failed to create ilst")
	}
	return updated, loc, nil
}

// m4aFreeformNameInBuf extracts the freeform ("----") atom's name from a
// buffer-backed child box.
func m4aFreeformNameInBuf(data []byte, box mp4Box) string {
	pos := box.body()
	for pos+8 <= box.end() {
		child, ok := readMP4Box(data, pos)
		if !ok {
			return ""
		}
		if child.typ == "name" && child.size > child.hdr+4 {
			raw := data[child.body()+4 : child.end()]
			return strings.TrimSpace(strings.TrimRight(string(raw), "\x00"))
		}
		pos = child.end()
	}
	return ""
}

// m4aIndexPairInBuf reads the (number, total) pair from a trkn/disk box.
func m4aIndexPairInBuf(data []byte, box mp4Box) (int, int) {
	pos := box.body()
	for pos+8 <= box.end() {
		child, ok := readMP4Box(data, pos)
		if !ok {
			return 0, 0
		}
		if child.typ == "data" && child.size >= child.hdr+8+6 {
			payload := data[child.body()+8 : child.end()]
			return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6]))
		}
		pos = child.end()
	}
	return 0, 0
}

// isTruthyTagValue reports whether a fields-map flag value means set/true.
func isTruthyTagValue(v string) bool {
	switch strings.ToLower(strings.TrimSpace(v)) {
	case "1", "true", "yes", "explicit":
		return true
	}
	return false
}

// EditM4AFields updates only the ilst entries whose keys are explicitly
// present in the fields map (set-or-clear semantics, mirroring EditFlacFields)
// while preserving every other atom. Standard atoms, freeform ISRC/LABEL, and
// ReplayGain freeform tags are all written in a single file rewrite. Only the
// moov box is held in memory; the audio bulk is streamed.
func EditM4AFields(filePath string, fields map[string]string) error {
	f, err := os.Open(filePath)
	if err != nil {
		return err
	}
	info, err := f.Stat()
	if err != nil {
		f.Close()
		return err
	}
	moovBuf, moovOffset, found, err := loadTopLevelMP4Box(f, info.Size(), "moov")
	f.Close()
	if err != nil {
		return err
	}
	if !found {
		return fmt.Errorf("moov not found")
	}
	moovLen := int64(len(moovBuf))

	data, loc, err := ensureM4AIlstInBuf(moovBuf, moovOffset)
	if err != nil {
		return err
	}

	dropStandard := map[string]bool{}
	var appended []byte

	for fieldKey, atomType := range m4aTextAtoms {
		v, ok := fields[fieldKey]
		if !ok {
			continue
		}
		dropStandard[atomType] = true
		if fieldKey == "genre" {
			dropStandard["gnre"] = true // legacy numeric genre
		}
		if strings.TrimSpace(v) != "" {
			appended = append(appended, buildM4ATextAtom(atomType, v)...)
		}
	}

	// Freeform tags: ISRC, LABEL, ReplayGain (+iTunNORM).
	removeFreeform := map[string]struct{}{}
	var freeformTags []m4aFreeformTag
	if _, ok := fields["isrc"]; ok {
		removeFreeform["ISRC"] = struct{}{}
		freeformTags = append(freeformTags, m4aFreeformTag{name: "ISRC", value: strings.TrimSpace(fields["isrc"])})
	}
	if _, ok := fields["label"]; ok {
		removeFreeform["LABEL"] = struct{}{}
		removeFreeform["ORGANIZATION"] = struct{}{}
		freeformTags = append(freeformTags, m4aFreeformTag{name: "LABEL", value: strings.TrimSpace(fields["label"])})
	}
	if v, ok := fields["album_type"]; ok {
		removeFreeform["RELEASETYPE"] = struct{}{}
		freeformTags = append(freeformTags, m4aFreeformTag{name: "RELEASETYPE", value: strings.TrimSpace(v)})
	}
	if v, ok := fields["upc"]; ok {
		removeFreeform["BARCODE"] = struct{}{}
		freeformTags = append(freeformTags, m4aFreeformTag{name: "BARCODE", value: strings.TrimSpace(v)})
	}
	// Content advisory (rtng) and compilation (cpil) are integer/boolean
	// atoms rather than text.
	if v, ok := fields["explicit"]; ok {
		dropStandard["rtng"] = true
		if isTruthyTagValue(v) {
			appended = append(appended, buildM4AInt8Atom("rtng", 1)...)
		}
	}
	if v, ok := fields["compilation"]; ok {
		dropStandard["cpil"] = true
		if isTruthyTagValue(v) {
			appended = append(appended, buildM4AFlagAtom("cpil", true)...)
		}
	}
	replayGain := collectM4AReplayGainFields(fields)
	if len(replayGain) > 0 {
		for _, key := range []string{"replaygain_track_gain", "replaygain_track_peak", "replaygain_album_gain", "replaygain_album_peak"} {
			removeFreeform[strings.ToUpper(key)] = struct{}{}
			if value := replayGain[key]; value != "" {
				freeformTags = append(freeformTags, m4aFreeformTag{name: key, value: value})
			}
		}
		removeFreeform["ITUNNORM"] = struct{}{}
		if norm := replayGain["iTunNORM"]; norm != "" {
			freeformTags = append(freeformTags, m4aFreeformTag{name: "iTunNORM", value: norm})
		}
	}
	for _, tag := range freeformTags {
		if tag.value != "" {
			appended = append(appended, buildM4AFreeformAtom(tag.name, tag.value)...)
		}
	}

	editTrack := hasMapKey(fields, "track_number") || hasMapKey(fields, "track_total")
	editDisc := hasMapKey(fields, "disc_number") || hasMapKey(fields, "disc_total")

	coverPath := strings.TrimSpace(fields["cover_path"])
	var coverData []byte
	if coverPath != "" {
		if b, err := os.ReadFile(coverPath); err == nil && len(b) > 0 {
			coverData = b
			dropStandard["covr"] = true
		}
	}

	// Rebuild the ilst body, keeping untouched children verbatim.
	var newBody []byte
	curTrack, curTrackTotal := 0, 0
	curDisc, curDiscTotal := 0, 0
	for pos := loc.ilst.body(); pos+8 <= loc.ilst.end(); {
		child, ok := readMP4Box(data, pos)
		if !ok {
			return fmt.Errorf("malformed ilst child at %d", pos)
		}
		keep := true
		switch {
		case dropStandard[child.typ]:
			keep = false
		case child.typ == "trkn":
			curTrack, curTrackTotal = m4aIndexPairInBuf(data, child)
			keep = !editTrack
		case child.typ == "disk":
			curDisc, curDiscTotal = m4aIndexPairInBuf(data, child)
			keep = !editDisc
		case child.typ == "----":
			name := strings.ToUpper(m4aFreeformNameInBuf(data, child))
			if _, remove := removeFreeform[name]; remove {
				keep = false
			}
		}
		if keep {
			newBody = append(newBody, data[child.offset:child.end()]...)
		}
		pos = child.end()
	}

	if editTrack {
		if v, ok := fields["track_number"]; ok {
			curTrack = parsePositiveInt(v)
		}
		if v, ok := fields["track_total"]; ok {
			curTrackTotal = parsePositiveInt(v)
		}
		if curTrack > 0 {
			appended = append(appended, buildM4AIndexAtom("trkn", curTrack, curTrackTotal)...)
		}
	}
	if editDisc {
		if v, ok := fields["disc_number"]; ok {
			curDisc = parsePositiveInt(v)
		}
		if v, ok := fields["disc_total"]; ok {
			curDiscTotal = parsePositiveInt(v)
		}
		if curDisc > 0 {
			appended = append(appended, buildM4AIndexAtom("disk", curDisc, curDiscTotal)...)
		}
	}
	if len(coverData) > 0 {
		appended = append(appended, buildM4ACoverAtom(coverData)...)
	}
	newBody = append(newBody, appended...)

	newIlst := buildM4AAtom("ilst", newBody)
	delta := int64(len(newIlst)) - loc.ilst.size

	updated := make([]byte, 0, int64(len(data))+delta)
	updated = append(updated, data[:loc.ilst.offset]...)
	updated = append(updated, newIlst...)
	updated = append(updated, data[loc.ilst.end():]...)

	for _, b := range loc.ancestors {
		growBoxSize(updated, b, delta)
	}
	if moov, ok := findChildMP4(updated, 0, int64(len(updated)), "moov"); ok {
		shiftChunkOffsets(updated, moov, moovOffset+loc.ilst.offset, delta)
	}

	return replaceFileSectionsStreaming(filePath, []fileSection{
		{start: moovOffset, end: moovOffset + moovLen, data: updated},
	})
}
