bundle   = "osglecture"
ctanpkg  = bundle
maindir  = maindir or "."

local is_module = type(module) == "string" and module ~= ""

-- Only the bundle root lists its modules.  l3build's ctan target in a module
-- that belongs to a bundle calls bundlecheck/bundlectan for every entry of
-- `modules`; inherited from here, a module would run those targets for the
-- whole bundle list from inside its own directory (the "cd <module>" fails,
-- the command continues in the module) and rebuild itself over and over.
if not is_module then
  modules = {
     "ansiterm",
     "osglistings",
     "ollm",
     "osgdoc",
     "langselect",
     "tagbridge",
     "tagdirtree",
     "lttheme",
     "lttheme-tuc-2019",
     "osgstyler",
     "osglecture-modes",
     "osglecture",
     "tagpax",
     "semcat",
     "manual-src",
   }
end

-- CTAN submission data for "l3build upload", kept in its own file.  The
-- CTAN copy of the bundle has no ctan/ directory; it is only built there.
if not is_module and fileexists("ctan/upload.lua") then
  dofile("ctan/upload.lua")
end

-- A module is only ever packaged as part of the bundle; say so instead of
-- failing on the missing module list.
if is_module and target_list and target_list.ctan then
  target_list.ctan.func = function()
    print("'" .. module .. "' is packaged as part of the " .. bundle
      .. " bundle: run 'l3build ctan' in the bundle root.")
    return 1
  end
end

-- The same holds for the upload.  l3build's upload target has no bundle
-- function, so in the bundle root it would run in every module and look
-- for the zip file there; run it in the root itself instead.
if target_list and target_list.upload then
  if is_module then
    target_list.upload.func = function()
      print("'" .. module .. "' is uploaded as part of the " .. bundle
        .. " bundle: run 'l3build upload' in the bundle root.")
      return 1
    end
  else
    target_list.upload.bundle_func = function(names) return upload(names) end
  end
end

-- The GitHub README.md is not distributed: CTAN gets the snapshot
-- ctan/README.md derived from it (tools/make-ctan-readme.lua).  Modules
-- distribute their README-<module>.md, never the README.md link to it.
-- In the CTAN copy the snapshot already is the top-level README.md.
if not is_module then
  textfiledir = fileexists("ctan/README.md") and "ctan" or "."
  textfiles = { "README.md" }
end

-- The package directory mirrors the repository, so that the l3build files
-- work there as well: build.lua and config-*.lua must therefore not be
-- excluded.  CTAN prefers uploads without a TDS archive; l3build still
-- builds the TDS tree (see copyctan below), and setting packtdszip adds
-- osglecture.tds.zip to the upload again should it ever be wanted.
if not is_module then
  packtdszip = false
  excludefiles = { "*~" }
  -- l3build drops the executable bit unless a file is listed here.
  exefiles = { "ollm" }
end

--[[
  The CTAN package directory mirrors the repository rather than l3build's
  flat collection: each module gets its own directory with its tracked
  files (sources, l3build files, tests, README-<module>.md) and its
  documentation PDFs, so "l3build check/doc/install" also work there.
  The repository-only README.md links are git symlinks and stay behind;
  so do development-docs/, doc/, tools/, ctan/, and images/ apart from
  images/logos (the other images serve osglecture projects, not the
  bundle), which no module copies.
  The top-level build.lua and LICENSE, the shared test support files, the
  logos used by the lttheme-tuc-2019 tests and example, and the examples
  come along with every module (the copies are identical).
  Hidden files are left out: CTAN does not accept them.  So are the
  PDF-based visual tests (*.pvt, *.tpf): their normalised reference PDFs
  are rejected by CTAN's checks, and CTAN asked to leave them out.  They
  remain part of the repository and its CI.

  If a TDS archive is uploaded, CTAN requires it to hold every file of the
  package tree.  Documentation (PDFs, README files, LICENSE, examples)
  goes to doc/<tdsroot>/<bundle>, everything else to
  source/<tdsroot>/<bundle>, which therefore mirrors the package tree.
--]]
local ctan_excluded = { "%.pvt$", "%.tpf$" }

