package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"time"
)

const (
	AppName           = "JimSSng - 시체 색상 빛기둥 자동병합기"
	AppVersion        = "1.0.0"
	ModName           = "JimSSng - 시체 색상 빛기둥"
	ModVersion        = "1.0.0"
	InstallerFamilyID = "jimssng.corpse-color-pillar.installer.v1"
	DefaultPillarCode = "gold2"
	PillarPrefix      = "jimssng_itemoverlay_corpse_color_pillar_"
)

var PlayerClasses = []string{"amazon", "assassin", "barbarian", "druid", "necromancer", "paladin", "sorceress", "warlock"}
var PillarOffsets = []float64{0.0, 2.7, 6.0, 10.0, 15.0, 20.0, 25.0}
var pillarCodeRx = regexp.MustCompile(`^(red|orange|gold|peach|white|yellow|green|cyan|blue|navy|pink|magenta)([123])$`)

func ParsePillarCode(s string) (string, error) {
	s = strings.ToLower(strings.Join(strings.Fields(s), ""))
	if !pillarCodeRx.MatchString(s) {
		return "", fmt.Errorf("빛기둥 지정값이 올바르지 않습니다: %q", s)
	}
	return s, nil
}

func TimelineRelPath(className string) string {
	return filepath.Join("hd", "character", "player", className, "animation", "combined.timelines")
}

func BaselinePath(sourceRoot, className string) string {
	return filepath.Join(sourceRoot, "installer_sources", "vanilla", TimelineRelPath(className))
}

func ColorAssetSource(sourceRoot, name string) string {
	return filepath.Join(sourceRoot, "installer_sources", "colorlight", "hd", "vfx", "colorlight", name)
}

func ColorAssetRel(name string) string {
	return filepath.Join("hd", "vfx", "colorlight", name)
}

// stripJSONC removes UTF-8 BOM, line/block comments and trailing commas without touching string contents.
func stripJSONC(in []byte) ([]byte, error) {
	in = bytes.TrimPrefix(in, []byte{0xEF, 0xBB, 0xBF})
	out := make([]byte, 0, len(in))
	inStr, esc := false, false
	lineComment, blockComment := false, false
	for i := 0; i < len(in); i++ {
		c := in[i]
		if lineComment {
			if c == '\n' || c == '\r' {
				lineComment = false
				out = append(out, c)
			}
			continue
		}
		if blockComment {
			if c == '*' && i+1 < len(in) && in[i+1] == '/' {
				blockComment = false
				i++
				continue
			}
			if c == '\n' || c == '\r' {
				out = append(out, c)
			}
			continue
		}
		if inStr {
			out = append(out, c)
			if esc {
				esc = false
				continue
			}
			if c == '\\' {
				esc = true
				continue
			}
			if c == '"' {
				inStr = false
			}
			continue
		}
		if c == '"' {
			inStr = true
			out = append(out, c)
			continue
		}
		if c == '/' && i+1 < len(in) {
			if in[i+1] == '/' {
				lineComment = true
				i++
				continue
			}
			if in[i+1] == '*' {
				blockComment = true
				i++
				continue
			}
		}
		out = append(out, c)
	}
	if inStr || blockComment {
		return nil, errors.New("JSONC 문자열/주석이 닫히지 않았습니다")
	}

	// Remove commas whose next non-space token is ] or }.
	cleaned := make([]byte, 0, len(out))
	inStr, esc = false, false
	for i := 0; i < len(out); i++ {
		c := out[i]
		if inStr {
			cleaned = append(cleaned, c)
			if esc {
				esc = false
				continue
			}
			if c == '\\' {
				esc = true
				continue
			}
			if c == '"' {
				inStr = false
			}
			continue
		}
		if c == '"' {
			inStr = true
			cleaned = append(cleaned, c)
			continue
		}
		if c == ',' {
			j := i + 1
			for j < len(out) && (out[j] == ' ' || out[j] == '\t' || out[j] == '\r' || out[j] == '\n') {
				j++
			}
			if j < len(out) && (out[j] == ']' || out[j] == '}') {
				continue
			}
		}
		cleaned = append(cleaned, c)
	}
	return cleaned, nil
}

// jsoncObjectProperty describes one top-level property inside a specific JSON/JSONC object.
type jsoncObjectProperty struct {
	Key             string
	PropertyStart   int
	ValueStart      int
	ValueEnd        int
	CommaAfterValue int // -1 when this is the last property
}

func skipJSONCTrivia(in []byte, i, end int) (int, error) {
	for i < end {
		switch in[i] {
		case ' ', '\t', '\r', '\n':
			i++
			continue
		case '/':
			if i+1 >= end {
				return i, nil
			}
			if in[i+1] == '/' {
				i += 2
				for i < end && in[i] != '\n' && in[i] != '\r' {
					i++
				}
				continue
			}
			if in[i+1] == '*' {
				i += 2
				closed := false
				for i+1 < end {
					if in[i] == '*' && in[i+1] == '/' {
						i += 2
						closed = true
						break
					}
					i++
				}
				if !closed {
					return i, errors.New("JSONC block comment가 닫히지 않았습니다")
				}
				continue
			}
		}
		break
	}
	return i, nil
}

