#!/usr/bin/env bash

# merge entries own some keys of a JSON, TOML, or YAML file that an
# application also writes; every other key, comment, and the key order stay
mkdir -p shared
cat <<'EOF' >shared/codex.toml
model = "gpt-5"

[tui]
theme = "light"
EOF
cat <<'EOF' >shared/claude.json
{"model": "opus", "permissions": {"allow": ["Bash(ls)"]}}
EOF
cat <<'EOF' >shared/omp.yml
theme: light
tools:
  bash: true
EOF
cat <<'EOF' >mise.toml
[dotfiles]
"~/.codex/config.toml/shared" = { source = "shared/codex.toml", merge = true }
"~/.claude/settings.json/shared" = { source = "shared/claude.json", merge = true }
"~/.omp/config.yml/shared" = { source = "shared/omp.yml", merge = true }
EOF

# nothing exists yet, so everything is missing
assert_contains "mise dotfiles status" "merge:shared"
assert_contains "mise dotfiles status" "missing"
assert_fail "mise dotfiles status --missing"

# a dry run lists the work and writes nothing
assert_contains "mise dotfiles apply --dry-run" "merge:shared"
assert_fail "cat ~/.codex/config.toml"

# applying creates missing files from the source
assert_succeed "mise dotfiles apply --yes"
assert_contains "cat ~/.codex/config.toml" 'model = "gpt-5"'
assert_contains "cat ~/.claude/settings.json" '"model": "opus"'
assert_contains "cat ~/.omp/config.yml" "theme: light"

# the applications write their own keys, comments included
mkdir -p ~/.codex ~/.claude ~/.omp
cat <<'EOF' >~/.codex/config.toml
# written by the desktop app
notify = ["a"]
model = "gpt-4"

[tui]
theme = "dark"  # app picked this

[plugins.foo]
path = "/Applications/X.app"
EOF
cat <<'EOF' >~/.claude/settings.json
{
    "zzz": 1,
    "model": "sonnet",
    "permissions": {"deny": ["Bash(rm)"]}
}
EOF
cat <<'EOF' >~/.omp/config.yml
# omp keeps this note
theme: dark   # toggled in the UI
modes:
  fast: true
tools:
  bash: false
EOF

# drift is reported for owned keys only
assert_contains "mise dotfiles status" "differs"
assert_contains "mise dotfiles diff" '-model = "gpt-4"'
assert_contains "mise dotfiles diff" '+model = "gpt-5"'
assert_not_contains "mise dotfiles diff" '-notify'

assert_succeed "mise dotfiles apply --yes"

# owned keys are set; everything else is exactly where the app left it
cat <<'EOF' >expected-codex.toml
# written by the desktop app
notify = ["a"]
model = "gpt-5"

[tui]
theme = "light"  # app picked this

[plugins.foo]
path = "/Applications/X.app"
EOF
assert_succeed "cmp expected-codex.toml ~/.codex/config.toml"
cat <<'EOF' >expected-omp.yml
# omp keeps this note
theme: light   # toggled in the UI
modes:
  fast: true
tools:
  bash: true
EOF
assert_succeed "cmp expected-omp.yml ~/.omp/config.yml"
assert "jq -r .zzz ~/.claude/settings.json" "1"
assert "jq -r .model ~/.claude/settings.json" "opus"
assert "jq -c .permissions ~/.claude/settings.json" '{"deny":["Bash(rm)"],"allow":["Bash(ls)"]}'
assert "head -2 ~/.claude/settings.json | tail -1" '    "zzz": 1,'

# in sync now, and an application writing a new key does not cause drift
assert_succeed "mise dotfiles status --missing"
echo 'extra = true' >>~/.codex/config.toml
assert_succeed "mise dotfiles apply --yes"
assert_contains "cat ~/.codex/config.toml" "extra = true"
assert_contains "mise dotfiles apply --dry-run 2>&1" "all edits are applied"

# unapply leaves merged keys alone
assert_succeed "mise dotfiles unapply --yes"
assert_contains "cat ~/.codex/config.toml" 'model = "gpt-5"'