local function git_tracked_files(dir)
  local handle = io.popen("git -C " .. dir .. " ls-files -s -- . 2>" .. os_null)
  local files = { }
  for line in handle:lines() do
    local mode, path = line:match("^(%d+) %x+ %d+\t(.+)$")
    if mode and mode ~= "120000" then
      local excluded = false
      for _, pattern in ipairs(ctan_excluded) do
        excluded = excluded or path:match(pattern) ~= nil
      end
      if path:match("^%.[^/]") or path:match("/%.[^/]") then
        print("copyctan: hidden file " .. dir .. "/" .. path
          .. " is not distributed")
      elseif not excluded then
        table.insert(files, path)
      end
    end
  end
  handle:close()
  return files
end

local function copy_tracked(sourcedir, ...)
  local files = git_tracked_files(sourcedir)
  if #files == 0 then
    print("copyctan: no tracked files in " .. sourcedir)
    return 1
  end
  for _, targetdir in ipairs({ ... }) do
    for _, path in ipairs(files) do
      local dir, name = splitpath(path)
      mkdir(targetdir .. "/" .. dir)
      local errorlevel = cp(name, sourcedir .. "/" .. dir, targetdir .. "/" .. dir)
      if errorlevel ~= 0 then
        return errorlevel
      end
    end
  end
  return 0
end

if is_module then
  function copyctan()
    local pkgdir = ctandir .. "/" .. ctanpkg
    local tdsdocdir = tdsdir .. "/doc/" .. tdsroot .. "/" .. bundle
    local tdssourcedir = tdsdir .. "/source/" .. tdsroot .. "/" .. bundle
    local dirname = lfs.currentdir():match("([^/\\]+)$")
    local targetdir = pkgdir .. "/" .. dirname
    mkdir(targetdir)
    -- The mirror replaces l3build's own, flattened source directory.
    rmdir(tdsdir .. "/source/" .. moduledir)
    local errorlevel = copy_tracked(".", targetdir, tdssourcedir .. "/" .. dirname)
      + copy_tracked(maindir .. "/support",
          pkgdir .. "/support", tdssourcedir .. "/support")
      + copy_tracked(maindir .. "/images/logos",
          pkgdir .. "/images/logos", tdssourcedir .. "/images/logos")
      + copy_tracked(maindir .. "/examples",
          pkgdir .. "/examples", tdsdocdir .. "/examples")
      + cp("build.lua", maindir, pkgdir)
      + cp("build.lua", maindir, tdssourcedir)
      + cp("LICENSE", maindir, pkgdir)
      + cp("LICENSE", maindir, tdsdocdir)
    for _, files in ipairs({ docfiles, pdffiles, typesetlist }) do
      for _, pattern in ipairs(files or { }) do
        if pattern:match("%.pdf$") then
          cp(pattern, docfiledir, targetdir)
        end
      end
    end
    return errorlevel
  end

  -- l3build looks for the text files (README-<module>.md, THIRD_PARTY-*.md)
  -- in docfiledir when installing, where they never are; install them from
  -- the module directory instead.
  local std_install_files = install_files
  function install_files(target, full, dry_run)
    local errorlevel = std_install_files(target, full, dry_run)
    if errorlevel ~= 0 or not full or dry_run then
      return errorlevel
    end
    local docdir = target .. "/doc/" .. moduledir
    mkdir(docdir)
    for _, pattern in ipairs(textfiles) do
      for _, name in ipairs(filelist(textfiledir, pattern)) do
        errorlevel = errorlevel + cp(name, textfiledir, docdir)
      end
    end
    return errorlevel
  end
end

textfiles = textfiles or {
  "README-*.md"
 }

unpackfiles = unpackfiles  or { "*.dtx" }

stdengine    = "luatex"
checkengines = { "luatex" }

-- Dokumentation
docfiledir = docfiledir or maindir.."/doc/"
typesetexe = "lualatex"
typesetopts = "-interaction=nonstopmode -shell-escape --synctex=10"
maxruns    = 3


