// Package dayzxml merges mod XML snippets into a running DayZ server's
// Central Economy mission XMLs (types.xml, events.xml, cfgspawnabletypes.xml,
// cfgeventspawns.xml, cfgrandompresets.xml, messages.xml).
//
// Why this exists:
//
// Mods almost always ship "snippet" XMLs — a types.xml with just the mod's new
// items, or an events.xml with the mod's new dynamic events. Operators are
// expected to merge these into their base mission files. The canonical
// approach Bohemia documents is to register each mod's folder via
// cfgeconomycore.xml and let the engine merge at load time, but most community
// operators still pre-merge into a single file per kind to make debugging,
// diffing, and rolling back easier. That's what this package does.
//
// Merge semantics (per Bohemia wiki "DayZ:Central Economy mission files modding"):
//
//   - types.xml, cfgspawnabletypes.xml, cfgeventspawns.xml, cfgrandompresets.xml
//       → replace existing entry by `name` attribute; new names appended.
//   - events.xml
//       → replace the entry's scalar children; APPEND new <child> nodes (events'
//         children list is append-only in engine merge behavior — there's no
//         "remove a child" via merge; operators must redefine the entire event).
//   - messages.xml
//       → append-only; messages don't have a unique key and there's no
//         replace-by-id semantic in the engine.
//
// We use github.com/beevik/etree rather than Go's encoding/xml so element
// order, attribute order, and comments are preserved verbatim. DayZ's
// engine doesn't care about order, but operators diff these files by hand
// and expect them to stay stable across panel edits.

package dayzxml

import (
	"bytes"
	"fmt"
	"sort"
	"strings"

	"github.com/beevik/etree"
)

// FileKind identifies a DayZ CE XML file type whose merge semantics we know.
type FileKind string

const (
	KindTypes          FileKind = "types"          // types.xml
	KindSpawnableTypes FileKind = "spawnabletypes" // cfgspawnabletypes.xml
	KindEvents         FileKind = "events"         // events.xml
	KindEventSpawns    FileKind = "eventposdef"    // cfgeventspawns.xml
	KindRandomPresets  FileKind = "randompresets"  // cfgrandompresets.xml
	KindMessages       FileKind = "messages"       // messages.xml
)

// MergeMode controls what happens when a snippet entry collides with an existing one.
type MergeMode int

const (
	// ReplaceByName overwrites the existing entry wholesale.
	ReplaceByName MergeMode = iota
	// AppendOnly ignores the existing entry and just appends the snippet's copy.
	// Used for files without a unique key (messages.xml).
	AppendOnly
	// EventsMerge replaces the entry's scalar attributes + non-child descendants,
	// and APPENDS <child> nodes from the snippet that don't already exist in
	// base (matched by `type=` attribute). Matches engine behavior for events.xml.
	EventsMerge
)

// Spec describes how to identify and merge entries in one file kind.
type Spec struct {
	RootTag  string      // expected root element tag ("types", "events", ...)
	EntryTag string      // entry element tag ("type", "event"); empty = heterogeneous
	KeyAttr  string      // attribute that identifies an entry ("name"); empty = no key
	Mode     MergeMode   // how to resolve collisions
	EntryTags []string   // for heterogeneous files (randompresets: "cargo"/"attachments")
}

// defaultSpecs holds the canonical spec per kind. Callers should treat
// these as read-only.
var defaultSpecs = map[FileKind]Spec{
	KindTypes:          {RootTag: "types", EntryTag: "type", KeyAttr: "name", Mode: ReplaceByName},
	KindSpawnableTypes: {RootTag: "spawnabletypes", EntryTag: "type", KeyAttr: "name", Mode: ReplaceByName},
	KindEvents:         {RootTag: "events", EntryTag: "event", KeyAttr: "name", Mode: EventsMerge},
	KindEventSpawns:    {RootTag: "eventposdef", EntryTag: "event", KeyAttr: "name", Mode: ReplaceByName},
	KindRandomPresets:  {RootTag: "randompresets", KeyAttr: "name", Mode: ReplaceByName, EntryTags: []string{"cargo", "attachments"}},
	KindMessages:       {RootTag: "messages", EntryTag: "message", Mode: AppendOnly},
}