func scanJSONStringEnd(in []byte, start, end int) (int, error) {
	if start >= end || in[start] != '"' {
		return start, errors.New("JSON string 시작 문자가 없습니다")
	}
	esc := false
	for i := start + 1; i < end; i++ {
		c := in[i]
		if esc {
			esc = false
			continue
		}
		if c == '\\' {
			esc = true
			continue
		}
		if c == '"' {
			return i + 1, nil
		}
	}
	return start, errors.New("JSON string이 닫히지 않았습니다")
}

func scanJSONCValueEnd(in []byte, start, end int) (int, error) {
	i, err := skipJSONCTrivia(in, start, end)
	if err != nil {
		return start, err
	}
	if i >= end {
		return start, errors.New("JSON value가 없습니다")
	}
	if in[i] == '"' {
		return scanJSONStringEnd(in, i, end)
	}
	if in[i] != '{' && in[i] != '[' {
		for i < end {
			if in[i] == ',' || in[i] == '}' || in[i] == ']' || in[i] == '\r' || in[i] == '\n' {
				break
			}
			if in[i] == '/' && i+1 < end && (in[i+1] == '/' || in[i+1] == '*') {
				break
			}
			i++
		}
		for i > start && (in[i-1] == ' ' || in[i-1] == '\t') {
			i--
		}
		return i, nil
	}

	stack := []byte{in[i]}
	inStr, esc := false, false
	lineComment, blockComment := false, false
	for j := i + 1; j < end; j++ {
		c := in[j]
		if lineComment {
			if c == '\n' || c == '\r' {
				lineComment = false
			}
			continue
		}
		if blockComment {
			if c == '*' && j+1 < end && in[j+1] == '/' {
				blockComment = false
				j++
			}
			continue
		}
		if inStr {
			if esc {
				esc = false
				continue
			}
			if c == '\\' {
				esc = true
			} else if c == '"' {
				inStr = false
			}
			continue
		}
		if c == '"' {
			inStr = true
			continue
		}
		if c == '/' && j+1 < end {
			if in[j+1] == '/' {
				lineComment = true
				j++
				continue
			}
			if in[j+1] == '*' {
				blockComment = true
				j++
				continue
			}
		}
		if c == '{' || c == '[' {
			stack = append(stack, c)
			continue
		}
		if c == '}' || c == ']' {
			if len(stack) == 0 {
				return start, errors.New("JSON bracket stack 오류")
			}
			want := byte('{')
			if c == ']' {
				want = '['
			}
			if stack[len(stack)-1] != want {
				return start, errors.New("JSON bracket 종류가 맞지 않습니다")
			}
			stack = stack[:len(stack)-1]
			if len(stack) == 0 {
				return j + 1, nil
			}
		}
	}
	return start, errors.New("JSON object/array가 닫히지 않았습니다")
}

func decodeJSONStringToken(in []byte) (string, error) {
	var s string
	if err := json.Unmarshal(in, &s); err != nil {
		return "", err
	}
	return s, nil
}

func listJSONCObjectProperties(in []byte, objectStart, objectEnd int) ([]jsoncObjectProperty, error) {
	if objectStart < 0 || objectEnd > len(in) || objectStart >= objectEnd || in[objectStart] != '{' || in[objectEnd-1] != '}' {
		return nil, errors.New("JSON object span이 올바르지 않습니다")
	}
	var props []jsoncObjectProperty
	i := objectStart + 1
	for {
		var err error
		i, err = skipJSONCTrivia(in, i, objectEnd-1)
		if err != nil {
			return nil, err
		}
		if i >= objectEnd-1 {
			break
		}
		if in[i] != '"' {
			return nil, fmt.Errorf("JSON object key를 찾지 못했습니다 (offset %d)", i)
		}
		propStart := i
		keyEnd, err := scanJSONStringEnd(in, i, objectEnd-1)
		if err != nil {
			return nil, err
		}
		key, err := decodeJSONStringToken(in[i:keyEnd])
		if err != nil {
			return nil, err
		}
		i, err = skipJSONCTrivia(in, keyEnd, objectEnd-1)
		if err != nil {
			return nil, err
		}
		if i >= objectEnd-1 || in[i] != ':' {
			return nil, fmt.Errorf("JSON object ':'를 찾지 못했습니다: %s", key)
		}
		i++
		i, err = skipJSONCTrivia(in, i, objectEnd-1)
		if err != nil {
			return nil, err
		}
		valueStart := i
		valueEnd, err := scanJSONCValueEnd(in, valueStart, objectEnd-1)
		if err != nil {
			return nil, fmt.Errorf("JSON property %s: %w", key, err)
		}
		i, err = skipJSONCTrivia(in, valueEnd, objectEnd-1)
		if err != nil {
			return nil, err
		}
		comma := -1
		if i < objectEnd-1 && in[i] == ',' {
			comma = i
			i++
		}
		props = append(props, jsoncObjectProperty{Key: key, PropertyStart: propStart, ValueStart: valueStart, ValueEnd: valueEnd, CommaAfterValue: comma})
	}
	return props, nil
}

func findJSONCProperty(props []jsoncObjectProperty, key string) (jsoncObjectProperty, bool) {
	for _, p := range props {
		if p.Key == key {
			return p, true
		}
	}
	return jsoncObjectProperty{}, false
}