sourcefiles = sourcefiles or { "*.dtx" }
typesetfiles = typesetfiles or (
  is_module
  and { module .. ".dtx" }
  or { "*.dtx" }
)

cleanfiles={
    "*-cnltx*", -- artefacts from cnltx tools
    "*.toc",
    "*.aux",
    "*.log",
    "*.idx",
    "*.ilg",
    "*.ind"
}
--[[ We need the documentation pdf's only at time of distribution,
     but not during build, where they pollute the build/doc directory.
--]]
local target = options["target"]
local with_distributed_docs =
  target == "ctan"
  or target == "bundlectan"
  or target == "install"

if is_module then
  docfiles = docfiles or { }
  if with_distributed_docs then
    table.insert(docfiles, module .. "-en.pdf")
    table.insert(docfiles, module .. "-de.pdf")
  end
else
  docfiles = docfiles or (
    with_distributed_docs
    and { "*.pdf" }
    or {}
  )
end

-- A full installation from the bundle root installs every module with its
-- documentation in doc/<tdsroot>/<bundle>/<module> (PDFs, README and
-- THIRD_PARTY files), as in the TDS tree of the CTAN package; the root then
-- adds README.md, LICENSE, and the examples to doc/<tdsroot>/<bundle>.
-- The relative links between these files depend on that layout.
local function texmfhome_dir()
  local texmfhome = options["texmfhome"]
  if not texmfhome then
    local handle = io.popen("kpsewhich -var-value TEXMFHOME")
    texmfhome = handle and handle:read("*l")
    if handle then handle:close() end
  end
  return texmfhome
end

-- l3build installs ollm below TEXMFHOME/scripts, which is never on PATH.
-- A distribution creates the launcher itself; an installation from the
-- repository has to be told how.
local function ollm_path_hint()
  if options["dry-run"] then
    return
  end
  local windows = os_type == "windows"
  local probe = windows and "where ollm >NUL 2>NUL" or "command -v ollm >/dev/null 2>&1"
  if os.execute(probe) == 0 then
    return
  end
  local script_dir = (texmfhome_dir() or "<TEXMFHOME>") .. "/scripts/" .. bundle
  print("")
  print("Note: 'ollm' is installed in " .. script_dir)
  print("      but is not on your PATH.  To call it as 'ollm', either")
  if windows then
    print("        - add that directory to PATH (it contains ollm.cmd), or")
    print("        - call it as: perl \"" .. script_dir .. "/ollm\"")
  else
    print("        - link it into a directory on PATH, e.g.")
    print("            ln -s \"" .. script_dir .. "/ollm\" ~/.local/bin/ollm")
    print("        - or add that directory to PATH.")
  end
end

-- README.md, LICENSE, and the examples (without hidden files such as an
-- activated .ollmconfig.local.toml).
local function install_bundle_docs()
  local texmfhome = texmfhome_dir()
  if not texmfhome then
    print("Cannot determine TEXMFHOME for the bundle documentation")
    return 1
  end
  local docdir = texmfhome .. "/doc/" .. tdsroot .. "/" .. bundle
  local files = { { "README.md", textfiledir }, { "LICENSE", "." } }
  for _, p in ipairs(tree("examples", "**")) do
    local path = p.src:gsub("^%./", "")
    if lfs.attributes(p.cwd, "mode") == "file"
      and not path:match("^%.") and not path:match("/%.") then
      local dir, name = splitpath(path)
      table.insert(files, { name, "examples/" .. dir, "examples/" .. dir })
    end
  end
  local errorlevel = 0
  for _, file in ipairs(files) do
    local name, sourcedir, targetdir = file[1], file[2], file[3] or "."
    if options["dry-run"] then
      print("- doc/" .. tdsroot .. "/" .. bundle .. "/" .. targetdir .. "/" .. name)
    else
      mkdir(docdir .. "/" .. targetdir)
      errorlevel = errorlevel + cp(name, sourcedir, docdir .. "/" .. targetdir)
    end
  end
  return errorlevel