// SpecFor returns the default Spec for a file kind, or zero + false if unknown.
func SpecFor(k FileKind) (Spec, bool) {
	s, ok := defaultSpecs[k]
	return s, ok
}

// SupportedKinds returns all FileKinds this package knows how to merge, in
// a stable order suitable for UI lists.
func SupportedKinds() []FileKind {
	return []FileKind{
		KindTypes, KindSpawnableTypes, KindEvents, KindEventSpawns,
		KindRandomPresets, KindMessages,
	}
}

// Plan is the outcome of a (possibly dry-run) merge.
type Plan struct {
	Kind      FileKind `json:"kind"`
	Added     []string `json:"added"`              // new entry names appended
	Replaced  []string `json:"replaced"`           // existing names overwritten
	Unchanged []string `json:"unchanged"`          // snippet entries byte-identical to base
	AppendedChildren map[string][]string `json:"appended_children,omitempty"` // event name → child types added
	Conflicts []string `json:"conflicts,omitempty"` // snippet has same key twice; first wins
	Warnings  []string `json:"warnings,omitempty"`  // non-fatal (BOM stripped, EOL adjusted, etc.)
}

// Result is returned by Merge. Bytes is the merged XML (empty in dry-run mode).
type Result struct {
	Plan  Plan
	Bytes []byte
}

// DetectKind classifies a raw snippet by its root element tag. Returns
// the matching FileKind or an error if the root doesn't match any known spec.
func DetectKind(snippet []byte) (FileKind, error) {
	doc := etree.NewDocument()
	if err := doc.ReadFromBytes(stripBOM(snippet)); err != nil {
		return "", fmt.Errorf("parse snippet: %w", err)
	}
	root := doc.Root()
	if root == nil {
		return "", fmt.Errorf("snippet has no root element")
	}
	for k, s := range defaultSpecs {
		if s.RootTag == root.Tag {
			return k, nil
		}
	}
	return "", fmt.Errorf("unknown root element %q — not a supported DayZ CE XML", root.Tag)
}

// Merge applies the snippet to the base file. If dryRun is true, only the
// Plan is populated (Bytes is nil). Otherwise Bytes contains the merged
// XML, ready to write.
func Merge(kind FileKind, base, snippet []byte, dryRun bool) (*Result, error) {
	spec, ok := defaultSpecs[kind]
	if !ok {
		return nil, fmt.Errorf("unsupported file kind %q", kind)
	}
	baseDoc, warnings, err := parseDoc(base, "base")
	if err != nil {
		return nil, err
	}
	snipDoc, snipWarnings, err := parseDoc(snippet, "snippet")
	if err != nil {
		return nil, err
	}
	warnings = append(warnings, snipWarnings...)

	baseRoot := baseDoc.Root()
	if baseRoot == nil || baseRoot.Tag != spec.RootTag {
		return nil, fmt.Errorf("base is not a <%s> file (got <%s>)", spec.RootTag, safeRootTag(baseDoc))
	}
	snipRoot := snipDoc.Root()
	if snipRoot == nil || snipRoot.Tag != spec.RootTag {
		return nil, fmt.Errorf("snippet is not a <%s> file (got <%s>)", spec.RootTag, safeRootTag(snipDoc))
	}

	plan := Plan{
		Kind:             kind,
		AppendedChildren: map[string][]string{},
	}

	// Index base entries by key, preserving insertion order.
	baseIndex, baseOrder := indexEntries(baseRoot, spec)
	// Walk the snippet in order.
	seenInSnippet := map[string]bool{}

	for _, child := range snipRoot.ChildElements() {
		if !matchesEntry(child, spec) {
			continue
		}
		key := entryKey(child, spec)
		if spec.Mode == AppendOnly || key == "" {
			// No dedup; append unconditionally.
			baseRoot.AddChild(child.Copy())
			plan.Added = append(plan.Added, fmt.Sprintf("%s#%d", child.Tag, len(plan.Added)+1))
			continue
		}
		if seenInSnippet[key] {
			plan.Conflicts = append(plan.Conflicts, key)
			continue
		}
		seenInSnippet[key] = true
		existing, exists := baseIndex[key]
		if !exists {
			baseRoot.AddChild(child.Copy())
			plan.Added = append(plan.Added, key)
			continue
		}
		// Collision — resolve per mode.
		switch spec.Mode {
		case ReplaceByName:
			if elementsEqualish(existing, child) {
				plan.Unchanged = append(plan.Unchanged, key)
				continue
			}
			replaceElement(baseRoot, existing, child)
			baseIndex[key] = child.Copy()
			plan.Replaced = append(plan.Replaced, key)
		case EventsMerge:
			appended, changed := mergeEventChildren(existing, child)
			if !changed {
				plan.Unchanged = append(plan.Unchanged, key)
				continue
			}
			plan.Replaced = append(plan.Replaced, key)
			if len(appended) > 0 {
				plan.AppendedChildren[key] = appended
			}
		}
		_ = baseOrder // preserved via baseRoot ordering
	}

	// Stable-sort output slices for deterministic UI.
	sort.Strings(plan.Added)
	sort.Strings(plan.Replaced)
	sort.Strings(plan.Unchanged)
	sort.Strings(plan.Conflicts)
	plan.Warnings = append(plan.Warnings, warnings...)

	res := &Result{Plan: plan}
	if dryRun {
		return res, nil
	}
	baseDoc.Indent(4)
	out, err := baseDoc.WriteToBytes()
	if err != nil {
		return nil, fmt.Errorf("render merged xml: %w", err)
	}
	// Preserve the base file's line-ending convention.
	if bytes.Contains(base, []byte("\r\n")) && !bytes.Contains(out, []byte("\r\n")) {
		out = bytes.ReplaceAll(out, []byte("\n"), []byte("\r\n"))
	}
	res.Bytes = out
	return res, nil
}