func marshalCompact(v any) ([]byte, error) {
	return json.Marshal(v)
}

func patchHthDeadObject(rawValue []byte, className, code string) ([]byte, error) {
	clean, err := stripJSONC(rawValue)
	if err != nil {
		return nil, err
	}
	dec := json.NewDecoder(bytes.NewReader(clean))
	dec.UseNumber()
	var h map[string]any
	if err := dec.Decode(&h); err != nil {
		return nil, fmt.Errorf("hth_dead JSON 파싱 실패: %w", err)
	}
	if className == "paladin" && looksVanillaPaladin(h) {
		h["duration"] = 0.0
	}
	ev, _ := h["animationEvents"].([]any)
	if ev == nil {
		ev = []any{}
	}
	kept := make([]any, 0, len(ev)+len(PillarOffsets))
	for _, item := range ev {
		m, ok := item.(map[string]any)
		if ok {
			if n, _ := m["name"].(string); strings.HasPrefix(n, PillarPrefix) {
				continue
			}
		}
		kept = append(kept, item)
	}
	particle := "data/hd/vfx/colorlight/drop_light_" + code + ".particles"
	for i, y := range PillarOffsets {
		kept = append(kept, createPillarEvent(fmt.Sprintf("%s%d", PillarPrefix, i), particle, y))
	}
	h["animationEvents"] = kept
	return marshalCompact(h)
}

func newHthDeadObject(code string) ([]byte, error) {
	h := map[string]any{
		"type": "AnimationTimeline", "name": "hth_dead", "duration": 0.0, "timeStep": 0.03333334,
		"animationEvents": []any{createShieldEvent(-1.0)},
	}
	b, err := marshalCompact(h)
	if err != nil {
		return nil, err
	}
	return patchHthDeadObject(b, "", code)
}

func insertPropertyAfter(raw []byte, objectEnd int, props []jsoncObjectProperty, afterKey, key string, value []byte) ([]byte, error) {
	quotedKey, _ := json.Marshal(key)
	newProp := append(append(append([]byte{}, quotedKey...), ':'), value...)
	if after, ok := findJSONCProperty(props, afterKey); ok {
		if after.CommaAfterValue >= 0 {
			pos := after.CommaAfterValue + 1
			insert := append(append([]byte{}, newProp...), ',')
			out := make([]byte, 0, len(raw)+len(insert))
			out = append(out, raw[:pos]...)
			out = append(out, insert...)
			out = append(out, raw[pos:]...)
			return out, nil
		}
		pos := after.ValueEnd
		insert := append([]byte{','}, newProp...)
		out := make([]byte, 0, len(raw)+len(insert))
		out = append(out, raw[:pos]...)
		out = append(out, insert...)
		out = append(out, raw[pos:]...)
		return out, nil
	}

	// Fallback only when hth_death is missing: append to the end of timelines.
	pos := objectEnd - 1
	prefix := []byte{}
	if len(props) > 0 {
		prefix = []byte{','}
	}
	insert := append(prefix, newProp...)
	out := make([]byte, 0, len(raw)+len(insert))
	out = append(out, raw[:pos]...)
	out = append(out, insert...)
	out = append(out, raw[pos:]...)
	return out, nil
}

func number(v any) float64 {
	switch x := v.(type) {
	case float64:
		return x
	case json.Number:
		f, _ := x.Float64()
		return f
	}
	return 0
}

func createShieldEvent(duration float64) map[string]any {
	return map[string]any{
		"type": "ChangeEquipmentLayerWeight", "name": "ChangeEquipmentLayerWeight_Item034",
		"eventTimestamp": 0.0, "duration": duration, "isCritical": false,
		"eventParams": []any{
			map[string]any{"type": "ChangeEquipmentLayerWeightLayerNameEventParameter", "name": "ChangeEquipmentLayerWeight_Track_eventParams_layerName069", "value": "ShieldEquipLayer", "parameterName": "layerName"},
			map[string]any{"type": "ChangeEquipmentLayerWeightWeightEventParameter", "name": "ChangeEquipmentLayerWeight_Track_eventParams_weight069", "value": 0.0, "parameterName": "weight"},
			map[string]any{"type": "ChangeEquipmentLayerWeightBlendInTimeEventParameter", "name": "ChangeEquipmentLayerWeight_Track_eventParams_blendInTime069", "value": 0.25, "parameterName": "blendInTime"},
			map[string]any{"type": "ChangeEquipmentLayerWeightBlendOutTimeEventParameter", "name": "ChangeEquipmentLayerWeight_Track_eventParams_blendOutTime069", "value": 0.25, "parameterName": "blendOutTime"},
		},
	}
}