end

if not is_module and target_list and target_list.install then
  target_list.install.bundle_func = function(names)
    if names then
      print("Bundle installation does not accept file names")
      return 1
    end
    local errorlevel = call(modules, "install", options)
    if errorlevel == 0 and options["full"] then
      errorlevel = install_bundle_docs()
    end
    if errorlevel == 0 then
      ollm_path_hint()
    end
    return errorlevel
  end
end

-- All documented sources use osgdoc for their driver and langselect for the
-- German/English variants.  Declare those as typesetting dependencies so a
-- documentation build also works with an empty build/local tree.  l3build
-- installs dependencies with its unpack target, which deliberately breaks the
-- apparent documentation cycle between osgdoc and langselect.
if is_module then
  typesetdeps = typesetdeps or { }

  local function add_typeset_dependency(dependency)
    for _, configured_dependency in ipairs(typesetdeps) do
      if configured_dependency == dependency then
        return
      end
    end
    table.insert(typesetdeps, dependency)
  end

  if module ~= "osgdoc" then
    add_typeset_dependency("../osgdoc")
  end
  if module ~= "langselect" then
    add_typeset_dependency("../langselect")
  end
end

-- It is (mainly) a luatex package
tdsroot = "luatex"

--[[ 
The documentation is in two languages, English and German.
I.e., each .dtx file has to be compiled twice.
We use langselect and get the target language from jobname.
Thus, we need a special typeset function.  Other bilingual sources (the
manuals of ollm and of the bundle) are listed in 'bilingualfiles' and get
<name>-en and <name>-de as jobnames in the same way.
--]]
bilingualfiles = bilingualfiles or { }

function typeset(file, dir, cmd)
   dir = dir or "."
   local jobnames
   local ext = file:match("%.([^.]*)$")
   local stem = file:match("([^/]+)%.[^.]*$")
   local bilingual = false
   for _, name in ipairs(bilingualfiles) do
      if name == file:match("([^/]+)$") then
         bilingual = true
      end
   end
   print(" typeset called")
   if ext == 'dtx' then 
      jobnames = {module.."-en", module.."-de"}
   elseif bilingual then
      jobnames = {stem.."-en", stem.."-de"}
   else
      jobnames = {stem}
   end
   for _, job in ipairs(jobnames) do
      local errorlevel
      
      for i = 1, typesetruns do
	 errorlevel = tex(file, dir, cmd .. " -jobname=" .. job)
	 if errorlevel ~= 0 then return errorlevel end
	 
	 if i == 1 then
	    makeindex(job, dir, ".idx", ".ind", ".ilg", indexstyle)
	 end
	 
	 if i > 1 and not rerun_needed(job, dir) then
	    break
	 end
      end
      --[[ Actually, doc() is responsible to save the results.
	         However, it can't cope with changed file stems.
      --]]
      cp(job..".pdf", typesetdir, docfiledir)
   end
   
  return 0
end

function rerun_needed(job, dir)
  local log = io.open(dir .. "/" .. job .. ".log", "r")
  if not log then return false end
  local s = log:read("*all")
  log:close()

  return
    s:find("Rerun to get cross%-references right") or
    s:find("Label%(s%) may have changed") or
    s:find("There were undefined references") or
    s:find("Rerun LaTeX")
end

--[[
  I want a two-level clean:
  - 'l3build clean' keeps the final build files (pdf)
  - 'l3build cleanall' cleans everything
--]]
stdclean = target_list.clean.func

function cleanlite()
  for _, pattern in ipairs(cleanfiles or {}) do
    rm(typesetdir, pattern)
  end
  return 0
end

target_list.clean.func = cleanlite

target_list.cleanall = {
  desc = "Cleans all generated files",
  func = stdclean,
}