# unusable entries are refused instead of guessed at
cat <<'EOF' >mise.toml
[dotfiles]
"~/notes.txt/shared" = { source = "shared/codex.toml", merge = true }
EOF
assert_contains "mise dotfiles status 2>&1" "merge needs a .json, .toml, .yaml, or .yml target"

# a target that does not parse is reported, never overwritten
cat <<'EOF' >mise.toml
[dotfiles]
"~/broken.json/shared" = { source = "shared/claude.json", merge = true }
EOF
echo '{// not json' >~/broken.json
assert_fail "mise dotfiles apply --yes"
assert "cat ~/broken.json" "{// not json"

# a source that does not parse is refused before anything is written, even
# when its target does not exist yet
mkdir -p ~/early
echo '{// not json' >shared/bad.json
cat <<'EOF' >mise.toml
[dotfiles]
"~/early/first.toml/shared" = { source = "shared/codex.toml", merge = true }
"~/early/second.json/shared" = { source = "shared/bad.json", merge = true }
EOF
assert_fail_contains "mise dotfiles apply --yes" "invalid merge source"
assert_fail "cat ~/early/first.toml"

# two entries that set one key to different values are refused, not flipped
cat <<'EOF' >shared/other.toml
model = "gpt-4"
EOF
cat <<'EOF' >mise.toml
[dotfiles]
"~/conflict.toml/a" = { source = "shared/codex.toml", merge = true }
"~/conflict.toml/b" = { source = "shared/other.toml", merge = true }
EOF
assert_fail_contains "mise dotfiles apply --yes" "set different values for model"
assert_fail "cat ~/conflict.toml"

# a trailing newline in a YAML block scalar is part of the value
printf 'notes: |\n  line one\n  line two\n' >shared/notes.yml
cat <<'EOF' >mise.toml
[dotfiles]
"~/notes.yml/shared" = { source = "shared/notes.yml", merge = true }
EOF
assert_succeed "mise dotfiles apply --yes"
assert_succeed "mise dotfiles status --missing"
assert_contains "mise dotfiles apply --dry-run 2>&1" "all edits are applied"

# unapply never touches a merge target, even one it could not read
ln -sf ~/notes.yml ~/notes-link.yml
cat <<'EOF' >mise.toml
[dotfiles]
"~/notes-link.yml/shared" = { source = "shared/notes.yml", merge = true }
EOF
assert_succeed "mise dotfiles unapply --yes"

# the same file under another name (a hard link here, a case variant on
# case-insensitive filesystems) still counts as one target for conflicts
printf 'model = "x"\n' >~/hl.toml
ln ~/hl.toml ~/hl-alias.toml
cat <<'EOF' >mise.toml
[dotfiles]
"~/hl.toml/a" = { source = "shared/codex.toml", merge = true }
"~/hl-alias.toml/b" = { source = "shared/other.toml", merge = true }
EOF
assert_fail_contains "mise dotfiles apply --yes" "set different values for model"
assert "cat ~/hl.toml" 'model = "x"'

# applying one of two conflicting entries through a target filter still sees
# the other one
cat <<'EOF' >mise.toml
[dotfiles]
"~/filtered.toml/a" = { source = "shared/codex.toml", merge = true }
"~/filtered.toml/b" = { source = "shared/other.toml", merge = true }
EOF
assert_fail_contains "mise dotfiles apply ~/filtered.toml/a --yes" "set different values for model"
assert_fail "cat ~/filtered.toml"

# two siblings that disagree do not block applying a third entry that this run
# is not conflicting with
cat <<'EOF' >shared/third.toml
other = 1
EOF
cat <<'EOF' >mise.toml
[dotfiles]
"~/trio.toml/a" = { source = "shared/codex.toml", merge = true }
"~/trio.toml/b" = { source = "shared/other.toml", merge = true }
"~/trio.toml/c" = { source = "shared/third.toml", merge = true }
EOF
assert_succeed "mise dotfiles apply ~/trio.toml/c --yes"
assert "cat ~/trio.toml" "other = 1"