func createPillarEvent(name, particle string, y float64) map[string]any {
	return map[string]any{
		"type": "SpawnVFX", "name": name, "eventTimestamp": 0.0, "duration": -1.0, "isCritical": false,
		"eventParams": []any{
			map[string]any{"type": "VFXSourceAnimationEventParameter", "name": name + "_filename", "value": particle, "parameterName": "filename"},
			map[string]any{"type": "AttachToEntityAnimationEventParameter", "name": name + "_attachToEntity", "value": true, "parameterName": "attachToEntity"},
			map[string]any{"type": "BoneNameAnimationEventParameter", "name": name + "_boneName", "value": "root", "parameterName": "boneName"},
			map[string]any{"type": "OrientationBoneNameAnimationEventParameter", "name": name + "_orientationBoneName", "value": "root", "parameterName": "orientationBoneName"},
			map[string]any{"type": "TranslationOffsetAnimationEventParameter", "name": name + "_translationOffset", "value": map[string]any{"x": 0.0, "y": y, "z": 0.0}, "parameterName": "translationOffset"},
			map[string]any{"type": "RotationOffsetAnimationEventParameter", "name": name + "_rotationOffset", "value": map[string]any{"x": 0.0, "y": 0.0, "z": 0.0}, "parameterName": "rotationOffset"},
			map[string]any{"type": "HardKillVFXAnimationEventParameter", "name": name + "_hardKill", "value": false, "parameterName": "hardKillOnTimeout"},
			map[string]any{"type": "AttachToDeadBodyEventParameter", "name": name + "_attachToDeadBody", "value": true, "parameterName": "attachToDeadBody"},
		},
	}
}

func looksVanillaPaladin(h map[string]any) bool {
	if h["type"] != "AnimationTimeline" || h["name"] != "hth_dead" {
		return false
	}
	if abs(number(h["duration"])-0.3333333) > 0.000001 || abs(number(h["timeStep"])-0.03333334) > 0.000001 {
		return false
	}
	ev, ok := h["animationEvents"].([]any)
	if !ok || len(ev) != 1 {
		return false
	}
	e, ok := ev[0].(map[string]any)
	return ok && e["type"] == "ChangeEquipmentLayerWeight"
}
func abs(v float64) float64 {
	if v < 0 {
		return -v
	}
	return v
}

func PatchCombinedTimelines(raw []byte, className, code string) ([]byte, error) {
	code, err := ParsePillarCode(code)
	if err != nil {
		return nil, err
	}
	if len(raw) == 0 {
		return nil, errors.New("combined.timelines가 비어 있습니다")
	}
	rootStart, err := skipJSONCTrivia(raw, 0, len(raw))
	if err != nil {
		return nil, err
	}
	if rootStart >= len(raw) || raw[rootStart] != '{' {
		return nil, errors.New("combined.timelines root object가 없습니다")
	}
	rootEnd, err := scanJSONCValueEnd(raw, rootStart, len(raw))
	if err != nil {
		return nil, err
	}
	rootProps, err := listJSONCObjectProperties(raw, rootStart, rootEnd)
	if err != nil {
		return nil, err
	}
	timelinesProp, ok := findJSONCProperty(rootProps, "timelines")
	if !ok || timelinesProp.ValueStart >= len(raw) || raw[timelinesProp.ValueStart] != '{' {
		return nil, errors.New("timelines 객체가 없습니다")
	}
	timelinesEnd, err := scanJSONCValueEnd(raw, timelinesProp.ValueStart, timelinesProp.ValueEnd)
	if err != nil {
		return nil, err
	}
	timelineProps, err := listJSONCObjectProperties(raw, timelinesProp.ValueStart, timelinesEnd)
	if err != nil {
		return nil, err
	}
	if hprop, exists := findJSONCProperty(timelineProps, "hth_dead"); exists {
		patched, err := patchHthDeadObject(raw[hprop.ValueStart:hprop.ValueEnd], className, code)
		if err != nil {
			return nil, err
		}
		out := make([]byte, 0, len(raw)-len(raw[hprop.ValueStart:hprop.ValueEnd])+len(patched))
		out = append(out, raw[:hprop.ValueStart]...)
		out = append(out, patched...)
		out = append(out, raw[hprop.ValueEnd:]...)
		return out, nil
	}

	newValue, err := newHthDeadObject(code)
	if err != nil {
		return nil, err
	}
	return insertPropertyAfter(raw, timelinesEnd, timelineProps, "hth_death", "hth_dead", newValue)
}

type SourceState struct {
	Files map[string]string `json:"files"`
}

func expectedSourceFiles() []string {
	var out []string
	for _, c := range PlayerClasses {
		out = append(out, filepath.Join("data", TimelineRelPath(c)))
		out = append(out, filepath.Join("installer_sources", "vanilla", TimelineRelPath(c)))
	}
	out = append(out,
		filepath.Join("data", "hd", "vfx", "colorlight", "drop_light_gold2.particles"),
		filepath.Join("data", "hd", "vfx", "colorlight", "color_light.texture"),
		filepath.Join("data", "hd", "vfx", "colorlight", "gold2.texture"),
		"modinfo.json",
	)
	colors := []string{"red", "orange", "gold", "peach", "white", "yellow", "green", "cyan", "blue", "navy", "pink", "magenta"}
	out = append(out, filepath.Join("installer_sources", "colorlight", "hd", "vfx", "colorlight", "color_light.texture"))
	for _, c := range colors {
		for _, p := range []string{"1", "2", "3"} {
			v := c + p
			out = append(out, filepath.Join("installer_sources", "colorlight", "hd", "vfx", "colorlight", "drop_light_"+v+".particles"))
			out = append(out, filepath.Join("installer_sources", "colorlight", "hd", "vfx", "colorlight", v+".texture"))
		}
	}
	sort.Strings(out)
	return out
}