-- Tagging
--[[
  Versions belong to the modules, the bundle as a whole is identified by its
  release date only:
  - 'l3build tag vX.Y.Z' in a module directory sets that module's version
    and date,
  - 'l3build tag' in the bundle root sets the bundle release date in
    README.md and leaves the modules alone,
  - 'l3build gittag' in the bundle root derives the git tags
    ('<module>/vX.Y.Z', '<date>') from the committed sources.
--]]
tagfiles = tagfiles or (is_module and { "*.dtx", "*.lua" } or { "README.md" })

local release_date_pattern =
  "(<!%-%- BUNDLE RELEASE DATE %-%->)(.-)(<!%-%- /BUNDLE RELEASE DATE %-%->)"

local function is_iso_date(value)
  return type(value) == "string"
    and value:match("^%d%d%d%d%-%d%d%-%d%d$") ~= nil
end

function normalize_tagname(tagname)
  if not tagname:match("^v") then
    tagname = "v" .. tagname
  end
  return tagname
end

if is_module then
  local stdtagpre = target_list.tag.pre

  target_list.tag.pre = function(names)
    if not names or #names == 0 then
      print("A module needs an explicit version: l3build tag vX.Y.Z")
      return 1
    end
    return stdtagpre and stdtagpre(names) or 0
  end
else
  target_list.tag.bundle_func = function(names)
    local tagdate = options["date"]
    for _, name in ipairs(names or { }) do
      local name_as_date = name:gsub("/", "-")
      if not tagdate and is_iso_date(name_as_date) then
        tagdate = name_as_date
      else
        print("Warning: ignoring '" .. name .. "': the bundle is tagged by "
          .. "date only; module versions are set in the module directories")
      end
    end

    tagdate = (tagdate or os.date("%Y-%m-%d")):gsub("/", "-")
    if not is_iso_date(tagdate) then
      print("Invalid release date '" .. tagdate .. "', expected YYYY-MM-DD")
      return 1
    end

    print("Setting bundle release date to " .. tagdate)
    options["date"] = tagdate
    return tag(nil)
  end
end

local function update_lua_tag(content, tagname, tagdate)
  local updated = content:gsub(
    "(Date:%s*\n%s*)%d%d%d%d%-%d%d%-%d%d",
    "%1" .. tagdate
  )
  updated = updated:gsub(
    "(Version:%s*\n%s*)v?[%w%.%-]+",
    "%1" .. tagname
  )
  return updated
end

function update_tag(file, content, tagname, tagdate)
  --[[
    l3build passes --date through without validation or normalisation.
    We accept both common input forms and use ISO 8601 for every target.
  ]]
  local iso_date = tagdate:gsub("/", "-")

  if not is_module then
    if file ~= "README.md" then
      return content
    end
    local updated, count = content:gsub(
      release_date_pattern,
      "%1" .. iso_date .. "%3",
      1
    )
    if count == 0 then
      print("No BUNDLE RELEASE DATE marker pair found in " .. file)
    end
    return updated
  end

  tagname = normalize_tagname(tagname)

  if file:match("%.lua$") then
    return update_lua_tag(content, tagname, iso_date)
  end

  if file:match("%.dtx$") then
    local updated = content:gsub(
      "(\\ProvidesExpl%a*%s*{[^}]+}%s*\n?%s*{)"
        .. "%d%d%d%d[/-]%d%d[/-]%d%d"
        .. "(}%s*%s*{)[^}%s]+(})",
      "%1" .. iso_date .. "%2" .. tagname .. "%3"
    )

--[[
  Lua has no \Provides... declaration. Restrict its independent metadata
  update to the docstrip guard so that no other embedded file becomes a
  second source for the package version.
  NOTE: Has to be adapted in case of several lua files.
]]
    updated = updated:gsub(
      "(%%<%*lua>\n)(.-)(\n%%</lua>)",
      function(opening, lua, closing)
        return opening .. update_lua_tag(lua, tagname, iso_date) .. closing
      end,
      1
    )
    return updated
  end

  return content
end