# a sibling whose target is blocked is not rendered, so its template's exec()
# does not run
printf 'x = 1\n' >~/real-target.toml
ln -sf ~/real-target.toml ~/linked.toml
cat <<'EOF' >shared/exec.toml.tera
{{ exec(command="touch $HOME/sibling-exec-ran") }}x = 2
EOF
cat <<'EOF' >mise.toml
[dotfiles]
"~/real-target.toml/a" = { source = "shared/other.toml", merge = true }
"~/linked.toml/b" = { source = "shared/exec.toml.tera", merge = true, template = "tera" }
EOF
rm -f ~/sibling-exec-ran
assert_succeed "mise dotfiles apply ~/real-target.toml/a --yes"
assert_fail "test -e ~/sibling-exec-ran"

# whole-file keys on a merge entry are refused by name and never make the
# target a directory
cat <<'EOF' >mise.toml
[dotfiles]
"~/withx.json/shared" = { source = "shared/claude.json", merge = true, exclude = [] }
EOF
assert_contains "mise dotfiles status 2>&1" "exclude applies to whole-file entries, not merge edits"
mise dotfiles apply --yes >/dev/null 2>&1
assert_fail "test -e ~/withx.json"

# one file merged as two formats (a hard link under a .json and a .yml name) is
# refused whatever the sources hold
printf '{}\n' >~/fmt.json
ln ~/fmt.json ~/fmt.yml
cat <<'EOF' >shared/model.json
{"model": "a"}
EOF
cat <<'EOF' >shared/model.yml
model: b
EOF
cat <<'EOF' >mise.toml
[dotfiles]
"~/fmt.json/a" = { source = "shared/model.json", merge = true }
"~/fmt.yml/b" = { source = "shared/model.yml", merge = true }
EOF
assert_fail_contains "mise dotfiles apply --yes" "as different formats"
assert "cat ~/fmt.json" "{}"

# the format conflict needs no rendering, so templates and dry runs see it too
cat <<'EOF' >shared/model.json.tera
{"model": "{{ 'a' }}"}
EOF
cat <<'EOF' >mise.toml
[dotfiles]
"~/fmt.json/a" = { source = "shared/model.json.tera", merge = true, template = "tera" }
"~/fmt.yml/b" = { source = "shared/model.yml", merge = true }
EOF
assert_fail_contains "mise dotfiles apply --dry-run" "as different formats"

# a sibling whose target is a symlink cannot write anything, so it does not
# count as a format conflict for an entry applied through a filter
printf '{}\n' >~/sym.json
ln -sf ~/sym.json ~/sym-alias.yml
cat <<'EOF' >mise.toml
[dotfiles]
"~/sym.json/a" = { source = "shared/model.json", merge = true }
"~/sym-alias.yml/b" = { source = "shared/model.yml", merge = true }
EOF
assert_succeed "mise dotfiles apply ~/sym.json/a --yes"
assert_contains "cat ~/sym.json" '"model": "a"'

# an unselected entry whose target is a named pipe is never opened by a filtered
# apply, so it cannot hang it
mkfifo ~/pipe.json
cat <<'EOF' >mise.toml
[dotfiles]
"~/ok.json/a" = { source = "shared/model.json", merge = true }
"~/pipe.json/b" = { source = "shared/model.json", merge = true }
EOF
printf '{}\n' >~/ok.json
assert_succeed "timeout 60 mise dotfiles apply ~/ok.json/a --yes"
assert_contains "cat ~/ok.json" '"model": "a"'

# a filtered apply never renders an unselected template entry for the same
# file, so its exec() does not run
printf '{}\n' >~/shared-target.json
cat <<'EOF' >shared/exec.json.tera
{{ exec(command="touch $HOME/unselected-exec-ran") }}{"model": "x"}
EOF
cat <<'EOF' >mise.toml
[dotfiles]
"~/shared-target.json/a" = { source = "shared/model.json", merge = true }
"~/shared-target.json/b" = { source = "shared/exec.json.tera", merge = true, template = "tera" }
EOF
rm -f ~/unselected-exec-ran
assert_succeed "mise dotfiles apply ~/shared-target.json/a --yes"
assert_fail "test -e ~/unselected-exec-ran"

