package main

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

const (
	ModName           = "JimSSng - 가짜보관함 모드"
	ModVersion        = "1.0.0"
	AppName           = "FakeStash Installer"
	AppVersion        = "1.0.0"
	InstallerFamilyID = "jimssng.fakestash.data.installer.v1"
	ExeFileName       = "FakeStash_Installer.exe"
)

type BeaconOptions struct{ Style, Color, Thickness, Opacity string }

type SourceSession struct {
	Root         string
	Fingerprints map[string]string
}

type WriteOp struct {
	Rel  string
	Data []byte
}
type InstallPlan struct {
	DataRoot    string
	Writes      []WriteOp
	PreSnapshot map[string]string
	Options     BeaconOptions
}

type BackupEntry struct {
	Path    string `json:"path"`
	Existed bool   `json:"existed"`
	Size    int64  `json:"size"`
	SHA256  string `json:"sha256"`
}

type BackupManifest struct {
	Version                  int           `json:"version"`
	InstallerFamilyID        string        `json:"installerFamilyId"`
	App                      string        `json:"app"`
	AppVersion               string        `json:"appVersion"`
	ModName                  string        `json:"modName"`
	ModVersion               string        `json:"modVersion"`
	Kind                     string        `json:"kind"`
	CreatedAt                string        `json:"createdAt"`
	DataRoot                 string        `json:"dataRoot"`
	TargetMode               string        `json:"targetMode"`
	NeverDeleteAutomatically bool          `json:"neverDeleteAutomatically"`
	Entries                  []BackupEntry `json:"entries"`
}

type BackupInfo struct {
	Folder   string
	Label    string
	Manifest BackupManifest
}

var sharedUI = []string{
	"global/ui/layouts/playerinventoryoriginallayouthd.json",
	"global/ui/layouts/playerinventoryexpansionlayouthd.json",
	"global/ui/layouts/controller/playerinventoryoriginallayouthd.json",
	"global/ui/layouts/controller/playerinventoryexpansionlayouthd.json",
	"global/ui/layouts/bankexpansionlayouthd.json",
	"global/ui/layouts/controller/bankexpansionlayouthd.json",
}

var dedicatedJSON = []string{
	"global/ui/layouts/fakestashcontrolleroriginalactionhd.json",
	"global/ui/layouts/fakestashcontrollerexpansionactionhd.json",
	"hd/env/stash_radius/pf_stash_radius.json",
}

var assetFiles = []string{
	"hd/global/ui/panel/utility_buttons/fake_stash_button.sprite",
	"hd/global/ui/panel/utility_buttons/fake_stash_button.lowend.sprite",
	"hd/global/ui/panel/utility_buttons/gold_drop_button.sprite",
	"hd/global/ui/panel/utility_buttons/gold_drop_button.lowend.sprite",
}

const worldBank = "hd/objects/chests/bank.json"
const alphaTarget = "hd/env/stash_radius/texture/stash_radius_selected$a.texture"
const rgbTarget = "hd/env/stash_radius/texture/stash_radius_selected$rgb.texture"
const prefabLogical = "data/hd/env/stash_radius/pf_stash_radius.json"

func ValidOptions(o BeaconOptions) error {
	goodStyle := o.Style == "outer" || o.Style == "rings"
	goodC := false
	for i := 0; i < 20; i++ {
		if o.Color == fmt.Sprintf("c%02d", i) {
			goodC = true
		}
	}
	goodS := false
	for i := 0; i < 8; i++ {
		if o.Thickness == fmt.Sprintf("s%02d", i) {
			goodS = true
		}
	}
	goodO := o.Opacity == "o25" || o.Opacity == "o50" || o.Opacity == "o75" || o.Opacity == "o100"
	if !goodStyle || !goodC || !goodS || !goodO {
		return fmt.Errorf("invalid beacon options: %s/%s/%s/%s", o.Style, o.Color, o.Thickness, o.Opacity)
	}
	return nil
}

func DefaultOptions() BeaconOptions { return BeaconOptions{"outer", "c17", "s00", "o75"} }

func cleanRel(rel string) (string, error) {
	rel = filepath.ToSlash(strings.TrimSpace(rel))
	if rel == "" || strings.HasPrefix(rel, "/") || filepath.IsAbs(rel) {
		return "", errors.New("invalid relative path")
	}
	c := filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel)))
	if c == ".." || strings.HasPrefix(c, "../") || strings.Contains(c, "/../") {
		return "", errors.New("path traversal")
	}
	return c, nil
}

func safeJoin(root, rel string) (string, error) {
	c, err := cleanRel(rel)
	if err != nil {
		return "", err
	}
	base, err := filepath.Abs(root)
	if err != nil {
		return "", err
	}
	p, err := filepath.Abs(filepath.Join(base, filepath.FromSlash(c)))
	if err != nil {
		return "", err
	}
	r, err := filepath.Rel(base, p)
	if err != nil {
		return "", err
	}
	if r == ".." || strings.HasPrefix(r, ".."+string(os.PathSeparator)) {
		return "", errors.New("path escapes root")
	}
	return p, nil
}

func readFileSafe(root, rel string) ([]byte, error) {
	p, err := safeJoin(root, rel)
	if err != nil {
		return nil, err
	}
	if err := rejectUnsafePath(root, p); err != nil {
		return nil, err
	}
	return os.ReadFile(p)
}

