-- libccpm: the testable core of the TrapOS package manager (ccpm).
--
-- A factory: `local createCcpm = require('/apis/libccpm'); local ccpm = createCcpm();`
--
-- State lives under `opts.stateDir` (default `/trapos`):
--   - ccpm.json      -> { registries = { { name, type, branch }, ... } }
--   - ccpm.lock.json -> { packages = { <name> = { version, registry, files,
--                                                  dependencies, autostart,
--                                                  explicit } } }
--   - ccpm.cache.json -> { packages = { <name> = { version, registry } } }
--
-- Files are written under `opts.installRoot` (default '' -> filesystem root), so
-- tests can isolate downloads. `opts.http` overrides the global `http` for tests.

local DEFAULT_STATE_DIR = '/trapos';

local function normalizePath(path)
  path = path:gsub('//+', '/');
  if path:sub(1, 1) ~= '/' then
    path = '/' .. path;
  end
  return path;
end

local function parentDir(path)
  return path:match('^(.*)/[^/]+$');
end

local function createCcpm(opts)
  opts = opts or {};
  local httpLib = opts.http or http;
  local stateDir = opts.stateDir or DEFAULT_STATE_DIR;
  local installRoot = opts.installRoot or '';

  local configPath = stateDir .. '/ccpm.json';
  local lockPath = stateDir .. '/ccpm.lock.json';
  local cachePath = stateDir .. '/ccpm.cache.json';
  local manifestPath = stateDir .. '/manifest.json';

  local api = {};

  -- ---------- JSON file helpers ----------

  local function readJsonFile(path)
    if not fs.exists(path) then return nil; end
    local f = fs.open(path, 'r');
    if not f then return nil; end
    local data = f.readAll();
    f.close();
    if not data or data == '' then return nil; end
    return textutils.unserializeJSON(data);
  end

  local function writeJsonFile(path, value)
    local dir = parentDir(path);
    if dir then fs.makeDir(dir); end
    local f = fs.open(path, 'w');
    if not f then return false; end
    f.write(textutils.serializeJSON(value));
    f.close();
    return true;
  end

  -- ---------- config (registries) ----------

  function api.readConfig()
    return readJsonFile(configPath) or { registries = {} };
  end

  function api.writeConfig(cfg)
    return writeJsonFile(configPath, cfg);
  end

  function api.listRegistries()
    return api.readConfig().registries or {};
  end

  function api.addRegistry(name, registryOpts)
    registryOpts = registryOpts or {};
    local cfg = api.readConfig();
    cfg.registries = cfg.registries or {};
    for _, r in ipairs(cfg.registries) do
      if r.name == name then
        return false, 'registry already exists: ' .. name;
      end
    end
    local registry = {
      name = name,
      type = registryOpts.type or 'gitea',
      branch = registryOpts.branch or 'master',
    };
    cfg.registries[#cfg.registries + 1] = registry;
    api.writeConfig(cfg);
    return true, registry;
  end

  function api.removeRegistry(name)
    local cfg = api.readConfig();
    cfg.registries = cfg.registries or {};
    local kept = {};
    local found = false;
    for _, r in ipairs(cfg.registries) do
      if r.name == name then
        found = true;
      else
        kept[#kept + 1] = r;
      end
    end
    if not found then
      return false, 'registry not found: ' .. name;
    end
    cfg.registries = kept;
    api.writeConfig(cfg);
    return true;
  end

  -- ---------- lock (installed packages) ----------

  function api.readLock()
    return readJsonFile(lockPath) or { packages = {} };
  end

  function api.writeLock(lock)
    return writeJsonFile(lockPath, lock);
  end

  function api.list()
    return api.readLock().packages or {};
  end

  local function dependentsOf(lock, pkg)
    local dependents = {};
    for name, entry in pairs(lock.packages or {}) do
      if name ~= pkg then
        for _, dep in ipairs(entry.dependencies or {}) do
          if dep == pkg then dependents[#dependents + 1] = name; end
        end
      end
    end
    table.sort(dependents);
    return dependents;
  end

  local function copyLockWithout(lock, pkg)
    local packages = {};
    for name, entry in pairs(lock.packages or {}) do
      if name ~= pkg then
        packages[name] = entry;
      end
    end
    return { packages = packages };
  end

  local function markReachable(lock, name, reachable)
    if reachable[name] then return; end
    local entry = lock.packages and lock.packages[name];
    if not entry then return; end
    reachable[name] = true;
    for _, dep in ipairs(entry.dependencies or {}) do
      markReachable(lock, dep, reachable);
    end
  end

  local function pruneCandidates(lock)
    local reachable = {};
    for name, entry in pairs(lock.packages or {}) do
      -- Missing `explicit` is legacy state and remains a root.
      if entry.explicit ~= false then
        markReachable(lock, name, reachable);
      end
    end

    local names = {};
    for name in pairs(lock.packages or {}) do
      if not reachable[name] then
        names[#names + 1] = name;
      end
    end
    table.sort(names);
    return names;
  end

  local function writeOsState(lock)
    local files = {};
    local seenFile = {};
    local autostart = {};
    local seenAuto = {};
    for _, entry in pairs(lock.packages or {}) do
      for _, filePath in ipairs(entry.files or {}) do
        if not seenFile[filePath] then
          seenFile[filePath] = true;
          files[#files + 1] = filePath;
        end
      end
      for _, server in ipairs(entry.autostart or {}) do
        if not seenAuto[server] then
          seenAuto[server] = true;
          autostart[#autostart + 1] = server;
        end
      end
    end
    table.sort(files);
    table.sort(autostart);
    local cfg = api.readConfig();
    local registry = cfg.registries and cfg.registries[1] or {};
    local trapos = lock.packages and lock.packages.trapos;
    return writeJsonFile(manifestPath, {
      name = 'TrapOS',
      version = trapos and trapos.version or '?',
      branch = registry.branch or 'master',
      files = files,
      autostart = autostart,
    });
  end

  -- ---------- cache (available packages) ----------

  function api.readCache()
    return readJsonFile(cachePath) or { packages = {} };
  end

  function api.writeCache(cache)
    return writeJsonFile(cachePath, cache);
  end

  -- ---------- URL resolution ----------

  function api.registryBaseUrl(registry)
    if registry.type == 'gitea' then
      local branch = registry.branch or 'master';
      return 'https://git.trapcloud.fr/' .. registry.name .. '/raw/branch/' .. branch .. '/';
    end
    local base = registry.name;
    if base:sub(-1) ~= '/' then base = base .. '/'; end
    return base;
  end

  function api.descriptorUrl(registry, pkg)
    return api.registryBaseUrl(registry) .. 'packages/' .. pkg .. '/ccpm.json';
  end

  function api.indexUrl(registry)
    return api.registryBaseUrl(registry) .. 'packages/index.json';
  end

  -- Sources live under packages/<pkg>/ in the repo but install flat (the
  -- `filePath` from ccpm.json is the install destination), so the fetch URL
  -- gains the package prefix while the install target stays flat.
  function api.fileUrl(registry, pkg, filePath)
    return api.registryBaseUrl(registry) .. 'packages/' .. pkg .. '/' .. filePath;
  end

  -- ---------- HTTP ----------

  local function httpGetBody(url)
    local res = httpLib.get(url);
    if not res then return nil; end
    local body = res.readAll();
    res.close();
    return body;
  end

  function api.fetchJson(url)
    local body = httpGetBody(url);
    if not body or body == '' then return nil; end
    return textutils.unserializeJSON(body);
  end

  -- Find a package descriptor across the configured registries.
  -- Returns descriptor, registry (or nil when not found).
  function api.findPackage(pkg)
    local cfg = api.readConfig();
    for _, registry in ipairs(cfg.registries or {}) do
      local desc = api.fetchJson(api.descriptorUrl(registry, pkg));
      if desc then return desc, registry; end
    end
    return nil;
  end

  function api.info(pkg)
    return api.findPackage(pkg);
  end

  function api.search(term)
    local results = {};
    local cache = api.readCache();
    for name, entry in pairs(cache.packages or {}) do
      if not term or term == '' or string.find(name, term, 1, true) then
        results[#results + 1] = { name = name, version = entry.version, registry = entry.registry };
      end
    end
    table.sort(results, function(a, b) return a.name < b.name; end);
    return results;
  end

  function api.update()
    local cfg = api.readConfig();
    local cache = { packages = {} };
    for _, registry in ipairs(cfg.registries or {}) do
      local index = api.fetchJson(api.indexUrl(registry));
      if index and index.packages then
        for name, version in pairs(index.packages) do
          if not cache.packages[name] then
            cache.packages[name] = { version = version, registry = registry.name };
          end
        end
      end
    end
    api.writeCache(cache);
    return cache;
  end

  local function compareVersions(a, b)
    a = tostring(a or '');
    b = tostring(b or '');
    if a == b then return 0; end
    local aparts = {};
    local bparts = {};
    for part in string.gmatch(a, '[^%.]+') do aparts[#aparts + 1] = part; end
    for part in string.gmatch(b, '[^%.]+') do bparts[#bparts + 1] = part; end
    local max = math.max(#aparts, #bparts);
    local hitBreak = false;
    for i = 1, max do
      local apart = aparts[i] or '0';
      local bpart = bparts[i] or '0';
      local anum = tonumber(apart);
      local bnum = tonumber(bpart);
      if not anum or not bnum then hitBreak = true; break; end
      if anum < bnum then return -1; end
      if anum > bnum then return 1; end
    end
    if not hitBreak then return 0; end
    if a < b then return -1; end
    return 1;
  end

  function api.available(term)
    local cache = api.readCache();
    local installed = api.list();
    local results = {};
    for name, entry in pairs(cache.packages or {}) do
      if not term or term == '' or string.find(name, term, 1, true) then
        local status = 'available';
        local installedVersion = nil;
        if installed[name] then
          installedVersion = installed[name].version;
          if compareVersions(installedVersion, entry.version) < 0 then
            status = 'updatable';
          else
            status = 'up-to-date';
          end
        end
        results[#results + 1] = {
          name = name,
          version = entry.version,
          installedVersion = installedVersion,
          registry = entry.registry,
          status = status,
        };
      end
    end
    table.sort(results, function(a, b) return a.name < b.name; end);
    return results;
  end

  -- ---------- dependency resolution ----------

  -- Resolve `pkg` and all of its dependencies into install order (deps first,
  -- target last). Returns an ordered list of { name, desc, registry } or nil, err.
  function api.resolve(pkg)
    local ordered = {};
    local state = {}; -- name -> 'visiting' | 'done'
    local errMsg = nil;

    local function visit(name, chain)
      if errMsg then return; end
      if state[name] == 'done' then return; end
      if state[name] == 'visiting' then
        errMsg = 'dependency cycle detected: ' .. table.concat(chain, ' -> ') .. ' -> ' .. name;
        return;
      end
      state[name] = 'visiting';
      local desc, registry = api.findPackage(name);
      if not desc then
        errMsg = 'package not found: ' .. name;
        return;
      end
      chain[#chain + 1] = name;
      for _, dep in ipairs(desc.dependencies or {}) do
        visit(dep, chain);
        if errMsg then return; end
      end
      chain[#chain] = nil;
      state[name] = 'done';
      ordered[#ordered + 1] = { name = name, desc = desc, registry = registry };
    end

    visit(pkg, {});
    if errMsg then return nil, errMsg; end
    return ordered;
  end

  -- ---------- install / uninstall ----------

  local function installTarget(filePath)
    return normalizePath(installRoot .. '/' .. filePath);
  end

  local function cleanupEmptyGeneratedDirs()
    for _, dir in ipairs({ 'programs', 'apis', 'daemons', 'startup' }) do
      local target = installTarget(dir);
      if target ~= '/' and fs.exists(target) and fs.isDir(target) and #fs.list(target) == 0 then
        fs.delete(target);
      end
    end
  end

  local function cleanupStateIfEmpty(lock)
    if next(lock.packages or {}) then return; end
    if normalizePath(stateDir) ~= '/' then
      fs.delete(stateDir);
    end
    cleanupEmptyGeneratedDirs();
  end

  function api.downloadFile(registry, pkg, filePath)
    local body = httpGetBody(api.fileUrl(registry, pkg, filePath));
    if not body then
      return false, 'failed to download ' .. filePath;
    end
    local target = installTarget(filePath);
    local dir = parentDir(target);
    if dir then fs.makeDir(dir); end
    fs.delete(target);
    local f = fs.open(target, 'w');
    if not f then
      return false, 'failed to write ' .. target;
    end
    f.write(body);
    f.close();
    return true;
  end

  -- installOpts: { force = bool, log = function(msg) }
  function api.install(pkg, installOpts)
    installOpts = installOpts or {};
    local log = installOpts.log or function() end;
    local lock = api.readLock();
    lock.packages = lock.packages or {};

    if lock.packages[pkg] and not installOpts.force then
      return false, "package already installed, use 'ccpm reinstall " .. pkg .. "' instead.";
    end

    local ordered, err = api.resolve(pkg);
    if not ordered then return false, err; end

    for _, item in ipairs(ordered) do
      local isTarget = item.name == pkg;
      local alreadyInstalled = lock.packages[item.name] ~= nil;
      if alreadyInstalled and not (isTarget and installOpts.force) then
        log('skip ' .. item.name .. ' (already installed)');
      else
        log('install ' .. item.name .. ' v' .. tostring(item.desc.version or '?'));
        for _, filePath in ipairs(item.desc.files or {}) do
          log('  ' .. filePath);
          local ok, derr = api.downloadFile(item.registry, item.name, filePath);
          if not ok then return false, derr; end
        end
        local previous = lock.packages[item.name];
        lock.packages[item.name] = {
          version = item.desc.version,
          registry = item.registry.name,
          files = item.desc.files or {},
          dependencies = item.desc.dependencies or {},
          autostart = item.desc.autostart or {},
          explicit = isTarget or (previous and previous.explicit == true) or false,
        };
      end
    end

    api.writeLock(lock);
    writeOsState(lock);
    return true, lock.packages[pkg];
  end

  function api.upgrade(upgradeOpts)
    upgradeOpts = upgradeOpts or {};
    local log = upgradeOpts.log or function() end;
    local lock = api.readLock();
    local cache = api.readCache();
    if not cache.packages or not next(cache.packages) then
      return false, "package cache is empty, run 'ccpm update' first.";
    end

    local names = {};
    for name, entry in pairs(lock.packages or {}) do
      local cached = cache.packages[name];
      if cached and compareVersions(entry.version, cached.version) < 0 then
        names[#names + 1] = name;
      end
    end
    table.sort(names);

    for _, name in ipairs(names) do
      log('upgrade ' .. name);
      local ok, err = api.install(name, { force = true, log = log });
      if not ok then return false, err; end
    end
    return true, names;
  end

  -- pruneOpts: { dryRun = bool, log = function(msg) }
  function api.prune(pruneOpts)
    pruneOpts = pruneOpts or {};
    local log = pruneOpts.log or function() end;
    local lock = api.readLock();
    lock.packages = lock.packages or {};
    local names = pruneCandidates(lock);

    if pruneOpts.dryRun then
      return names;
    end

    if #names == 0 then
      cleanupStateIfEmpty(lock);
      return names;
    end

    for _, name in ipairs(names) do
      local entry = lock.packages[name];
      if entry then
        log('prune ' .. name);
        for _, filePath in ipairs(entry.files or {}) do
          log('remove ' .. filePath);
          fs.delete(installTarget(filePath));
        end
      end
    end
    for _, name in ipairs(names) do
      lock.packages[name] = nil;
    end
    api.writeLock(lock);
    writeOsState(lock);
    cleanupStateIfEmpty(lock);
    return names;
  end

  -- uninstallOpts: { force = bool, prune = bool }
  function api.planUninstall(pkg, uninstallOpts)
    uninstallOpts = uninstallOpts or {};
    local lock = api.readLock();
    lock.packages = lock.packages or {};

    if not lock.packages[pkg] then
      return false, 'package not installed: ' .. pkg;
    end

    local dependents = dependentsOf(lock, pkg);
    if #dependents > 0 and not uninstallOpts.force then
      return false, 'cannot uninstall ' .. pkg .. ': required by ' .. table.concat(dependents, ', ');
    end

    local removed = { pkg };
    if uninstallOpts.prune ~= false then
      local previewLock = copyLockWithout(lock, pkg);
      for _, name in ipairs(pruneCandidates(previewLock)) do
        removed[#removed + 1] = name;
      end
    end
    return true, removed;
  end

  -- uninstallOpts: { force = bool, prune = bool, log = function(msg) }
  function api.uninstall(pkg, uninstallOpts)
    uninstallOpts = uninstallOpts or {};
    local log = uninstallOpts.log or function() end;
    local lock = api.readLock();
    lock.packages = lock.packages or {};

    local entry = lock.packages[pkg];
    if not entry then
      return false, 'package not installed: ' .. pkg;
    end

    local dependents = dependentsOf(lock, pkg);
    if #dependents > 0 and not uninstallOpts.force then
      return false, 'cannot uninstall ' .. pkg .. ': required by ' .. table.concat(dependents, ', ');
    end

    for _, filePath in ipairs(entry.files or {}) do
      log('remove ' .. filePath);
      fs.delete(installTarget(filePath));
    end
    lock.packages[pkg] = nil;
    api.writeLock(lock);
    writeOsState(lock);

    local removed = { pkg };
    if uninstallOpts.prune ~= false then
      for _, name in ipairs(api.prune({ log = log })) do
        removed[#removed + 1] = name;
      end
    else
      cleanupStateIfEmpty(lock);
    end
    return true, removed;
  end

  return api;
end

return createCcpm;