func hashFile(path string) (string, int64, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", 0, err
	}
	defer f.Close()
	h := sha256.New()
	n, err := io.Copy(h, f)
	if err != nil {
		return "", 0, err
	}
	return hex.EncodeToString(h.Sum(nil)), n, nil
}

func ValidateSource(sourceRoot string) (*SourceState, error) {
	if st, err := os.Stat(filepath.Join(sourceRoot, "data")); err != nil || !st.IsDir() {
		return nil, errors.New("EXE 옆 data 폴더가 없습니다")
	}
	state := &SourceState{Files: map[string]string{}}
	for _, rel := range expectedSourceFiles() {
		p, err := safeJoin(sourceRoot, rel)
		if err != nil {
			return nil, err
		}
		st, err := os.Lstat(p)
		if err != nil {
			return nil, fmt.Errorf("필수 source 누락: %s", rel)
		}
		if st.Mode()&os.ModeSymlink != 0 || !st.Mode().IsRegular() {
			return nil, fmt.Errorf("허용되지 않는 source 형태: %s", rel)
		}
		if st.Size() <= 0 {
			return nil, fmt.Errorf("0 byte source: %s", rel)
		}
		h, _, err := hashFile(p)
		if err != nil {
			return nil, err
		}
		state.Files[filepath.ToSlash(rel)] = h
		if strings.HasSuffix(rel, "combined.timelines") {
			b, err := os.ReadFile(p)
			if err != nil {
				return nil, err
			}
			clean, err := stripJSONC(b)
			if err != nil {
				return nil, err
			}
			var v map[string]any
			if err := json.Unmarshal(clean, &v); err != nil {
				return nil, fmt.Errorf("source JSON 오류 %s: %w", rel, err)
			}
			timelines, ok := v["timelines"].(map[string]any)
			if !ok {
				return nil, fmt.Errorf("source timelines 누락: %s", rel)
			}
			if strings.HasPrefix(filepath.ToSlash(rel), "data/") {
				h, ok := timelines["hth_dead"].(map[string]any)
				if !ok {
					return nil, fmt.Errorf("기본 data hth_dead 누락: %s", rel)
				}
				ev, _ := h["animationEvents"].([]any)
				count := 0
				for _, item := range ev {
					m, _ := item.(map[string]any)
					n, _ := m["name"].(string)
					if strings.HasPrefix(n, PillarPrefix) {
						count++
					}
				}
				if count != 7 {
					return nil, fmt.Errorf("기본 data 빛기둥 이벤트 수 오류: %s", rel)
				}
			}
		}
	}
	return state, nil
}

func SameSourceState(a, b *SourceState) bool {
	if a == nil || b == nil || len(a.Files) != len(b.Files) {
		return false
	}
	for k, v := range a.Files {
		if b.Files[k] != v {
			return false
		}
	}
	return true
}

type TargetInfo struct {
	Selected, ModRoot, MPQRoot, DataRoot string
	FirstInstall                         bool
}

func ResolveTarget(selected string, firstInstall bool) (TargetInfo, error) {
	selected = strings.TrimSpace(selected)
	if selected == "" {
		return TargetInfo{}, errors.New("대상 폴더를 지정하세요")
	}
	absSel, err := filepath.Abs(selected)
	if err != nil {
		return TargetInfo{}, err
	}
	absSel = filepath.Clean(absSel)
	if isRootPath(absSel) {
		return TargetInfo{}, errors.New("드라이브/시스템 루트는 대상이 될 수 없습니다")
	}
	if firstInstall {
		modRoot := filepath.Join(absSel, "JimSSng")
		mpq := filepath.Join(modRoot, "JimSSng.mpq")
		data := filepath.Join(mpq, "data")
		if _, err := os.Stat(modRoot); err == nil {
			return TargetInfo{}, errors.New("동일한 JimSSng 모드폴더가 이미 존재합니다. 일반 설치 대상으로 지정하세요")
		}
		return TargetInfo{Selected: absSel, ModRoot: modRoot, MPQRoot: mpq, DataRoot: data, FirstInstall: true}, nil
	}
	st, err := os.Stat(absSel)
	if err != nil || !st.IsDir() {
		return TargetInfo{}, errors.New("대상 폴더가 존재하지 않습니다")
	}
	base := strings.ToLower(filepath.Base(absSel))
	if base == "data" && strings.HasSuffix(strings.ToLower(filepath.Base(filepath.Dir(absSel))), ".mpq") {
		mpq := filepath.Dir(absSel)
		return TargetInfo{Selected: absSel, MPQRoot: mpq, ModRoot: filepath.Dir(mpq), DataRoot: absSel}, nil
	}
	if strings.HasSuffix(base, ".mpq") {
		return TargetInfo{Selected: absSel, MPQRoot: absSel, ModRoot: filepath.Dir(absSel), DataRoot: filepath.Join(absSel, "data")}, nil
	}
	expected := filepath.Join(absSel, filepath.Base(absSel)+".mpq")
	if st, err := os.Stat(expected); err == nil && st.IsDir() {
		return TargetInfo{Selected: absSel, ModRoot: absSel, MPQRoot: expected, DataRoot: filepath.Join(expected, "data")}, nil
	}
	entries, _ := os.ReadDir(absSel)
	var mpqs []string
	for _, e := range entries {
		if e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".mpq") {
			mpqs = append(mpqs, filepath.Join(absSel, e.Name()))
		}
	}
	if len(mpqs) == 1 {
		return TargetInfo{Selected: absSel, ModRoot: absSel, MPQRoot: mpqs[0], DataRoot: filepath.Join(mpqs[0], "data")}, nil
	}
	if len(mpqs) > 1 {
		return TargetInfo{}, errors.New(".mpq 하위폴더가 여러 개라 자동 선택할 수 없습니다")
	}
	return TargetInfo{}, errors.New("선택한 폴더에서 .mpq/data 구조를 찾을 수 없습니다")
}