func stripJSONC(in []byte) ([]byte, error) {
	s := string(bytes.TrimPrefix(in, []byte{0xEF, 0xBB, 0xBF}))
	var b strings.Builder
	inStr, esc := false, false
	for i := 0; i < len(s); {
		c := s[i]
		if inStr {
			b.WriteByte(c)
			if esc {
				esc = false
			} else if c == '\\' {
				esc = true
			} else if c == '"' {
				inStr = false
			}
			i++
			continue
		}
		if c == '"' {
			inStr = true
			b.WriteByte(c)
			i++
			continue
		}
		if c == '/' && i+1 < len(s) && s[i+1] == '/' {
			i += 2
			for i < len(s) && s[i] != '\n' && s[i] != '\r' {
				i++
			}
			continue
		}
		if c == '/' && i+1 < len(s) && s[i+1] == '*' {
			i += 2
			found := false
			for i+1 < len(s) {
				if s[i] == '*' && s[i+1] == '/' {
					i += 2
					found = true
					break
				}
				i++
			}
			if !found {
				return nil, errors.New("unterminated JSON comment")
			}
			continue
		}
		b.WriteByte(c)
		i++
	}
	if inStr {
		return nil, errors.New("unterminated JSON string")
	}
	s = b.String()
	b.Reset()
	inStr = false
	esc = false
	for i := 0; i < len(s); i++ {
		c := s[i]
		if inStr {
			b.WriteByte(c)
			if esc {
				esc = false
			} else if c == '\\' {
				esc = true
			} else if c == '"' {
				inStr = false
			}
			continue
		}
		if c == '"' {
			inStr = true
			b.WriteByte(c)
			continue
		}
		if c == ',' {
			j := i + 1
			for j < len(s) && (s[j] == ' ' || s[j] == '\t' || s[j] == '\r' || s[j] == '\n') {
				j++
			}
			if j < len(s) && (s[j] == '}' || s[j] == ']') {
				continue
			}
		}
		b.WriteByte(c)
	}
	return []byte(b.String()), nil
}

func parseJSONBytes(b []byte) (map[string]any, error) {
	c, err := stripJSONC(b)
	if err != nil {
		return nil, err
	}
	var v map[string]any
	dec := json.NewDecoder(bytes.NewReader(c))
	dec.UseNumber()
	if err = dec.Decode(&v); err != nil {
		return nil, err
	}
	return v, nil
}
func parseJSONFile(p string) (map[string]any, error) {
	b, e := os.ReadFile(p)
	if e != nil {
		return nil, e
	}
	return parseJSONBytes(b)
}
func jsonBytes(v any) ([]byte, error) {
	b, e := json.MarshalIndent(v, "", "  ")
	if e != nil {
		return nil, e
	}
	return append(b, '\n'), nil
}