// ---------- helpers ----------

func parseDoc(raw []byte, label string) (*etree.Document, []string, error) {
	warnings := []string{}
	clean, bomStripped := stripBOMWithFlag(raw)
	if bomStripped {
		warnings = append(warnings, label+": stripped UTF-8 BOM")
	}
	doc := etree.NewDocument()
	if err := doc.ReadFromBytes(clean); err != nil {
		return nil, warnings, fmt.Errorf("%s: parse: %w", label, err)
	}
	return doc, warnings, nil
}

func safeRootTag(d *etree.Document) string {
	if r := d.Root(); r != nil {
		return r.Tag
	}
	return "(no-root)"
}

// matchesEntry reports whether child is one of the entry tags this spec considers.
func matchesEntry(child *etree.Element, spec Spec) bool {
	if len(spec.EntryTags) > 0 {
		for _, t := range spec.EntryTags {
			if child.Tag == t {
				return true
			}
		}
		return false
	}
	return spec.EntryTag != "" && child.Tag == spec.EntryTag
}

// entryKey returns the merge key for a child element (empty string if no key).
func entryKey(child *etree.Element, spec Spec) string {
	if spec.KeyAttr == "" {
		return ""
	}
	return child.SelectAttrValue(spec.KeyAttr, "")
}

// indexEntries scans root and builds a name→element map of mergeable entries.
// Also returns the insertion-order list (useful for deterministic diffs).
func indexEntries(root *etree.Element, spec Spec) (map[string]*etree.Element, []string) {
	m := make(map[string]*etree.Element)
	order := make([]string, 0, 64)
	for _, child := range root.ChildElements() {
		if !matchesEntry(child, spec) {
			continue
		}
		k := entryKey(child, spec)
		if k == "" {
			continue
		}
		if _, dup := m[k]; !dup {
			order = append(order, k)
		}
		m[k] = child
	}
	return m, order
}

// replaceElement swaps old in-place with a copy of repl, preserving sibling order.
func replaceElement(parent, old, repl *etree.Element) {
	newEl := repl.Copy()
	// etree doesn't have an "insert at index"; replicate by collecting children
	// around `old`, removing all, and re-adding.
	children := parent.ChildElements()
	idx := -1
	for i, c := range children {
		if c == old {
			idx = i
			break
		}
	}
	if idx < 0 {
		parent.AddChild(newEl)
		return
	}
	// Remove old and rebuild tail ordering.
	parent.RemoveChild(old)
	// If we removed from the end, AddChild appends at the end — correct.
	// Otherwise we need to move the trailing children back after newEl. etree's
	// AddChild appends, so we re-add newEl then move the original tail past it.
	tail := children[idx+1:]
	for _, t := range tail {
		parent.RemoveChild(t)
	}
	parent.AddChild(newEl)
	for _, t := range tail {
		parent.AddChild(t)
	}
}

