-- Manifest v2 and lifecycle v2 over an injected in-memory filesystem:
-- semver ranges, game_version enforcement, conflict refusal, disabled and
-- version-mismatched dependencies, cycle isolation, inter-mod exports/find,
-- the rest of the v2 mod object, and the dev-mode permissions tripwire.
package.path = "./?.lua;./?/init.lua;" .. package.path
-- run_tests installs the stub before chaining this file; standalone runs get
-- their own so mod.assets:image has a graphics context either way
love = love or require("tests.love_stub")

local Loader = require("src.mods.Loader")
local Manifest = require("src.mods.Manifest")
local Semver = require("src.mods.Semver")
local Runtime = require("src.mods.Runtime")
local Version = require("src.core.Version")
local Logger = require("src.core.Logger")

local savedEvents, savedHooks = Runtime.events, Runtime.hooks

local S = require("tests.harness").suite("mod manifest v2")
local check = S.check

local function logged(fragmentA, fragmentB)
  for _, line in ipairs(Logger.history) do
    if line:find(fragmentA, 1, true)
        and (not fragmentB or line:find(fragmentB, 1, true)) then
      return true
    end
  end
  return false
end

local function memfs(files)
  return {
    read = function(path) return files[path] end,
    getInfo = function(path)
      if files[path] then return { type = "file" } end
      local prefix = path .. "/"
      for key in pairs(files) do
        if key:sub(1, #prefix) == prefix then return { type = "directory" } end
      end
      return nil
    end,
    load = function(path)
      if not files[path] then return nil, "no file: " .. path end
      return load(files[path], path)
    end,
    getDirectoryItems = function(path)
      local seen, items = {}, {}
      local prefix = path .. "/"
      for key in pairs(files) do
        if key:sub(1, #prefix) == prefix then
          local child = key:sub(#prefix + 1):match("^[^/]+")
          if child and not seen[child] then
            seen[child] = true
            items[#items + 1] = child
          end
        end
      end
      table.sort(items)
      return items
    end,
  }
end

-- fields is a table of extra manifest json fragments, e.g. {api = "2"}
local function manifestJson(id, extra)
  local parts = {
    ('"id":"%s"'):format(id), ('"name":"%s"'):format(id),
    '"version":"1.0.0"', '"entry":"main.lua"',
  }
  for key, value in pairs(extra or {}) do
    parts[#parts + 1] = ('"%s":%s'):format(key, value)
  end
  return "{" .. table.concat(parts, ",") .. "}"
end

local NOOP = "return function(mod) end\n"

local function statusById(loader)
  local byId = {}
  for _, entry in ipairs(loader:status().available) do byId[entry.id] = entry end
  return byId
end

-- ------- semver
check(Semver.satisfies("1.0.0", ">=1.0 <2.0"), "range: 1.0.0 in >=1.0 <2.0")
check(not Semver.satisfies("2.0.0", ">=1.0 <2.0"), "range: 2.0.0 out of >=1.0 <2.0")
check(Semver.satisfies("1.4.7", "^1.4"), "caret accepts a later patch")
check(not Semver.satisfies("2.0.0", "^1.4"), "caret stops at the next major")
check(Semver.satisfies("0.2.9", "^0.2") and not Semver.satisfies("0.3.0", "^0.2"),
  "caret pins the leftmost non-zero component")
check(Semver.satisfies("2.1.0", "^1.4 || ^2.0"), "|| offers alternatives")
check(Semver.compare("1.0.0-beta", "1.0.0") == -1, "a pre-release sorts first")
check(Semver.compare("1.10.0", "1.9.0") == 1, "components compare numerically")
local ok, err = Semver.satisfies("banana", ">=1.0")
check(ok == false and err ~= nil, "an unparsable version reports a reason")
local rangeOk, rangeErr = Semver.validRange(">>1.0")
check(rangeOk == false and rangeErr ~= nil, "a malformed range reports a reason")
check(Semver.parse("1").minor == 0 and Semver.parse("1.2").patch == 0,
  "absent version components default to 0")

-- ------- manifest v2 fields
local full = Manifest.validate({
  id = "full", name = "Full", version = "1.0.0", entry = "main.lua",
  api = 2, profile = "overhaul", permissions = { "network" },
  dependencies = { "colorlib@^1.2" }, conflicts = { "always_noon" },
  options_schema = "options.lua", assets_transforms = "transforms.lua",
  force_enable_env = "SOME_ENV",
}, "mods/full")
check(full.api == 2 and full.profile == "overhaul", "api and profile parse")
check(full.affects_link == true, "overhaul defaults to affecting link play")
check(full.permissionSet.network == true, "permissions normalize to a set")
check(full.dependencySpecs[1].id == "colorlib"
  and full.dependencySpecs[1].range == "^1.2", "dependency pins parse as id@range")
check(full.conflictSpecs[1].id == "always_noon" and full.conflictSpecs[1].range == nil,
  "a bare conflict entry has no range")
check(full.options_schema == "options.lua"
  and full.assets_transforms == "transforms.lua", "declared files are kept")
check(full.force_enable_env == "SOME_ENV", "force_enable_env is kept")

-- ------- github / experimental / incompatible
local gh = Manifest.validate({
  id = "gh", name = "GH", version = "1.0.0", entry = "main.lua",
  github = "https://github.com/Acme/Cool-Mod.git",
  experimental = true,
  incompatible = { "rival" },
  conflicts = { "rival", "other" },
})
check(gh.github == "Acme/Cool-Mod", "github URL normalizes to owner/repo")
check(gh.experimental == true, "experimental flag is kept")
check(#gh.conflictSpecs == 2, "incompatible merges into conflicts without dupes")
check(gh.conflictSpecs[1].id == "rival" and gh.conflictSpecs[2].id == "other",
  "conflicts list order keeps conflicts then new incompatible ids")
check(Manifest.parseGithub(nil) == nil and Manifest.parseGithub("") == nil,
  "absent github is nil")
check(not pcall(Manifest.parseGithub, "not a repo"),
  "a malformed github value fails")
check(not pcall(Manifest.validate, {
  id = "badgh", name = "Bad", version = "1.0.0", entry = "main.lua",
  github = "ftp://example.com/x",
}), "an unsupported github URL fails validation")

-- ------- dependency github repo spec hints & dependency resolver
local depGh = Manifest.validate({
  id = "depgh", name = "DepGH", version = "1.0.0", entry = "main.lua",
  dependencies = { "colorlib@^1.2.0#Acme/ColorLib", "soundpack#Acme/SoundPack" },
  dependency_sources = { helper = "Acme/Helper" },
})
check(depGh.dependencySpecs[1].id == "colorlib" and depGh.dependencySpecs[1].github == "Acme/ColorLib",
  "dependency spec hash hint parses github owner/repo")
check(depGh.dependencySpecs[2].id == "soundpack" and depGh.dependencySpecs[2].github == "Acme/SoundPack",
  "dependency spec hash hint without range parses github owner/repo")

local LauncherMods = require("src.mods.LauncherMods")
local depCheck = LauncherMods.checkDependencies(depGh)
check(depCheck.hasIssues == true, "missing dependencies trigger issues verdict")
check(#depCheck.deps == 2, "dependency check lists all specs")
check(depCheck.deps[1].status == "missing", "absent dependency reports missing")
check(depCheck.deps[1].safeUrl == "https://github.com/Acme/ColorLib", "safeUrl built from validated github repo")

local v1 = Manifest.validate({
  id = "v1", name = "V1", version = "1.0.0", entry = "main.lua",
}, "mods/v1")
check(v1.api == 1, "an absent api means 1")
check(v1.profile == "content" and v1.affects_link == false,
  "a content profile is the default and does not claim link relevance")
check(#v1.permissions == 0 and next(v1.permissionSet) == nil,
  "no permissions declared means none granted")

check(not pcall(Manifest.validate, {
  id = "future", name = "Future", version = "1.0.0", entry = "main.lua",
  api = Version.modApi + 1,
}, "mods/future"), "an api newer than the engine fails validation")
check(not pcall(Manifest.validate, {
  id = "badprofile", name = "Bad", version = "1.0.0", entry = "main.lua",
  api = 2, profile = "nonsense",
}, "mods/badprofile"), "an unknown profile fails for api 2")
local coerced = Manifest.validate({
  id = "oldprofile", name = "Old", version = "1.0.0", entry = "main.lua",
  profile = "nonsense",
}, "mods/oldprofile")
check(coerced.profile == "content", "an unknown profile coerces for api 1")
check(logged("[oldprofile]", "unknown profile"), "the coercion is attributed")
check(not pcall(Manifest.validate, {
  id = "badperm", name = "Bad", version = "1.0.0", entry = "main.lua",
  api = 2, permissions = { "root" },
}, "mods/badperm"), "an unknown permission fails for api 2")
check(pcall(Manifest.validate, {
  id = "oldperm", name = "Old", version = "1.0.0", entry = "main.lua",
  permissions = { "root" },
}, "mods/oldperm"), "an unknown permission only warns for api 1")
check(not pcall(Manifest.validate, {
  id = "badrange", name = "Bad", version = "1.0.0", entry = "main.lua",
  game_version = ">>1.0",
}, "mods/badrange"), "a malformed game_version fails validation")
check(not pcall(Manifest.validate, {
  id = "baddep", name = "Bad", version = "1.0.0", entry = "main.lua",
  dependencies = { "other@nonsense" },
}, "mods/baddep"), "a malformed dependency range fails validation")

-- ------- experimental mods stay disabled until options.mods says otherwise
do
  local loader = Loader.new({ fs = memfs({
    ["mods/lab/manifest.json"] = manifestJson("lab", {
      experimental = "true", api = "2", profile = '"content"',
    }),
    ["mods/lab/main.lua"] = "return function(mod) mod.content.items:register('X', {}) end",
  }) })
  local data = { items = {} }
  check(loader:load(data) == true, "experimental-only install still boots")
  local st = statusById(loader)
  check(st.lab.state == "disabled", "experimental mod is disabled by default")
  check(data.items.X == nil, "experimental entry chunk does not run while off")
end

-- ------- game_version against the engine
--
-- Stamped, because the working tree carries the "0.0.0-dev" placeholder and
-- Loader:_validate skips the range check against it on purpose (a placeholder
-- sorts below every release, so a checkout would refuse every mod that names a
-- floor).  What is under test here is the check a SHIPPED build runs.
local realEngine = Version.engine
Version.engine = "1.4.0"
local versionLoader = Loader.new({ fs = memfs({
  ["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }),
  ["mods/future/main.lua"] = "return function(mod) mod.content.items:register('NOPE', {}) end",
  ["mods/current/manifest.json"] = manifestJson("current", { game_version = '">=0.0.0-0 <2.0"' }),
  ["mods/current/main.lua"] = NOOP,
}) })
local versionData = { items = {} }
check(versionLoader:load(versionData) == false, "a game_version miss fails the load")
local versionStatus = statusById(versionLoader)
check(versionStatus.future.state == "invalid",
  "the mod that outranges the engine is refused")
check(versionStatus.future.error:find(Version.engine, 1, true) ~= nil,
  "the refusal names the engine version")
check(versionData.items.NOPE == nil, "a refused mod never runs its entry chunk")
check(versionStatus.current.state == "loaded",
  "a satisfied game_version range still loads")

local shelvedLoader = Loader.new({ fs = memfs({
  ["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }),
  ["mods/future/main.lua"] = NOOP,
  ["options.lua"] = "return { mods = { future = false } }",
}) })
check(shelvedLoader:load({}) == true,
  "a switched-off mod that could not load is not a boot problem")
check(statusById(shelvedLoader).future.state == "disabled",
  "a switched-off mod reports as disabled, not as invalid")

-- and the placeholder itself: a dev checkout must not refuse a mod that names
-- a floor, because the number it would be judged against is not a release
Version.engine = realEngine
local devLoader = Loader.new({ fs = memfs({
  ["mods/floored/manifest.json"] = manifestJson("floored", { game_version = '">=0.1.0 <2.0.0"' }),
  ["mods/floored/main.lua"] = NOOP,
}) })
check(devLoader:load({}) == true, "a dev checkout loads a mod that names a floor")
check(statusById(devLoader).floored.state == "loaded",
  "the 0.0.0-dev placeholder is not a compatibility statement")

-- ------- conflicts refuse to co-enable
local conflictLoader = Loader.new({ fs = memfs({
  ["mods/noon/manifest.json"] = manifestJson("noon", { conflicts = '["dusk"]' }),
  ["mods/noon/main.lua"] = NOOP,
  ["mods/dusk/manifest.json"] = manifestJson("dusk", { conflicts = '["noon"]' }),
  ["mods/dusk/main.lua"] = NOOP,
  ["mods/bystander/manifest.json"] = manifestJson("bystander"),
  ["mods/bystander/main.lua"] = NOOP,
}) })
check(conflictLoader:load({}) == false, "mutual conflicts fail the load")
local conflictStatus = statusById(conflictLoader)
check(conflictStatus.noon.state == "conflict" and conflictStatus.dusk.state == "conflict",
  "two mods declaring each other both refuse to co-enable")
check(conflictStatus.noon.error:find("conflicts with dusk", 1, true) ~= nil,
  "the conflict message names the other mod")
check(conflictStatus.bystander.state == "loaded", "an unrelated mod still loads")

local onesidedLoader = Loader.new({ fs = memfs({
  ["mods/picky/manifest.json"] = manifestJson("picky", { conflicts = '["plain@^1.0"]' }),
  ["mods/picky/main.lua"] = NOOP,
  ["mods/plain/manifest.json"] = manifestJson("plain"),
  ["mods/plain/main.lua"] = NOOP,
}) })
onesidedLoader:load({})
local onesidedStatus = statusById(onesidedLoader)
check(onesidedStatus.picky.state == "conflict" and onesidedStatus.plain.state == "loaded",
  "the declaring mod loses a one-sided conflict")

-- ------- dependency enabled-ness, version ranges, and missing deps
local depFiles = {
  ["mods/lib/manifest.json"] = manifestJson("lib"),
  ["mods/lib/main.lua"] = NOOP,
  ["mods/user/manifest.json"] = manifestJson("user", { dependencies = '["lib"]' }),
  ["mods/user/main.lua"] = "return function(mod) mod.content.items:register('DEP_ITEM', {}) end",
  ["options.lua"] = "return { mods = { lib = false } }",
}
local depData = { items = {} }
local depLoader = Loader.new({ fs = memfs(depFiles) })
check(depLoader:load(depData) == false, "a disabled dependency fails the load")
local depStatus = statusById(depLoader)
check(depStatus.user.state == "blocked_dependency"
  and depStatus.user.error:find("dependency lib is disabled", 1, true) ~= nil,
  "a mod whose hard dependency is disabled fails with a manager-visible error")
check(depData.items.DEP_ITEM == nil, "the blocked mod never registered anything")

local rangeLoader = Loader.new({ fs = memfs({
  ["mods/lib/manifest.json"] = manifestJson("lib"),
  ["mods/lib/main.lua"] = NOOP,
  ["mods/user/manifest.json"] = manifestJson("user", { dependencies = '["lib@^2.0"]' }),
  ["mods/user/main.lua"] = NOOP,
}) })
rangeLoader:load({})
check(statusById(rangeLoader).user.error:find("needs lib@^2.0, found 1.0.0", 1, true) ~= nil,
  "a version-mismatched dependency names the range and the version found")

local missingLoader = Loader.new({ fs = memfs({
  ["mods/user/manifest.json"] = manifestJson("user", { dependencies = '["ghost"]' }),
  ["mods/user/main.lua"] = NOOP,
}) })
missingLoader:load({})
check(statusById(missingLoader).user.error:find("missing dependency: ghost", 1, true) ~= nil,
  "a missing dependency is named")

-- a mod whose dependency crashes must not run on top of a rolled-back mod
local transitiveLoader = Loader.new({ fs = memfs({
  ["mods/broken/manifest.json"] = manifestJson("broken"),
  ["mods/broken/main.lua"] = "return function(mod) error('entry blew up') end",
  ["mods/onbroken/manifest.json"] = manifestJson("onbroken", { dependencies = '["broken"]' }),
  ["mods/onbroken/main.lua"] = "return function(mod) mod.content.items:register('LATE', {}) end",
}) })
local transitiveData = { items = {} }
transitiveLoader:load(transitiveData)
local transitiveStatus = statusById(transitiveLoader)
check(transitiveStatus.broken.state == "failed", "a throwing entry chunk fails its mod")
check(transitiveStatus.onbroken.state == "blocked_dependency"
  and transitiveStatus.onbroken.error:find("failed to load", 1, true) ~= nil,
  "a dependent of a crashed mod is stopped before it runs")
check(transitiveData.items.LATE == nil, "the dependent registered nothing")

-- ------- a cycle disables only its own members
local cycleLoader = Loader.new({ fs = memfs({
  ["mods/alpha/manifest.json"] = manifestJson("alpha", { dependencies = '["beta"]' }),
  ["mods/alpha/main.lua"] = NOOP,
  ["mods/beta/manifest.json"] = manifestJson("beta", { dependencies = '["alpha"]' }),
  ["mods/beta/main.lua"] = NOOP,
  ["mods/innocent/manifest.json"] = manifestJson("innocent"),
  ["mods/innocent/main.lua"] = "return function(mod) mod.content.items:register('FINE', {}) end",
}) })
local cycleData = { items = {} }
check(cycleLoader:load(cycleData) == false, "a cycle is reported, not raised")
local cycleStatus = statusById(cycleLoader)
check(cycleStatus.alpha.state == "blocked_dependency"
  and cycleStatus.beta.state == "blocked_dependency",
  "both cycle members are disabled")
check(cycleStatus.alpha.error:find("circular dependency", 1, true) ~= nil,
  "the cycle members are told why")
check(cycleStatus.innocent.state == "loaded" and cycleData.items.FINE ~= nil,
  "a mod beside the cycle loads normally")

-- ------- inter-mod exports and find
-- probes report through mod.exports: a mod's globals are its own
-- (src/mods/Sandbox.lua)
local exportLoader = Loader.new({ fs = memfs({
  ["mods/colorlib/manifest.json"] = manifestJson("colorlib"),
  ["mods/colorlib/main.lua"] = [[
return function(mod)
  mod.exports = { tint = function(name) return "tinted:" .. name end }
end
]],
  ["mods/radio/manifest.json"] = manifestJson("radio"),
  ["mods/radio/main.lua"] = NOOP,
  ["mods/daynight/manifest.json"] = manifestJson("daynight", {
    dependencies = '["colorlib@^1.0"]', optional_dependencies = '["radio","absent_radio"]',
  }),
  ["mods/daynight/main.lua"] = [[
return function(mod)
  local results = mod.exports
  local color = mod.find("colorlib")
  results.depVersion = color.version
  results.tint = color.exports.tint("dusk")
  results.optional = mod.find("radio") ~= nil
  results.absent = mod.find("absent_radio")
  results.disabled = mod.find("shelved")
  results.method = mod:find("colorlib") ~= nil
end
]],
  ["mods/shelved/manifest.json"] = manifestJson("shelved"),
  ["mods/shelved/main.lua"] = NOOP,
  ["options.lua"] = "return { mods = { shelved = false } }",
}) })
check(exportLoader:load({}) == true, "the export fixture loads clean")
local found = exportLoader.exports.daynight
check(found.tint == "tinted:dusk", "find returns the other mod's live export table")
check(found.depVersion == "1.0.0", "the handle carries the other mod's version")
check(found.optional == true, "an enabled optional dependency is findable")
check(found.absent == nil, "find returns nil for a mod that is not installed")
check(found.disabled == nil, "find returns nil for a disabled mod")
check(found.method == true, "mod:find is tolerated alongside mod.find")
check(exportLoader.order[1] == "colorlib",
  "a hard dependency executes before its dependent")

-- ------- the rest of the v2 mod object
local objectLoader = Loader.new({ fs = memfs({
  ["mods/probe/manifest.json"] = manifestJson("probe", {
    api = "2", description = '"probing"', priority = "3",
  }),
  ["mods/probe/data.txt"] = "hello from the mod dir",
  ["mods/probe/main.lua"] = [[
return function(mod)
  local probe = mod.exports
  probe.id, probe.version, probe.path = mod.id, mod.version, mod.path
  probe.manifestApi = mod.manifest.api
  mod.manifest.api = 99
  probe.read = mod:read("data.txt")
  probe.assetPath = mod.assets:path("sprites/x.png")
  probe.aliasedRegistry = mod.assets.pokemon ~= nil
  probe.image = mod.assets:image("sprites/x.png")
  probe.imageCached = mod.assets:image("sprites/x.png") == probe.image

  mod.options:define({ { key = "dusk_hour", type = "number", default = 18 } })
  probe.optionDefault = mod.options:get("dusk_hour")
  probe.optionStored = mod.options:get("volume")
  probe.saveDefault = mod.save:get("clock_hour", 12)
  mod.save:set("clock_hour", 6)
  probe.saveRoundTrip = mod.save:get("clock_hour", 12)

  mod.commands:register("do_thing", function() return "done" end)
  mod.migrations:add("1.0.0", function() end)

  local fired = 0
  mod.events:once("mod.probe.ping", function() fired = fired + 1 end)
  mod.events:on("mod.probe.ping", function() probe.stillHeard = true end)
  mod.events:emit("mod.probe.ping", {})
  mod.events:emit("mod.probe.ping", {})
  probe.onceCount = fired
  probe.forgery = select(2, pcall(function() mod.events:emit("battle.started", {}) end))
end
]],
  ["options.lua"] = "return { modOptions = { probe = { volume = 4 } } }",
}) })
check(objectLoader:load({ pokemon = {} }) == true, "the mod object fixture loads clean")
local probe = objectLoader.exports.probe
check(probe.id == "probe" and probe.version == "1.0.0" and probe.path == "mods/probe",
  "identity fields are present")
check(probe.manifestApi == 2 and objectLoader.mods.probe.manifest.api == 2,
  "mod.manifest is a copy: writing to it cannot reach the loader")
check(probe.read == "hello from the mod dir", "mod:read still reads the mod dir")
check(probe.assetPath == "mods/probe/sprites/x.png", "assets:path builds a virtual path")
check(probe.aliasedRegistry == true, "assets keeps the v1 alias to the registries")
check(probe.image ~= nil and probe.imageCached == true,
  "assets:image loads from the mod dir and caches per path")
check(probe.optionDefault == 18, "options:get falls back to the declared default")
check(probe.optionStored == 4, "options:get prefers the stored value")
check(objectLoader.optionSchemas.probe[1].key == "dusk_hour",
  "the options schema is recorded for the manager")
check(probe.saveDefault == 12 and probe.saveRoundTrip == 6,
  "save:get honours the default and reads back what set wrote")
-- the sugar writes straight into the commands registry now that M4 has
-- declared it, so the verb is owned there rather than in the holding table
check(objectLoader.content.commands:get("do_thing") ~= nil
  and objectLoader.content.commands.owners.do_thing == "probe",
  "commands:register records the verb against its mod")
check(objectLoader.migrations.probe[1].since == "1.0.0", "migrations are recorded")
check(probe.onceCount == 1 and probe.stillHeard == true,
  "events:once fires once and does not skip the listener behind it")
check(tostring(probe.forgery):find("may only emit", 1, true) ~= nil,
  "a mod cannot emit outside its own event namespace")

-- a failing entry chunk takes its exports, commands and migrations with it
local residueLoader = Loader.new({ fs = memfs({
  ["mods/messy/manifest.json"] = manifestJson("messy"),
  ["mods/messy/main.lua"] = [[
return function(mod)
  mod.exports = { hello = true }
  mod.commands:register("messy_verb", function() end)
  mod.migrations:add("1.0.0", function() end)
  error("messy failed late")
end
]],
}) })
residueLoader:load({})
check(residueLoader.exports.messy == nil, "a failed mod publishes no exports")
check(residueLoader.content.commands:get("messy_verb") == nil,
  "a failed mod leaves no command")
check(residueLoader.migrations.messy == nil, "a failed mod leaves no migration")

-- ------- dev-mode permissions tripwire
local devFiles = {
  ["mods/nosy/manifest.json"] = manifestJson("nosy"),
  ["mods/nosy/main.lua"] = [[
return function(mod)
  pcall(require, "src.battle.BattleState")
end
]],
  ["mods/declared/manifest.json"] = manifestJson("declared", {
    api = "2", permissions = '["engine_internals"]',
  }),
  ["mods/declared/main.lua"] = [[
return function(mod)
  pcall(require, "src.battle.BattleState")
end
]],
}
local devLoader = Loader.new({ fs = memfs(devFiles), dev = true })
local historyMark = #Logger.history
check(devLoader:load({}) == true, "the dev fixture loads clean")
local sawUndeclared, sawDeclared = false, false
for index = historyMark + 1, #Logger.history do
  local line = Logger.history[index]
  if line:find("[nosy]", 1, true)
    and line:find("undeclared engine_internals require: src.battle.BattleState", 1, true) then
    sawUndeclared = true
  end
  if line:find("[declared]", 1, true) and line:find("undeclared", 1, true) then
    sawDeclared = true
  end
end
check(sawUndeclared,
  "an undeclared private engine require is attributed to the mod that made it")
check(not sawDeclared, "a mod that declared engine_internals is not warned about")
check(Runtime.currentMod == nil, "no mod frame is left open after the load")

-- engine requires outside a mod frame stay silent
local engineMark = #Logger.history
require("src.core.Data")
for index = engineMark + 1, #Logger.history do
  check(not Logger.history[index]:find("undeclared", 1, true),
    "an engine require outside a mod frame is not warned about")
end

-- ------- parity: with no mod nothing new appears and nothing is refused
local pristine = { pokemon = { A = { hp = 1 } }, items = {} }
local emptyLoader = Loader.new({ fs = memfs({}) })
check(emptyLoader:load(pristine) == true, "an empty mods dir still loads clean")
check(#emptyLoader:status().errors == 0, "no mods means no diagnostics")
check(#emptyLoader.order == 0, "no mods means an empty load order")
-- ------- dependency resolver conflict detection test
local LauncherMods = require("src.mods.LauncherMods")
local testTargetManifest = Manifest.validate({
  id = "new_mod",
  name = "New Mod",
  version = "1.0.0",
  entry = "main.lua",
  incompatible = { "colorlib" },
}, "mods/new_mod")
local installedColorlib = Manifest.validate({
  id = "colorlib",
  name = "Color Lib",
  version = "1.0.0",
  entry = "main.lua",
}, "mods/colorlib")
local unconditionalConflict = LauncherMods.checkDependencies(testTargetManifest,
  nil, nil, { testTargetManifest, installedColorlib })
check(unconditionalConflict.hasIssues == true,
  "dependency resolver reports an unversioned conflict")

local function rangeTarget(version, conflicts)
  return Manifest.validate({
    id = "range_target",
    name = "Range Target",
    version = version,
    entry = "main.lua",
    conflicts = conflicts or {},
  }, "mods/range_target")
end
local function rangeSource(conflicts)
  return Manifest.validate({
    id = "range_source",
    name = "Range Source",
    version = "1.0.0",
    entry = "main.lua",
    conflicts = conflicts or {},
  }, "mods/range_source")
end

local forwardSource = rangeSource({ "range_target@<2.0.0" })
local matchingTarget = rangeTarget("1.4.0")
local nonmatchingTarget = rangeTarget("2.0.0")
local forwardMatching = LauncherMods.checkDependencies(forwardSource,
  nil, nil, { forwardSource, matchingTarget })
check(forwardMatching.hasIssues == true and #forwardMatching.deps == 1,
  "dependency resolver applies a matching forward conflict range")
local forwardNonmatching = LauncherMods.checkDependencies(forwardSource,
  nil, nil, { forwardSource, nonmatchingTarget })
check(forwardNonmatching.hasIssues == false and #forwardNonmatching.deps == 0,
  "dependency resolver ignores a nonmatching forward conflict range")

local reverseSource = rangeSource({ "range_target@<2.0.0" })
local reverseMatching = LauncherMods.checkDependencies(matchingTarget,
  nil, nil, { reverseSource, matchingTarget })
check(reverseMatching.hasIssues == true and #reverseMatching.deps == 1,
  "dependency resolver applies a matching reverse conflict range")
local reverseNonmatching = LauncherMods.checkDependencies(nonmatchingTarget,
  nil, nil, { reverseSource, nonmatchingTarget })
check(reverseNonmatching.hasIssues == false and #reverseNonmatching.deps == 0,
  "dependency resolver ignores a nonmatching reverse conflict range")
-- ------- scoped dependency tests
local Json = require("src.link.Json")
local scopedDepManifest = Manifest.validate({
  id = "dual_gen_mod",
  name = "Dual Gen Mod",
  version = "1.0.0",
  entry = "main.lua",
  games = { "gen1", "gen2" },
  dependencies = {
    { id = "gen2_only_dep", games = { "gen2" }, version = "^1.0.0" }
  },
}, "mods/dual_gen_mod")
check(#scopedDepManifest.dependencySpecs == 1, "scoped dependency parsed")
check(scopedDepManifest.dependencySpecs[1].games ~= nil, "dependency carries games list")

local dualGenFiles = {
  ["mods/dual_gen_mod/manifest.json"] = Json.encode({
    id = "dual_gen_mod",
    name = "Dual Gen Mod",
    version = "1.0.0",
    entry = "main.lua",
    games = { "gen1", "gen2" },
    dependencies = {
      { id = "gen2_only_dep", games = { "gen2" } }
    },
  }),
  ["mods/dual_gen_mod/main.lua"] = [[
return function(mod)
  mod.content.pokemon:register("DUAL_MON", { hp = 100 })
end
]],
}
local gen1Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 1 })
check(gen1Loader:load({}) == true, "dual gen mod loads on Gen 1 when Gen 2 dep is absent")
check(gen1Loader.content.pokemon:get("DUAL_MON") ~= nil, "dual gen mod executed on Gen 1")

local gen2Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 2 })
check(gen2Loader:load({}) == false, "loader returns false on Gen 2 when missing required Gen 2 dep")
check(gen2Loader.content.pokemon:get("DUAL_MON") == nil, "dual gen mod is blocked on Gen 2 when missing required Gen 2 dep")
check(#gen2Loader:status().errors > 0, "missing dependency error logged on Gen 2")

Runtime.install(savedEvents, savedHooks)

S.finish()