-- The module table in README.md mirrors the versions in the sources, so
-- every tagging run refreshes it, and then the CTAN snapshot of README.md.
-- The tools expect the bundle root.
function tag_hook(tagname, tagdate)
  local errorlevel = run(maindir, "texlua tools/update-bundle-manifest.lua")
  if errorlevel ~= 0 then
    return errorlevel
  end
  return run(maindir, "texlua tools/make-ctan-readme.lua")
end

-- Never package a CTAN README that no longer matches README.md.
if not is_module and target_list and target_list.ctan
  and fileexists("tools/make-ctan-readme.lua") then
  local stdctan = target_list.ctan.bundle_func
  target_list.ctan.bundle_func = function(names)
    local errorlevel = run(maindir, "texlua tools/make-ctan-readme.lua --check")
    if errorlevel ~= 0 then
      return errorlevel
    end
    return stdctan(names)
  end
end

--[[
  Git tags are derived from the sources, never the other way round: commit
  the version changes first (one commit may step several modules), then run
  'l3build gittag'.  Modules whose tag already exists are skipped, so there
  is no need to name the affected ones.  With --dry-run nothing is created
  and missing tags are only reported; .githooks/pre-push relies on that.
--]]
local function git_capture(arguments)
  local handle = io.popen("git " .. arguments .. " 2>" .. os_null)
  local output = handle:read("a")
  handle:close()
  return (output:gsub("%s+$", ""))
end

local function git_succeeds(arguments)
  local result = os.execute("git " .. arguments .. " >" .. os_null .. " 2>&1")
  return result == true or result == 0
end

local function gittag()
  local dry_run = options["dry-run"]

  if not dry_run
    and git_capture("status --porcelain --untracked-files=no") ~= "" then
    print("gittag: uncommitted changes; the tags would not match the sources")
    return 1
  end

  local versions = dofile("tools/bundle-versions.lua")
  local wanted = { }
  local errorlevel = 0

  local function want(name, message)
    if not name:match("^[%w%.%-/]+$") then
      print("gittag: refusing unusual tag name '" .. name .. "'")
      errorlevel = 1
    elseif not git_succeeds("rev-parse -q --verify refs/tags/" .. name) then
      table.insert(wanted, { name = name, message = message })
    else
      return true
    end
    return false
  end

  for _, entry in ipairs(versions.scan()) do
    local version = entry.version_list[1]
    if #entry.entries == 0 then
      print("gittag: no version found for " .. entry.name)
      errorlevel = 1
    elseif not entry.consistent then
      print("gittag: " .. entry.name .. " has mixed versions or dates ("
        .. table.concat(entry.version_list, ", ") .. "); not tagged")
      errorlevel = 1
    elseif version:match("%-dev$") then
      print("Skipping " .. entry.name .. " " .. version
        .. " (development version)")
    else
      version = normalize_tagname(version)
      local name = entry.name .. "/" .. version
      if want(name, entry.name .. " " .. version)
        and not dry_run
        and not git_succeeds(
          "diff --quiet " .. name .. " HEAD -- " .. entry.name
        ) then
        print("Warning: " .. entry.name .. " has changed since " .. name
          .. " but still carries that version")
      end
    end
  end

  local _, release_date = (versions.read_file("README.md") or "")
    :match(release_date_pattern)
  if is_iso_date(release_date) then
    want(release_date, bundle .. " bundle " .. release_date)
  end

  for _, tag in ipairs(wanted) do
    if dry_run then
      print("Missing git tag: " .. tag.name)
      errorlevel = 1
    elseif git_succeeds(
      "tag -a " .. tag.name .. ' -m "' .. tag.message .. '"'
    ) then
      print("Created git tag " .. tag.name)
    else
      print("gittag: could not create " .. tag.name)
      errorlevel = 1
    end
  end

  if #wanted == 0 then
    print("All git tags are up to date")
  elseif not dry_run then
    print("Publish them with: git push --follow-tags")
  end
  return errorlevel
end

target_list.gittag = {
  desc = "Creates the git tags matching the module versions (bundle root)",
  bundle_func = gittag,
  func = function()
    print("Run 'l3build gittag' from the bundle root")
    return 1
  end,
}