// elementsEqualish is a lenient equality check: same tag, same attrs
// (set-compare, order-insensitive), same text content, same child-element
// tree by signature dump.
func elementsEqualish(a, b *etree.Element) bool {
	if a.Tag != b.Tag {
		return false
	}
	if strings.TrimSpace(a.Text()) != strings.TrimSpace(b.Text()) {
		return false
	}
	if len(a.Attr) != len(b.Attr) {
		return false
	}
	aAttrs := attrMap(a)
	bAttrs := attrMap(b)
	for k, v := range aAttrs {
		if bAttrs[k] != v {
			return false
		}
	}
	return childrenSignature(a) == childrenSignature(b)
}

func attrMap(e *etree.Element) map[string]string {
	m := make(map[string]string, len(e.Attr))
	for _, a := range e.Attr {
		m[a.Key] = a.Value
	}
	return m
}

func childrenSignature(e *etree.Element) string {
	var b strings.Builder
	for _, c := range e.ChildElements() {
		b.WriteString(c.Tag)
		b.WriteByte('{')
		keys := make([]string, 0, len(c.Attr))
		am := attrMap(c)
		for k := range am {
			keys = append(keys, k)
		}
		sort.Strings(keys)
		for _, k := range keys {
			b.WriteString(k)
			b.WriteByte('=')
			b.WriteString(am[k])
			b.WriteByte(' ')
		}
		b.WriteString(childrenSignature(c))
		b.WriteByte('}')
		t := strings.TrimSpace(c.Text())
		if t != "" {
			b.WriteString("#")
			b.WriteString(t)
		}
	}
	return b.String()
}

// mergeEventChildren implements events.xml's special semantics: replace the
// scalar attrs + non-children descendants of `base` with those of `snippet`,
// then append any `<child type="...">` from snippet whose `type` isn't
// already present in base. Returns (appended child types, changed?).
func mergeEventChildren(base, snippet *etree.Element) ([]string, bool) {
	changed := false
	// 1. Overwrite attributes on the event element.
	if !sameAttrs(base, snippet) {
		for _, a := range base.Attr {
			base.RemoveAttr(a.Key)
		}
		for _, a := range snippet.Attr {
			base.CreateAttr(a.Key, a.Value)
		}
		changed = true
	}
	// 2. Replace scalar direct children (non-<children>) wholesale.
	for _, sChild := range snippet.ChildElements() {
		if sChild.Tag == "children" {
			continue
		}
		// find matching scalar in base (by tag)
		if bChild := base.SelectElement(sChild.Tag); bChild != nil {
			if !elementsEqualish(bChild, sChild) {
				base.RemoveChild(bChild)
				base.AddChild(sChild.Copy())
				changed = true
			}
		} else {
			base.AddChild(sChild.Copy())
			changed = true
		}
	}
	// 3. Append new <child type=...> entries from snippet's <children>.
	var appended []string
	sChildren := snippet.SelectElement("children")
	if sChildren == nil {
		return appended, changed
	}
	bChildren := base.SelectElement("children")
	if bChildren == nil {
		bChildren = base.CreateElement("children")
		changed = true
	}
	existingTypes := map[string]bool{}
	for _, c := range bChildren.ChildElements() {
		if t := c.SelectAttrValue("type", ""); t != "" {
			existingTypes[t] = true
		}
	}
	for _, sChildEl := range sChildren.ChildElements() {
		t := sChildEl.SelectAttrValue("type", "")
		if t == "" || existingTypes[t] {
			continue
		}
		bChildren.AddChild(sChildEl.Copy())
		existingTypes[t] = true
		appended = append(appended, t)
		changed = true
	}
	return appended, changed
}

func sameAttrs(a, b *etree.Element) bool {
	if len(a.Attr) != len(b.Attr) {
		return false
	}
	am := attrMap(a)
	for _, at := range b.Attr {
		if am[at.Key] != at.Value {
			return false
		}
	}
	return true
}

// stripBOM returns input with a leading UTF-8 BOM removed (if present).
func stripBOM(b []byte) []byte {
	out, _ := stripBOMWithFlag(b)
	return out
}

func stripBOMWithFlag(b []byte) ([]byte, bool) {
	if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
		return b[3:], true
	}
	return b, false
}