func isRootPath(p string) bool {
	vol := filepath.VolumeName(p)
	rest := strings.TrimPrefix(p, vol)
	rest = strings.Trim(rest, "/\\")
	return rest == ""
}
func safeJoin(root, rel string) (string, error) {
	if filepath.IsAbs(rel) {
		return "", errors.New("절대 상대경로는 허용되지 않습니다")
	}
	clean := filepath.Clean(rel)
	if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
		return "", errors.New("path traversal 차단")
	}
	out := filepath.Join(root, clean)
	absRoot, _ := filepath.Abs(root)
	absOut, _ := filepath.Abs(out)
	r := strings.ToLower(filepath.Clean(absRoot)) + string(filepath.Separator)
	o := strings.ToLower(filepath.Clean(absOut))
	if o != strings.TrimSuffix(r, string(filepath.Separator)) && !strings.HasPrefix(o, r) {
		return "", errors.New("root 밖 경로 차단")
	}
	return absOut, nil
}

type WriteOp struct {
	Rel, Dest, Source string
	Data              []byte
}
type InstallPlan struct {
	Writes   []WriteOp
	DataRoot string
}

func BuildInstallPlan(sourceRoot, dataRoot, code string) (*InstallPlan, error) {
	code, err := ParsePillarCode(code)
	if err != nil {
		return nil, err
	}
	p := &InstallPlan{DataRoot: dataRoot}
	for _, c := range PlayerClasses {
		rel := TimelineRelPath(c)
		dest, err := safeJoin(dataRoot, rel)
		if err != nil {
			return nil, err
		}
		var raw []byte
		if st, err := os.Lstat(dest); err == nil {
			if st.Mode()&os.ModeSymlink != 0 || !st.Mode().IsRegular() {
				return nil, fmt.Errorf("target 파일 형태 충돌: %s", rel)
			}
			raw, err = os.ReadFile(dest)
			if err != nil {
				return nil, err
			}
		} else {
			base := BaselinePath(sourceRoot, c)
			raw, err = os.ReadFile(base)
			if err != nil {
				return nil, fmt.Errorf("baseline 누락 %s: %w", c, err)
			}
		}
		out, err := PatchCombinedTimelines(raw, c, code)
		if err != nil {
			return nil, fmt.Errorf("%s 패치 실패: %w", c, err)
		}
		// Reparse output before any write. Target JSONC comments/trailing commas may be preserved outside hth_dead.
		cleanOut, err := stripJSONC(out)
		if err != nil {
			return nil, fmt.Errorf("생성 JSONC 재검증 실패 %s: %w", c, err)
		}
		var check any
		if err := json.Unmarshal(cleanOut, &check); err != nil {
			return nil, fmt.Errorf("생성 JSON 재검증 실패 %s: %w", c, err)
		}
		p.Writes = append(p.Writes, WriteOp{Rel: rel, Dest: dest, Data: out})
	}
	for _, name := range []string{"drop_light_" + code + ".particles", "color_light.texture", code + ".texture"} {
		src := ColorAssetSource(sourceRoot, name)
		b, err := os.ReadFile(src)
		if err != nil {
			return nil, err
		}
		if len(b) == 0 {
			return nil, fmt.Errorf("0 byte asset: %s", name)
		}
		rel := ColorAssetRel(name)
		dest, err := safeJoin(dataRoot, rel)
		if err != nil {
			return nil, err
		}
		p.Writes = append(p.Writes, WriteOp{Rel: rel, Dest: dest, Source: src, Data: b})
	}
	return p, nil
}

func SnapshotPlan(plan *InstallPlan) (map[string]string, error) {
	out := map[string]string{}
	for _, w := range plan.Writes {
		st, err := os.Lstat(w.Dest)
		if os.IsNotExist(err) {
			out[filepath.ToSlash(w.Rel)] = "<missing>"
			continue
		}
		if err != nil {
			return nil, err
		}
		if st.Mode()&os.ModeSymlink != 0 || !st.Mode().IsRegular() {
			return nil, fmt.Errorf("target 형태 충돌: %s", w.Rel)
		}
		h, _, err := hashFile(w.Dest)
		if err != nil {
			return nil, err
		}
		out[filepath.ToSlash(w.Rel)] = h
	}
	return out, nil
}
func SameSnapshot(a, b map[string]string) bool {
	if len(a) != len(b) {
		return false
	}
	for k, v := range a {
		if b[k] != v {
			return false
		}
	}
	return true
}