func hashBytes(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
func hashFile(p string) (string, error) {
	f, e := os.Open(p)
	if e != nil {
		return "", e
	}
	defer f.Close()
	h := sha256.New()
	if _, e = io.Copy(h, f); e != nil {
		return "", e
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

func sourceRelData(rel string) string { return "data/" + filepath.ToSlash(rel) }
func variantRel(o BeaconOptions, suffix string) string {
	folder := "texture"
	if o.Style == "rings" {
		folder = "texture_rings"
	}
	return fmt.Sprintf("variants/beacon/%s/beacon_%s_%s_%s%s.texture", folder, o.Color, o.Thickness, o.Opacity, suffix)
}

func sourceContractPaths() []string {
	var r []string
	for _, p := range sharedUI {
		r = append(r, sourceRelData(p))
	}
	r = append(r, sourceRelData(worldBank))
	for _, p := range dedicatedJSON {
		r = append(r, sourceRelData(p))
	}
	for _, p := range assetFiles {
		r = append(r, sourceRelData(p))
	}
	r = append(r, sourceRelData(alphaTarget), sourceRelData(rgbTarget))
	for _, style := range []string{"outer", "rings"} {
		for c := 0; c < 20; c++ {
			for s := 0; s < 8; s++ {
				for _, o := range []string{"o25", "o50", "o75", "o100"} {
					bo := BeaconOptions{style, fmt.Sprintf("c%02d", c), fmt.Sprintf("s%02d", s), o}
					r = append(r, variantRel(bo, "$a"), variantRel(bo, "$rgb"))
				}
			}
		}
	}
	return r
}

func ValidateSource(root string) (*SourceSession, error) {
	root, _ = filepath.Abs(root)
	dataDir := filepath.Join(root, "data")
	if st, e := os.Stat(dataDir); e != nil || !st.IsDir() {
		return nil, errors.New("EXE beside data folder is missing")
	}
	fp := map[string]string{}
	for _, rel := range sourceContractPaths() {
		p, e := safeJoin(root, rel)
		if e != nil {
			return nil, e
		}
		if e = rejectUnsafePath(root, p); e != nil {
			return nil, fmt.Errorf("unsafe source %s: %w", rel, e)
		}
		st, e := os.Stat(p)
		if e != nil {
			return nil, fmt.Errorf("required source missing: %s", rel)
		}
		if !st.Mode().IsRegular() || st.Size() == 0 {
			return nil, fmt.Errorf("invalid/empty source: %s", rel)
		}
		h, e := hashFile(p)
		if e != nil {
			return nil, e
		}
		fp[rel] = h
	}
	for _, rel := range append(append([]string{}, sharedUI...), append([]string{worldBank}, dedicatedJSON...)...) {
		p, _ := safeJoin(root, sourceRelData(rel))
		if _, e := parseJSONFile(p); e != nil {
			return nil, fmt.Errorf("source JSON parse failed %s: %w", rel, e)
		}
	}
	// semantic source signatures
	checks := map[string]string{
		"global/ui/layouts/fakestashcontrolleroriginalactionhd.json":  "FakeStashControllerOriginalAction",
		"global/ui/layouts/fakestashcontrollerexpansionactionhd.json": "FakeStashControllerExpansionAction",
		"hd/env/stash_radius/pf_stash_radius.json":                    "pf_stash_radius",
	}
	for rel, name := range checks {
		p, _ := safeJoin(root, sourceRelData(rel))
		v, _ := parseJSONFile(p)
		if fmt.Sprint(v["name"]) != name {
			return nil, fmt.Errorf("source signature mismatch: %s", rel)
		}
	}
	// base DATA must equal D2RMM defaults for selected beacon assets.
	def := DefaultOptions()
	for _, pair := range [][2]string{{sourceRelData(alphaTarget), variantRel(def, "$a")}, {sourceRelData(rgbTarget), variantRel(def, "$rgb")}} {
		if fp[pair[0]] != fp[pair[1]] {
			return nil, fmt.Errorf("default DATA beacon does not match %s", pair[1])
		}
	}
	return &SourceSession{Root: root, Fingerprints: fp}, nil
}

func VerifySourceSession(s *SourceSession) error {
	if s == nil {
		return errors.New("source has not been inspected")
	}
	for rel, old := range s.Fingerprints {
		p, e := safeJoin(s.Root, rel)
		if e != nil {
			return e
		}
		h, e := hashFile(p)
		if e != nil || h != old {
			return fmt.Errorf("source changed after inspection: %s", rel)
		}
	}
	return nil
}

func objChildren(v map[string]any) []any    { a, _ := v["children"].([]any); return a }
func setChildren(v map[string]any, a []any) { v["children"] = a }
func childName(x any) string                { m, _ := x.(map[string]any); return fmt.Sprint(m["name"]) }
func findNodeByName(v any, name string) map[string]any {
	m, ok := v.(map[string]any)
	if !ok {
		return nil
	}
	if fmt.Sprint(m["name"]) == name {
		return m
	}
	if a, ok := m["children"].([]any); ok {
		for _, x := range a {
			if f := findNodeByName(x, name); f != nil {
				return f
			}
		}
	}
	return nil
}
func directChild(v map[string]any, name string) map[string]any {
	for _, x := range objChildren(v) {
		m, _ := x.(map[string]any)
		if m != nil && fmt.Sprint(m["name"]) == name {
			return m
		}
	}
	return nil
}
func removeDirect(v map[string]any, names map[string]bool) {
	a := objChildren(v)
	out := make([]any, 0, len(a))
	for _, x := range a {
		if x == nil {
			continue
		}
		if !names[childName(x)] {
			out = append(out, x)
		}
	}
	setChildren(v, out)
}
func cloneMap(m map[string]any) map[string]any {
	b, _ := json.Marshal(m)
	var o map[string]any
	d := json.NewDecoder(bytes.NewReader(b))
	d.UseNumber()
	_ = d.Decode(&o)
	return o
}

func patchInventory(target, source map[string]any, controller bool) error {
	srcBtn := directChild(source, "fake_stash_button")
	if srcBtn == nil {
		return errors.New("source fake_stash_button missing")
	}
	if _, ok := target["children"].([]any); !ok {
		target["children"] = []any{}
	}
	removeDirect(target, map[string]bool{"fake_stash_button": true})
	a := objChildren(target)
	a = append(a, cloneMap(srcBtn))
	setChildren(target, a)
	if controller {
		tg := findNodeByName(target, "gold_button")
		sg := findNodeByName(source, "gold_button")
		if tg != nil && sg != nil {
			tf, _ := tg["fields"].(map[string]any)
			sf, _ := sg["fields"].(map[string]any)
			tn, _ := tf["navigation"].(map[string]any)
			sn, _ := sf["navigation"].(map[string]any)
			if tn != nil && sn != nil && sn["right"] != nil {
				b, _ := json.Marshal(sn["right"])
				var rr any
				_ = json.Unmarshal(b, &rr)
				tn["right"] = rr
			}
		}
	}
	return nil
}

func patchBankUI(target, source map[string]any, pc bool) error {
	tb := findNodeByName(target, "basicstash_container")
	sb := findNodeByName(source, "basicstash_container")
	if tb == nil || sb == nil {
		return errors.New("basicstash_container missing")
	}
	sg := directChild(sb, "gold_drop_button")
	if sg == nil {
		return errors.New("source gold_drop_button missing")
	}
	removeDirect(tb, map[string]bool{"gold_drop_button": true, "iv_gold_button": true, "gold_box2": true})
	a := objChildren(tb)
	a = append(a, cloneMap(sg))
	setChildren(tb, a)
	if pc {
		srcDock := directChild(source, "StashLeftDockingBar")
		if srcDock == nil {
			return errors.New("source StashLeftDockingBar missing")
		}
		cur := objChildren(target)
		out := make([]any, 0, len(cur)+1)
		out = append(out, cloneMap(srcDock))
		for _, x := range cur {
			if x == nil {
				continue
			}
			name := childName(x)
			obsolete := name == "StashOpenPanelBorders" || name == "StashOpenPlayerInventory" || name == "StashLeftDockingBar" || strings.HasSuffix(name, "Stash_LeftDockingBar") || strings.HasSuffix(name, "Stash_OpenPanelBorders") || strings.HasSuffix(name, "Stash_OpenPlayerInventory")
			if !obsolete {
				out = append(out, x)
			}
		}
		setChildren(target, out)
	}
	return nil
}

func componentPrefab(e map[string]any) bool {
	a, _ := e["components"].([]any)
	for _, x := range a {
		m, _ := x.(map[string]any)
		if m != nil && strings.EqualFold(fmt.Sprint(m["prefab"]), prefabLogical) {
			return true
		}
	}
	return false
}
func patchWorldBank(target, source map[string]any) error {
	sd, _ := source["dependencies"].(map[string]any)
	td, _ := target["dependencies"].(map[string]any)
	if td == nil {
		td = map[string]any{}
		target["dependencies"] = td
	}
	sj, _ := sd["json"].([]any)
	var srcDep map[string]any
	for _, x := range sj {
		m, _ := x.(map[string]any)
		if m != nil && strings.EqualFold(fmt.Sprint(m["path"]), prefabLogical) {
			srcDep = m
			break
		}
	}
	if srcDep == nil {
		return errors.New("source stash radius dependency missing")
	}
	tj, _ := td["json"].([]any)
	nj := make([]any, 0, len(tj)+1)
	for _, x := range tj {
		m, _ := x.(map[string]any)
		if m == nil || !strings.EqualFold(fmt.Sprint(m["path"]), prefabLogical) {
			nj = append(nj, x)
		}
	}
	nj = append(nj, cloneMap(srcDep))
	td["json"] = nj
	se, _ := source["entities"].([]any)
	var srcEnt map[string]any
	for _, x := range se {
		m, _ := x.(map[string]any)
		if m != nil && (fmt.Sprint(m["name"]) == "stash_interaction_radius" || fmt.Sprint(m["id"]) == "1601100001") {
			srcEnt = m
			break
		}
	}
	if srcEnt == nil {
		return errors.New("source stash placement missing")
	}
	te, _ := target["entities"].([]any)
	ne := make([]any, 0, len(te)+1)
	for _, x := range te {
		m, _ := x.(map[string]any)
		if m == nil {
			continue
		}
		if fmt.Sprint(m["name"]) == "stash_interaction_radius" || fmt.Sprint(m["id"]) == "1601100001" || componentPrefab(m) {
			continue
		}
		ne = append(ne, x)
	}
	ne = append(ne, cloneMap(srcEnt))
	target["entities"] = ne
	return nil
}

func targetOrSourceJSON(dataRoot, sourceRoot, rel string) (map[string]any, bool, error) {
	tp, e := safeJoin(dataRoot, rel)
	if e != nil {
		return nil, false, e
	}
	if e := rejectUnsafePath(dataRoot, tp); e != nil {
		return nil, false, e
	}
	if st, e := os.Stat(tp); e == nil && st.Mode().IsRegular() {
		v, e := parseJSONFile(tp)
		return v, true, e
	}
	sp, _ := safeJoin(sourceRoot, sourceRelData(rel))
	v, e := parseJSONFile(sp)
	return v, false, e
}

func signatureOwned(p, name string) error {
	v, e := parseJSONFile(p)
	if e != nil {
		return e
	}
	if fmt.Sprint(v["name"]) != name {
		return fmt.Errorf("dedicated file conflict: %s", p)
	}
	return nil
}

func BuildInstallPlan(s *SourceSession, dataRoot string, o BeaconOptions) (*InstallPlan, error) {
	if err := VerifySourceSession(s); err != nil {
		return nil, err
	}
	if err := ValidOptions(o); err != nil {
		return nil, err
	}
	abs, err := filepath.Abs(dataRoot)
	if err != nil {
		return nil, err
	}
	dataRoot = abs
	if samePath(s.Root, dataRoot) || samePath(filepath.Join(s.Root, "data"), dataRoot) {
		return nil, errors.New("source and target must be different")
	}
	rels := allOutputPaths()
	snap, err := snapshot(dataRoot, rels)
	if err != nil {
		return nil, err
	}
	var writes []WriteOp
	// Inventory layouts
	for i, rel := range sharedUI[:4] {
		v, exists, e := targetOrSourceJSON(dataRoot, s.Root, rel)
		if e != nil {
			return nil, e
		}
		if exists {
			sp, _ := safeJoin(s.Root, sourceRelData(rel))
			src, e := parseJSONFile(sp)
			if e != nil {
				return nil, e
			}
			if e = patchInventory(v, src, i >= 2); e != nil {
				return nil, fmt.Errorf("%s: %w", rel, e)
			}
		}
		b, e := jsonBytes(v)
		if e != nil {
			return nil, e
		}
		writes = append(writes, WriteOp{rel, b})
	}
	// bank layouts
	for i, rel := range sharedUI[4:] {
		v, exists, e := targetOrSourceJSON(dataRoot, s.Root, rel)
		if e != nil {
			return nil, e
		}
		if exists {
			sp, _ := safeJoin(s.Root, sourceRelData(rel))
			src, e := parseJSONFile(sp)
			if e != nil {
				return nil, e
			}
			if e = patchBankUI(v, src, i == 0); e != nil {
				return nil, fmt.Errorf("%s: %w", rel, e)
			}
		}
		b, e := jsonBytes(v)
		if e != nil {
			return nil, e
		}
		writes = append(writes, WriteOp{rel, b})
	}
	// world bank
	{
		rel := worldBank
		v, exists, e := targetOrSourceJSON(dataRoot, s.Root, rel)
		if e != nil {
			return nil, e
		}
		if exists {
			sp, _ := safeJoin(s.Root, sourceRelData(rel))
			src, e := parseJSONFile(sp)
			if e != nil {
				return nil, e
			}
			if e = patchWorldBank(v, src); e != nil {
				return nil, e
			}
		}
		b, e := jsonBytes(v)
		if e != nil {
			return nil, e
		}
		writes = append(writes, WriteOp{rel, b})
	}
	// dedicated JSON files
	sig := map[string]string{"global/ui/layouts/fakestashcontrolleroriginalactionhd.json": "FakeStashControllerOriginalAction", "global/ui/layouts/fakestashcontrollerexpansionactionhd.json": "FakeStashControllerExpansionAction", "hd/env/stash_radius/pf_stash_radius.json": "pf_stash_radius"}
	for _, rel := range dedicatedJSON {
		tp, _ := safeJoin(dataRoot, rel)
		if _, e := os.Stat(tp); e == nil {
			if e = signatureOwned(tp, sig[rel]); e != nil {
				return nil, e
			}
		}
		b, e := readFileSafe(s.Root, sourceRelData(rel))
		if e != nil {
			return nil, e
		}
		writes = append(writes, WriteOp{rel, b})
	}
	for _, rel := range assetFiles {
		b, e := readFileSafe(s.Root, sourceRelData(rel))
		if e != nil {
			return nil, e
		}
		writes = append(writes, WriteOp{rel, b})
	}
	ab, e := readFileSafe(s.Root, variantRel(o, "$a"))
	if e != nil {
		return nil, e
	}
	rb, e := readFileSafe(s.Root, variantRel(o, "$rgb"))
	if e != nil {
		return nil, e
	}
	writes = append(writes, WriteOp{alphaTarget, ab}, WriteOp{rgbTarget, rb})
	// Validate every output in memory.
	seen := map[string]bool{}
	for _, w := range writes {
		if len(w.Data) == 0 {
			return nil, fmt.Errorf("zero-byte output: %s", w.Rel)
		}
		if seen[w.Rel] {
			return nil, fmt.Errorf("duplicate output: %s", w.Rel)
		}
		seen[w.Rel] = true
		if strings.HasSuffix(strings.ToLower(w.Rel), ".json") {
			if _, e := parseJSONBytes(w.Data); e != nil {
				return nil, fmt.Errorf("generated JSON invalid %s: %w", w.Rel, e)
			}
		}
	}
	return &InstallPlan{DataRoot: dataRoot, Writes: writes, PreSnapshot: snap, Options: o}, nil
}

func allOutputPaths() []string {
	r := append([]string{}, sharedUI...)
	r = append(r, worldBank)
	r = append(r, dedicatedJSON...)
	r = append(r, assetFiles...)
	r = append(r, alphaTarget, rgbTarget)
	sort.Strings(r)
	return r
}
func snapshot(root string, rels []string) (map[string]string, error) {
	m := map[string]string{}
	for _, rel := range rels {
		p, e := safeJoin(root, rel)
		if e != nil {
			return nil, e
		}
		if e := rejectUnsafePath(root, p); e != nil {
			return nil, e
		}
		if st, e := os.Stat(p); e == nil {
			if !st.Mode().IsRegular() {
				return nil, fmt.Errorf("target path is not a regular file: %s", rel)
			}
			h, e := hashFile(p)
			if e != nil {
				return nil, e
			}
			m[rel] = h
		} else if os.IsNotExist(e) {
			m[rel] = "<absent>"
		} else {
			return nil, e
		}
	}
	return m, nil
}
func compareSnapshot(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
}
func VerifyTargetSnapshot(p *InstallPlan) error {
	b, e := snapshot(p.DataRoot, allOutputPaths())
	if e != nil {
		return e
	}
	if !compareSnapshot(p.PreSnapshot, b) {
		return errors.New("target changed after preflight; installation stopped")
	}
	return nil
}

func CreateBackup(exeDir, dataRoot, kind string, rels []string) (string, *BackupManifest, error) {
	root := filepath.Join(exeDir, "backups")
	if e := os.MkdirAll(root, 0755); e != nil {
		return "", nil, e
	}
	stamp := time.Now().Format("20060102_150405.000000000")
	folder := filepath.Join(root, stamp+"_"+kind)
	if e := os.MkdirAll(filepath.Join(folder, "files"), 0755); e != nil {
		return "", nil, e
	}
	incomplete := filepath.Join(folder, "BACKUP_INCOMPLETE.txt")
	_ = os.WriteFile(incomplete, []byte("incomplete\n"), 0644)
	abs, _ := filepath.Abs(dataRoot)
	man := &BackupManifest{1, InstallerFamilyID, AppName, AppVersion, ModName, ModVersion, kind, time.Now().Format(time.RFC3339Nano), abs, "mod", true, nil}
	for _, rel := range rels {
		c, e := cleanRel(rel)
		if e != nil {
			return folder, nil, e
		}
		src, e := safeJoin(dataRoot, c)
		if e != nil {
			return folder, nil, e
		}
		if e := rejectUnsafePath(dataRoot, src); e != nil {
			return folder, nil, e
		}
		ent := BackupEntry{Path: c}
		if st, e := os.Stat(src); e == nil {
			if !st.Mode().IsRegular() {
				return folder, nil, fmt.Errorf("cannot backup non-file: %s", c)
			}
			ent.Existed = true
			ent.Size = st.Size()
			dst, e := safeJoin(filepath.Join(folder, "files"), c)
			if e != nil {
				return folder, nil, e
			}
			if e = os.MkdirAll(filepath.Dir(dst), 0755); e != nil {
				return folder, nil, e
			}
			if e = copyFileVerified(src, dst); e != nil {
				return folder, nil, e
			}
			ent.SHA256, _ = hashFile(dst)
		} else if !os.IsNotExist(e) {
			return folder, nil, e
		}
		man.Entries = append(man.Entries, ent)
	}
	mb, e := jsonBytes(man)
	if e != nil {
		return folder, nil, e
	}
	if e = os.WriteFile(filepath.Join(folder, "manifest.json"), mb, 0644); e != nil {
		return folder, nil, e
	}
	if e = os.WriteFile(filepath.Join(folder, "BACKUP_COMPLETE.txt"), []byte("complete\n"), 0644); e != nil {
		return folder, nil, e
	}
	_ = os.Remove(incomplete)
	return folder, man, nil
}

func copyFileVerified(src, dst string) error {
	in, e := os.Open(src)
	if e != nil {
		return e
	}
	defer in.Close()
	tmp := dst + ".jss_copy_tmp"
	out, e := os.Create(tmp)
	if e != nil {
		return e
	}
	_, ce := io.Copy(out, in)
	se := out.Sync()
	cl := out.Close()
	if ce != nil {
		return ce
	}
	if se != nil {
		return se
	}
	if cl != nil {
		return cl
	}
	a, e := hashFile(src)
	if e != nil {
		return e
	}
	b, e := hashFile(tmp)
	if e != nil {
		return e
	}
	if a != b {
		return errors.New("copy hash mismatch")
	}
	_ = os.Remove(dst)
	return os.Rename(tmp, dst)
}

func atomicWrite(dest string, data []byte) error {
	if e := os.MkdirAll(filepath.Dir(dest), 0755); e != nil {
		return e
	}
	tmp := dest + ".jss_installer_tmp"
	old := dest + ".jss_installer_old"
	if _, e := os.Stat(tmp); e == nil {
		return errors.New("stale installer tmp exists")
	} else if !os.IsNotExist(e) {
		return e
	}
	if _, e := os.Stat(old); e == nil {
		return errors.New("stale installer old exists")
	} else if !os.IsNotExist(e) {
		return e
	}
	f, e := os.Create(tmp)
	if e != nil {
		return e
	}
	if _, e = f.Write(data); e == nil {
		e = f.Sync()
	}
	ce := f.Close()
	if e == nil {
		e = ce
	}
	if e != nil {
		_ = os.Remove(tmp)
		return e
	}
	if h, e := hashFile(tmp); e != nil || h != hashBytes(data) {
		_ = os.Remove(tmp)
		if e != nil {
			return e
		}
		return errors.New("tmp hash mismatch")
	}
	had := false
	if _, e = os.Stat(dest); e == nil {
		had = true
		if e = os.Rename(dest, old); e != nil {
			_ = os.Remove(tmp)
			return e
		}
	}
	if e = os.Rename(tmp, dest); e != nil {
		if had {
			_ = os.Rename(old, dest)
		}
		return e
	}
	if h, e := hashFile(dest); e != nil || h != hashBytes(data) {
		_ = os.Remove(dest)
		if had {
			_ = os.Rename(old, dest)
		}
		if e != nil {
			return e
		}
		return errors.New("final hash mismatch")
	}
	if had {
		_ = os.Remove(old)
	}
	return nil
}

func rollbackManifest(folder string, man *BackupManifest, dataRoot string) error {
	for _, ent := range man.Entries {
		dest, e := safeJoin(dataRoot, ent.Path)
		if e != nil {
			return e
		}
		if e := rejectUnsafePath(dataRoot, dest); e != nil {
			return e
		}
		if ent.Existed {
			src, e := safeJoin(filepath.Join(folder, "files"), ent.Path)
			if e != nil {
				return e
			}
			b, e := os.ReadFile(src)
			if e != nil {
				return e
			}
			if e = atomicWrite(dest, b); e != nil {
				return e
			}
		} else {
			if e = os.Remove(dest); e != nil && !os.IsNotExist(e) {
				return e
			}
		}
	}
	return nil
}

func ExecuteInstall(exeDir string, p *InstallPlan) (string, error) {
	return executeInstall(exeDir, p, -1)
}

func executeInstall(exeDir string, p *InstallPlan, failAfter int) (string, error) {
	if e := VerifyTargetSnapshot(p); e != nil {
		return "", e
	}
	if e := checkDiskSpace(exeDir, p); e != nil {
		return "", e
	}
	rels := make([]string, 0, len(p.Writes))
	for _, w := range p.Writes {
		rels = append(rels, w.Rel)
	}
	folder, man, e := CreateBackup(exeDir, p.DataRoot, "BeforeInstall", rels)
	if e != nil {
		return folder, e
	}
	now, e := snapshot(p.DataRoot, allOutputPaths())
	if e != nil {
		return folder, e
	}
	if !compareSnapshot(p.PreSnapshot, now) {
		return folder, errors.New("target changed during backup; no files were written")
	}
	for i, w := range p.Writes {
		if failAfter >= 0 && i == failAfter {
			e = errors.New("injected write failure for rollback test")
		} else {
			var dest string
			dest, e = safeJoin(p.DataRoot, w.Rel)
			if e == nil {
				e = rejectUnsafePath(p.DataRoot, dest)
			}
			if e == nil {
				e = atomicWrite(dest, w.Data)
			}
		}
		if e != nil {
			rb := rollbackManifest(folder, man, p.DataRoot)
			if rb != nil {
				return folder, fmt.Errorf("install failed: %v; rollback also failed: %v", e, rb)
			}
			return folder, fmt.Errorf("install failed and was rolled back: %w", e)
		}
	}
	return folder, nil
}

func readValidateManifest(folder string, verifyHashes bool) (*BackupManifest, error) {
	if _, e := os.Stat(filepath.Join(folder, "BACKUP_COMPLETE.txt")); e != nil {
		return nil, errors.New("backup complete marker missing")
	}
	b, e := os.ReadFile(filepath.Join(folder, "manifest.json"))
	if e != nil {
		return nil, e
	}
	var m BackupManifest
	if e = json.Unmarshal(b, &m); e != nil {
		return nil, e
	}
	if m.InstallerFamilyID != InstallerFamilyID {
		return nil, errors.New("installer family mismatch")
	}
	if !filepath.IsAbs(m.DataRoot) {
		return nil, errors.New("manifest dataRoot is not absolute")
	}
	if m.TargetMode != "mod" && m.TargetMode != "data" && m.TargetMode != "mpq" && m.TargetMode != "mod-root" && m.TargetMode != "first-install" {
		return nil, errors.New("unsupported targetMode")
	}
	seenEntries := map[string]bool{}
	for _, ent := range m.Entries {
		c, e := cleanRel(ent.Path)
		if e != nil || c != filepath.ToSlash(ent.Path) {
			return nil, errors.New("unsafe manifest path")
		}
		if seenEntries[c] {
			return nil, errors.New("duplicate manifest path")
		}
		seenEntries[c] = true
		if ent.Existed && verifyHashes {
			p, e := safeJoin(filepath.Join(folder, "files"), c)
			if e != nil {
				return nil, e
			}
			st, e := os.Stat(p)
			if e != nil || !st.Mode().IsRegular() || st.Size() != ent.Size {
				return nil, fmt.Errorf("backup file invalid: %s", c)
			}
			h, e := hashFile(p)
			if e != nil || h != ent.SHA256 {
				return nil, fmt.Errorf("backup hash mismatch: %s", c)
			}
		}
	}
	return &m, nil
}

func ListBackups(exeDir string) ([]BackupInfo, error) {
	root := filepath.Join(exeDir, "backups")
	ents, e := os.ReadDir(root)
	if os.IsNotExist(e) {
		return nil, nil
	}
	if e != nil {
		return nil, e
	}
	var out []BackupInfo
	for _, de := range ents {
		if !de.IsDir() {
			continue
		}
		folder := filepath.Join(root, de.Name())
		m, e := readValidateManifest(folder, true)
		if e != nil {
			continue
		}
		out = append(out, BackupInfo{folder, de.Name() + " | " + m.Kind + " | " + m.CreatedAt, *m})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Manifest.CreatedAt > out[j].Manifest.CreatedAt })
	return out, nil
}

func RestoreBackup(exeDir, dataRoot, folder string) (string, error) {
	backupBase, _ := filepath.Abs(filepath.Join(exeDir, "backups"))
	folderAbs, _ := filepath.Abs(folder)
	rel, e := filepath.Rel(backupBase, folderAbs)
	if e != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
		return "", errors.New("backup folder is outside installer backups")
	}
	m, e := readValidateManifest(folderAbs, true)
	if e != nil {
		return "", e
	}
	abs, _ := filepath.Abs(dataRoot)
	bm, _ := filepath.Abs(m.DataRoot)
	if !samePath(abs, bm) {
		return "", errors.New("backup dataRoot does not match selected target")
	}
	if e = checkRestoreDiskSpace(exeDir, abs, m); e != nil {
		return "", e
	}
	rels := make([]string, 0, len(m.Entries))
	for _, x := range m.Entries {
		rels = append(rels, x.Path)
	}
	before, bm2, e := CreateBackup(exeDir, abs, "BeforeRestore", rels)
	if e != nil {
		return before, e
	}
	if e = rollbackManifest(folderAbs, m, abs); e != nil {
		if rb := rollbackManifest(before, bm2, abs); rb != nil {
			return before, fmt.Errorf("restore failed: %v; recovery failed: %v", e, rb)
		}
		return before, fmt.Errorf("restore failed and was recovered: %w", e)
	}
	return before, nil
}

func checkRestoreDiskSpace(exeDir, dataRoot string, m *BackupManifest) error {
	var current, restore uint64
	for _, ent := range m.Entries {
		p, _ := safeJoin(dataRoot, ent.Path)
		if st, e := os.Stat(p); e == nil {
			current += uint64(st.Size())
		}
		if ent.Existed {
			restore += uint64(ent.Size)
		}
	}
	const reserve uint64 = 32 * 1024 * 1024
	be, e := existingAncestor(exeDir)
	if e != nil {
		return e
	}
	bf, e := freeSpace(be)
	if e != nil {
		return e
	}
	if bf < current+reserve {
		return errors.New("not enough free space for BeforeRestore backup")
	}
	te, e := existingAncestor(dataRoot)
	if e != nil {
		return e
	}
	tf, e := freeSpace(te)
	if e != nil {
		return e
	}
	if tf < restore+current+reserve {
		return errors.New("not enough free space on target volume for restore")
	}
	return nil
}

func existingAncestor(p string) (string, error) {
	a, _ := filepath.Abs(p)
	for {
		if st, e := os.Stat(a); e == nil && st.IsDir() {
			return a, nil
		}
		n := filepath.Dir(a)
		if n == a {
			return "", errors.New("no existing ancestor for disk check")
		}
		a = n
	}
}

func checkDiskSpace(exeDir string, p *InstallPlan) error {
	var writeBytes, backupBytes uint64
	for _, w := range p.Writes {
		writeBytes += uint64(len(w.Data))
		tp, _ := safeJoin(p.DataRoot, w.Rel)
		if st, e := os.Stat(tp); e == nil {
			backupBytes += uint64(st.Size())
		}
	}
	const reserve uint64 = 32 * 1024 * 1024
	be, err := existingAncestor(exeDir)
	if err != nil {
		return err
	}
	bf, err := freeSpace(be)
	if err != nil {
		return fmt.Errorf("backup disk-space check failed: %w", err)
	}
	if bf < backupBytes+reserve {
		return errors.New("not enough free space for backup")
	}
	te, err := existingAncestor(p.DataRoot)
	if err != nil {
		return err
	}
	tf, err := freeSpace(te)
	if err != nil {
		return fmt.Errorf("target disk-space check failed: %w", err)
	}
	if tf < writeBytes+backupBytes+reserve {
		return errors.New("not enough free space on target volume")
	}
	return nil
}

func ResolveTarget(selected string, firstInstall bool) (dataRoot string, mode string, error error) {
	selected = strings.TrimSpace(selected)
	if selected == "" {
		return "", "", errors.New("target folder is empty")
	}
	abs, e := filepath.Abs(selected)
	if e != nil {
		return "", "", e
	}
	if firstInstall {
		root := filepath.Join(abs, "JimSSng")
		mpq := filepath.Join(root, "JimSSng.mpq")
		dr := filepath.Join(mpq, "data")
		if _, e = os.Stat(root); e == nil {
			return "", "", errors.New("JimSSng folder already exists; use normal target mode")
		}
		return dr, "mod", nil
	}
	base := strings.ToLower(filepath.Base(abs))
	if base == "data" {
		par := filepath.Dir(abs)
		if strings.ToLower(filepath.Ext(par)) != ".mpq" {
			return "", "", errors.New("selected data folder is not under .mpq")
		}
		return abs, "mod", nil
	}
	if strings.ToLower(filepath.Ext(abs)) == ".mpq" {
		dr := filepath.Join(abs, "data")
		if st, e := os.Stat(dr); e != nil || !st.IsDir() {
			return "", "", errors.New("selected .mpq has no data folder")
		}
		return dr, "mod", nil
	}
	cand := filepath.Join(abs, filepath.Base(abs)+".mpq")
	if st, e := os.Stat(cand); e == nil && st.IsDir() {
		dr := filepath.Join(cand, "data")
		if st, e := os.Stat(dr); e == nil && st.IsDir() {
			return dr, "mod", nil
		}
	}
	ents, e := os.ReadDir(abs)
	if e != nil {
		return "", "", e
	}
	var mpqs []string
	for _, d := range ents {
		if d.IsDir() && strings.EqualFold(filepath.Ext(d.Name()), ".mpq") {
			mpqs = append(mpqs, filepath.Join(abs, d.Name()))
		}
	}
	if len(mpqs) != 1 {
		if len(mpqs) > 1 {
			return "", "", errors.New("multiple .mpq folders found; select one directly")
		}
		return "", "", errors.New("no .mpq folder found")
	}
	dr := filepath.Join(mpqs[0], "data")
	if st, e := os.Stat(dr); e != nil || !st.IsDir() {
		return "", "", errors.New("resolved .mpq has no data folder")
	}
	return dr, "mod", nil
}

func PrepareFirstInstall(selected, dataRoot string) error {
	abs, _ := filepath.Abs(selected)
	root := filepath.Join(abs, "JimSSng")
	expected := filepath.Join(root, "JimSSng.mpq", "data")
	if !samePath(expected, dataRoot) {
		return errors.New("unexpected first-install path")
	}
	return os.MkdirAll(dataRoot, 0755)
}

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

func WriteFailureLog(exeDir, phase, sourceRoot, selected, dataRoot string, first bool, err error, backup string, rollback string) string {
	name := time.Now().Format("20060102_150405") + "_INSTALL_FAILED.log"
	p := filepath.Join(exeDir, name)
	f, e := os.Create(p)
	if e != nil {
		return ""
	}
	defer f.Close()
	w := bufio.NewWriter(f)
	defer w.Flush()
	fmt.Fprintf(w, "Installer: %s %s\nMod: %s %s\nTime: %s\nSource root: %s\nSelected target: %s\nResolved dataRoot: %s\nFirst install: %v\nPhase: %s\nError: %v\nBackup folder: %s\nRollback: %s\n", AppName, AppVersion, ModName, ModVersion, time.Now().Format(time.RFC3339), sourceRoot, selected, dataRoot, first, phase, err, backup, rollback)
	return p
}