# a merge entry without a source reads the target's path under dotfiles.root
mkdir -p ~/.dotfiles/.implied
printf 'model = "gpt-5"\n' >~/.dotfiles/.implied/config.toml
cat <<'EOF2' >mise.toml
[dotfiles]
"~/.implied/config.toml/shared" = { merge = true }
EOF2
assert_succeed "mise dotfiles apply --yes"
assert_contains "cat ~/.implied/config.toml" 'model = "gpt-5"'

# switching a symlink to merge keeps the application's keys: the link to the
# merge source becomes a copy first, so trimming the source loses nothing
mkdir -p ~/.switch ~/.dotfiles/.switch
printf 'model = "gpt-4"\nnotify = ["a"]\n' >~/.dotfiles/.switch/config.toml
ln -s ~/.dotfiles/.switch/config.toml ~/.switch/config.toml
cat <<'EOF2' >mise.toml
[dotfiles]
"~/.switch/config.toml/shared" = { merge = true }
EOF2
assert_contains "mise dotfiles status 2>&1" "symlink to the merge source"
assert_succeed "mise dotfiles apply --yes"
assert_fail "test -L ~/.switch/config.toml"
assert_contains "cat ~/.switch/config.toml" 'notify = ["a"]'
printf 'model = "gpt-5"\n' >~/.dotfiles/.switch/config.toml
assert_succeed "mise dotfiles apply --yes"
assert_contains "cat ~/.switch/config.toml" 'notify = ["a"]'
assert_contains "cat ~/.switch/config.toml" 'model = "gpt-5"'

# a link to anywhere else is still refused, with the way out
echo 'model = "x"' >~/elsewhere.toml
ln -s ~/elsewhere.toml ~/other-link.toml
cat <<'EOF2' >mise.toml
[dotfiles]
"~/other-link.toml/shared" = { source = "shared/codex.toml", merge = true }
EOF2
assert_fail_contains "mise dotfiles apply --yes" "replace it with a copy of the real file"

# a read-only merge source still becomes a writable copy
mkdir -p ~/.dotfiles/.ro ~/.ro
printf 'notify = ["a"]\n' >~/.dotfiles/.ro/config.toml
chmod 444 ~/.dotfiles/.ro/config.toml
ln -s ~/.dotfiles/.ro/config.toml ~/.ro/config.toml
cat <<'EOF2' >mise.toml
[dotfiles]
"~/.ro/config.toml/shared" = { merge = true }
EOF2
assert_succeed "mise dotfiles apply --yes"
assert_fail "test -L ~/.ro/config.toml"
assert_contains "cat ~/.ro/config.toml" 'notify = ["a"]'
# root can write a 0444 file, so check the owner-write bit itself
assert "ls -l ~/.ro/config.toml | cut -c3" "w"

# merge = "missing" fills in defaults and leaves values the app changed
mkdir -p ~/.fill
cat <<'EOF2' >shared/fill-enforced.toml
theme = "light"
EOF2
cat <<'EOF2' >shared/fill-defaults.toml
model = "gpt-5"
effort = "high"
theme = "dark"

[tui]
size = 3
EOF2
cat <<'EOF2' >mise.toml
[dotfiles]
"~/.fill/config.toml/enforced" = { source = "shared/fill-enforced.toml", merge = true }
"~/.fill/config.toml/defaults" = { source = "shared/fill-defaults.toml", merge = "missing" }
EOF2
printf 'model = "mine"\n\n[tui]\ntheme = "x"\n' >~/.fill/config.toml
assert_contains "mise dotfiles status" "keys are missing"
assert_succeed "mise dotfiles apply --yes"
assert_contains "cat ~/.fill/config.toml" 'model = "mine"'
assert_contains "cat ~/.fill/config.toml" 'effort = "high"'
assert_contains "cat ~/.fill/config.toml" 'size = 3'
assert_contains "cat ~/.fill/config.toml" 'theme = "light"'
assert_succeed "mise dotfiles status --missing"
# the app changes a default and removes another: only the removed one returns
printf 'model = "other"\ntheme = "light"\n[tui]\nsize = 9\n' >~/.fill/config.toml
assert_succeed "mise dotfiles apply --yes"
assert_contains "cat ~/.fill/config.toml" 'model = "other"'
assert_contains "cat ~/.fill/config.toml" 'effort = "high"'
assert_contains "cat ~/.fill/config.toml" 'size = 9'
assert_succeed "mise dotfiles status --missing"