type BackupEntry struct {
	RelativePath string `json:"relativePath"`
	Existed      bool   `json:"existed"`
	Size         int64  `json:"size"`
	SHA256       string `json:"sha256,omitempty"`
}
type BackupManifest struct {
	Version                                                                                        int `json:"version"`
	InstallerFamilyID, App, AppVersion, ModName, ModVersion, Kind, CreatedAt, DataRoot, TargetMode string
	NeverDeleteAutomatically                                                                       bool          `json:"neverDeleteAutomatically"`
	Entries                                                                                        []BackupEntry `json:"entries"`
}

func backupFolderName(kind string) string {
	return time.Now().Format("20060102_150405.000") + "_" + kind
}
func CreateBackup(exeRoot string, plan *InstallPlan, kind string) (string, *BackupManifest, error) {
	bdir := filepath.Join(exeRoot, "backups", backupFolderName(kind))
	if err := os.MkdirAll(bdir, 0755); err != nil {
		return "", nil, err
	}
	_ = os.WriteFile(filepath.Join(bdir, "BACKUP_INCOMPLETE.txt"), []byte("incomplete\n"), 0644)
	m := &BackupManifest{Version: 1, InstallerFamilyID: InstallerFamilyID, App: AppName, AppVersion: AppVersion, ModName: ModName, ModVersion: ModVersion, Kind: kind, CreatedAt: time.Now().Format(time.RFC3339), DataRoot: filepath.Clean(plan.DataRoot), TargetMode: "mod", NeverDeleteAutomatically: true}
	seen := map[string]bool{}
	for _, w := range plan.Writes {
		rel := filepath.ToSlash(w.Rel)
		if seen[rel] {
			continue
		}
		seen[rel] = true
		e := BackupEntry{RelativePath: rel}
		st, err := os.Lstat(w.Dest)
		if os.IsNotExist(err) {
			m.Entries = append(m.Entries, e)
			continue
		}
		if err != nil {
			return bdir, m, err
		}
		if st.Mode()&os.ModeSymlink != 0 || !st.Mode().IsRegular() {
			return bdir, m, fmt.Errorf("backup 대상 형태 충돌: %s", rel)
		}
		h, n, err := hashFile(w.Dest)
		if err != nil {
			return bdir, m, err
		}
		e.Existed = true
		e.Size = n
		e.SHA256 = h
		bp, err := safeJoin(bdir, filepath.Join("files", filepath.FromSlash(rel)))
		if err != nil {
			return bdir, m, err
		}
		if err := os.MkdirAll(filepath.Dir(bp), 0755); err != nil {
			return bdir, m, err
		}
		if err := copyFile(w.Dest, bp); err != nil {
			return bdir, m, err
		}
		h2, n2, err := hashFile(bp)
		if err != nil || h2 != h || n2 != n {
			return bdir, m, errors.New("backup hash 검증 실패")
		}
		m.Entries = append(m.Entries, e)
	}
	mb, _ := json.MarshalIndent(m, "", "  ")
	if err := os.WriteFile(filepath.Join(bdir, "manifest.json"), append(mb, '\n'), 0644); err != nil {
		return bdir, m, err
	}
	_ = os.Remove(filepath.Join(bdir, "BACKUP_INCOMPLETE.txt"))
	if err := os.WriteFile(filepath.Join(bdir, "BACKUP_COMPLETE.txt"), []byte("complete\n"), 0644); err != nil {
		return bdir, m, err
	}
	return bdir, m, nil
}

func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
		return err
	}
	out, err := os.Create(dst)
	if err != nil {
		return err
	}
	_, e := io.Copy(out, in)
	s := out.Sync()
	c := out.Close()
	if e != nil {
		return e
	}
	if s != nil {
		return s
	}
	return c
}
func atomicWrite(dest string, data []byte) error {
	if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
		return err
	}
	tmp := dest + ".jss_installer_tmp"
	old := dest + ".jss_installer_old"
	_ = os.Remove(tmp)
	_ = os.Remove(old)
	f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
	if err != nil {
		return err
	}
	if _, err = f.Write(data); err != nil {
		f.Close()
		return err
	}
	if err = f.Sync(); err != nil {
		f.Close()
		return err
	}
	if err = f.Close(); err != nil {
		return err
	}
	want := sha256.Sum256(data)
	got, _, err := hashFile(tmp)
	if err != nil || got != hex.EncodeToString(want[:]) {
		return errors.New("tmp hash 검증 실패")
	}
	existed := false
	if _, err = os.Stat(dest); err == nil {
		existed = true
		if err = os.Rename(dest, old); err != nil {
			return err
		}
	}
	if err = os.Rename(tmp, dest); err != nil {
		if existed {
			_ = os.Rename(old, dest)
		}
		return err
	}
	got, _, err = hashFile(dest)
	if err != nil || got != hex.EncodeToString(want[:]) {
		if existed {
			_ = os.Remove(dest)
			_ = os.Rename(old, dest)
		}
		return errors.New("최종 hash 검증 실패")
	}
	if existed {
		_ = os.Remove(old)
	}
	return nil
}

func ApplyPlan(plan *InstallPlan) error {
	for _, w := range plan.Writes {
		if err := atomicWrite(w.Dest, w.Data); err != nil {
			return fmt.Errorf("%s 쓰기 실패: %w", w.Rel, err)
		}
	}
	return nil
}

func LoadAndValidateBackup(bdir string) (*BackupManifest, error) {
	if _, err := os.Stat(filepath.Join(bdir, "BACKUP_COMPLETE.txt")); err != nil {
		return nil, errors.New("BACKUP_COMPLETE 없음")
	}
	b, err := os.ReadFile(filepath.Join(bdir, "manifest.json"))
	if err != nil {
		return nil, err
	}
	var m BackupManifest
	if err = json.Unmarshal(b, &m); err != nil {
		return nil, err
	}
	if m.InstallerFamilyID != InstallerFamilyID {
		return nil, errors.New("installer family 불일치")
	}
	if m.TargetMode != "mod" && m.TargetMode != "data" && m.TargetMode != "mpq" && m.TargetMode != "mod-root" && m.TargetMode != "first-install" {
		return nil, errors.New("targetMode 불일치")
	}
	for _, e := range m.Entries {
		rel := filepath.FromSlash(e.RelativePath)
		if _, err := safeJoin(m.DataRoot, rel); err != nil {
			return nil, err
		}
		if e.Existed {
			bp, err := safeJoin(bdir, filepath.Join("files", rel))
			if err != nil {
				return nil, err
			}
			h, n, err := hashFile(bp)
			if err != nil || h != e.SHA256 || n != e.Size {
				return nil, fmt.Errorf("backup hash 불일치: %s", e.RelativePath)
			}
		}
	}
	return &m, nil
}

func RestoreBackup(exeRoot, bdir string, target TargetInfo) (string, error) {
	m, err := LoadAndValidateBackup(bdir)
	if err != nil {
		return "", err
	}
	if !samePath(m.DataRoot, target.DataRoot) {
		return "", errors.New("선택한 target의 dataRoot가 백업과 다릅니다")
	}
	plan := &InstallPlan{DataRoot: target.DataRoot}
	for _, e := range m.Entries {
		dest, err := safeJoin(target.DataRoot, filepath.FromSlash(e.RelativePath))
		if err != nil {
			return "", err
		}
		plan.Writes = append(plan.Writes, WriteOp{Rel: filepath.FromSlash(e.RelativePath), Dest: dest})
	}
	before, _, err := CreateBackup(exeRoot, plan, "BeforeRestore")
	if err != nil {
		return "", err
	}
	for _, e := range m.Entries {
		dest, _ := safeJoin(target.DataRoot, filepath.FromSlash(e.RelativePath))
		var restoreErr error
		if !e.Existed {
			if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
				restoreErr = err
			}
		} else {
			src, _ := safeJoin(bdir, filepath.Join("files", filepath.FromSlash(e.RelativePath)))
			data, err := os.ReadFile(src)
			if err != nil {
				restoreErr = err
			} else if err = atomicWrite(dest, data); err != nil {
				restoreErr = err
			}
		}
		if restoreErr != nil {
			rbErr := RollbackBackup(before, target)
			if rbErr != nil {
				return before, fmt.Errorf("복구 실패: %v / BeforeRestore 재롤백 실패: %v", restoreErr, rbErr)
			}
			return before, fmt.Errorf("복구 실패 후 BeforeRestore로 재롤백 완료: %w", restoreErr)
		}
	}
	return before, nil
}

func samePath(a, b string) bool {
	aa, _ := filepath.Abs(a)
	bb, _ := filepath.Abs(b)
	return strings.EqualFold(filepath.Clean(aa), filepath.Clean(bb))
}

func ListValidBackups(exeRoot string) ([]string, error) {
	dir := filepath.Join(exeRoot, "backups")
	ents, err := os.ReadDir(dir)
	if os.IsNotExist(err) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	var out []string
	for _, e := range ents {
		if !e.IsDir() {
			continue
		}
		p := filepath.Join(dir, e.Name())
		if _, err := LoadAndValidateBackup(p); err == nil {
			out = append(out, p)
		}
	}
	sort.Sort(sort.Reverse(sort.StringSlice(out)))
	return out, nil
}

// RollbackBackup restores a completed BeforeInstall backup without creating a BeforeRestore backup.
func RollbackBackup(bdir string, target TargetInfo) error {
	m, err := LoadAndValidateBackup(bdir)
	if err != nil {
		return err
	}
	if !samePath(m.DataRoot, target.DataRoot) {
		return errors.New("rollback dataRoot mismatch")
	}
	for _, e := range m.Entries {
		dest, err := safeJoin(target.DataRoot, filepath.FromSlash(e.RelativePath))
		if err != nil {
			return err
		}
		if !e.Existed {
			if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
				return err
			}
			continue
		}
		src, err := safeJoin(bdir, filepath.Join("files", filepath.FromSlash(e.RelativePath)))
		if err != nil {
			return err
		}
		data, err := os.ReadFile(src)
		if err != nil {
			return err
		}
		if err := atomicWrite(dest, data); err != nil {
			return err
		}
	}
	return nil
}