# defaults are reconsidered after an enforced merge on the same file: a scalar
# the enforced entry replaces with a table does not hide the defaults inside it
cat <<'EOF2' >shared/order-enforced.toml
[tui]
theme = "light"
EOF2
cat <<'EOF2' >shared/order-defaults.toml
[tui]
size = 3
EOF2
cat <<'EOF2' >mise.toml
[dotfiles]
"~/order.toml/a" = { source = "shared/order-defaults.toml", merge = "missing" }
"~/order.toml/b" = { source = "shared/order-enforced.toml", merge = true }
EOF2
echo 'tui = false' >~/order.toml
assert_succeed "mise dotfiles apply --yes"
assert_contains "cat ~/order.toml" 'size = 3'
assert_contains "cat ~/order.toml" 'theme = "light"'
assert_succeed "mise dotfiles status --missing"

# status and diff judge defaults the way apply runs them, after the enforced merge
echo 'tui = false' >~/order.toml
assert_contains "mise dotfiles status" "keys are missing"
assert_contains "mise dotfiles diff" '+size = 3'
assert_succeed "mise dotfiles apply --yes"
assert_succeed "mise dotfiles status --missing"

# a filtered preview runs only selected enforced merges, just like apply
printf 'model = "enforced"\n' >shared/selected-enforced.toml
printf 'model = "default"\n' >shared/selected-defaults.toml
cat <<'EOF2' >mise.toml
[dotfiles]
"~/selected.toml/enforced" = { source = "shared/selected-enforced.toml", merge = true }
"~/selected.toml/defaults" = { source = "shared/selected-defaults.toml", merge = "missing" }
EOF2
echo 'theme = "mine"' >~/selected.toml
assert_contains "mise dotfiles status ~/selected.toml/defaults" "keys are missing"
assert_contains "mise dotfiles diff ~/selected.toml/defaults" '+model = "default"'
assert_succeed "mise dotfiles apply ~/selected.toml/defaults --yes"
assert_contains "cat ~/selected.toml" 'model = "default"'

# Competing defaults are previewed in the same order as apply.
echo 'model = "first"' >~/competing-first.toml
echo 'model = "second"' >~/competing-second.toml
echo 'theme = "existing"' >~/competing-target.toml
cat >>~/.config/mise/config.toml <<'TOML'

[dotfiles."~/competing-target.toml/first"]
source = "~/competing-first.toml"
merge = "missing"
[dotfiles."~/competing-target.toml/second"]
source = "~/competing-second.toml"
merge = "missing"
TOML
assert_contains 'mise dot diff ~/competing-target.toml' '+model = "first"'
assert_not_contains 'mise dot diff ~/competing-target.toml' '+model = "second"'
assert_succeed 'mise dot apply ~/competing-target.toml --yes'
assert_contains 'cat ~/competing-target.toml' 'model = "first"'

# Earlier defaults retain live keys for subsequent defaults' status and diff.
echo 'effort = "high"' >~/competing-first.toml
echo 'model = "mine"' >~/competing-target.toml
assert_not_contains 'mise dot diff ~/competing-target.toml' '+model = "second"'
assert_contains 'mise dot status ~/competing-target.toml/second' 'applied'
assert_succeed 'mise dot apply ~/competing-target.toml --yes'
assert_contains 'cat ~/competing-target.toml' 'model = "mine"'
assert_contains 'cat ~/competing-target.toml' 'effort = "high"'
