--
-- Copyright (c) 2021-2025 Zeping Lee
-- Released under the MIT license.
-- Repository: https://github.com/zepinglee/citeproc-lua
--

local bibtex2csl = {}

local uni_utf8
local bibtex_parser
local bibtex_data
local journal_data
local latex_parser
local unicode
local util
local using_luatex, _ = pcall(require, "kpse")
if using_luatex then
  uni_utf8 = require("unicode").utf8
  bibtex_parser = require("citeproc-bibtex-parser")
  bibtex_data = require("citeproc-bibtex-data")
  unicode = require("citeproc-unicode")
  util = require("citeproc-util")
else
  uni_utf8 = require("lua-utf8")
  bibtex_parser = require("citeproc.bibtex-parser")
  bibtex_data = require("citeproc.bibtex-data")
  unicode = require("citeproc.unicode")
  util = require("citeproc.util")
end


---@alias CslItem ItemData
---@alias CslData CslItem[]


---Parse BibTeX content and convert to CSL-JSON
---@param str string
---@param keep_unknown_commands boolean? Keep unknown latex markups in <code>.
---@param case_protection boolean? Add case-protection to braces as in BibTeX.
---@param sentence_case_title boolean? Convert `title` and `booktitle` to sentence case.
---@param check_sentence_case boolean? Check titles that are already sentence cased and do not conver them.
---@return CslData?, Exception[]
function bibtex2csl.parse_bibtex_to_csl(str, keep_unknown_commands, case_protection, sentence_case_title,
    check_sentence_case)
  local strings = {}
  for name, macro in pairs(bibtex_data.macros) do
    strings[name] = macro.value
  end
  local bib_data, exceptions = bibtex_parser.parse(str, strings)
  local csl_json_items = nil
  if bib_data then
    -- TODO: Ideally we should load all .bib files and then resolve crossrefs and related
    bibtex_parser.resolve_crossrefs(bib_data.entries, bibtex_data.entries_by_id)
    csl_json_items = bibtex2csl.convert_to_csl_data(bib_data, keep_unknown_commands, case_protection,
      sentence_case_title, check_sentence_case)
  end
  return csl_json_items, exceptions
end


---@param bib BibtexData
---@param keep_unknown_commands boolean?
---@param case_protection boolean?
---@param sentence_case_title boolean?
---@param check_sentence_case boolean?
---@return CslData
function bibtex2csl.convert_to_csl_data(bib, keep_unknown_commands, case_protection, sentence_case_title,
    check_sentence_case)
  local csl_data = {}

  -- BibTeX looks for crossref in a case-insensitive manner.
  local bib_entry_dict = {}
  local csl_item_dict = {}

  for _, entry in ipairs(bib.entries) do
    bib_entry_dict[unicode.casefold(entry.key)] = entry
    local item = bibtex2csl.convert_to_csl_item(entry, keep_unknown_commands, case_protection, sentence_case_title,
      check_sentence_case)

    table.insert(csl_data, item)
    csl_item_dict[item.id] = item
  end

  bibtex2csl.resolve_related(csl_item_dict, bib_entry_dict)

  return csl_data
end


---@param entry BibtexEntry
---@param keep_unknown_commands boolean?
---@param case_protection boolean?
---@param sentence_case_title boolean?
---@param check_sentence_case boolean?
---@return CslItem
function bibtex2csl.convert_to_csl_item(entry, keep_unknown_commands, case_protection, sentence_case_title,
    check_sentence_case, disable_journal_abbreviation)
  ---@type CslItem
  local item = {
    id = entry.key,
    type = "document",
  }

  bibtex2csl.pre_process_special_fields(item, entry)

  -- First convert primary fields
  for field, csl_field in pairs(bibtex_data.primary_fields) do
    local value = entry.fields[field]
    if value then
      local _, csl_value = bibtex2csl.convert_field(
        field, value, keep_unknown_commands, case_protection, sentence_case_title, item.language, check_sentence_case)
      if csl_field and csl_value and not item[csl_field] then
        item[csl_field] = csl_value
      end
    end
  end

  -- Convert the fields in a fixed order
  local field_list = {}
  for field, _ in pairs(entry.fields) do
    table.insert(field_list, field)
  end
  table.sort(field_list)

  for _, field in ipairs(field_list) do
    local value = entry.fields[field]
    local csl_field, csl_value = bibtex2csl.convert_field(
      field, value, keep_unknown_commands, case_protection, sentence_case_title, item.language, check_sentence_case)
    if csl_field and csl_value and not item[csl_field] then
      item[csl_field] = csl_value
    end
  end

  bibtex2csl.post_process_special_fields(item, entry, disable_journal_abbreviation)
  return item
end


---@param item CslItem
---@param entry BibtexEntry
function bibtex2csl.pre_process_special_fields(item, entry)
  -- CSL types
  local type_data = bibtex_data.types[entry.type]
  if type_data and type_data.csl then
    item.type = type_data.csl
  elseif entry.fields.url then
    item.type = "webpage"
  end

  -- BibTeX's `edition` is expected to be an ordinal.
  if entry.fields.edition then
    item.edition = util.convert_ordinal_to_arabic(entry.fields.edition)
  end

  -- language: convert `babel` language to ISO 639-1 language code
  local lang = entry.fields.langid or entry.fields.language
  if lang then
    item.language = bibtex_data.language_code_map[unicode.casefold(lang)]
  end
  -- if not item.language then
  --   if util.has_cjk_char(item.title) then
  --     item.language = "zh"
  --   end
  -- end

  -- Merge title, maintitle, subtitle, titleaddon
  bibtex2csl.process_titles(entry)

  -- biblatex-apa
  if entry.fields.howpublished then
    local howpublished = string.lower(entry.fields.howpublished)
    if howpublished == "advance online publication" then
      -- TODO: get_locale_term("advance online publication" )
      item.status = "Advance online publication"
    elseif howpublished == "manunpub" then
      -- biblatex-apa
      item.genre = "Unpublished manuscript"
    elseif howpublished == "maninprep" then
      -- biblatex-apa
      item.genre = "Manuscript in preparation"
    elseif howpublished == "mansub" then
      -- biblatex-apa
      item.genre = "Manuscript submitted for publication"
    end
  end
  if entry.fields.pubstate
      and string.lower(entry.fields.pubstate) == "inpress" then
    -- TODO: get_locale_term("advance online publication" )
    item.status = "in press"
  end
end


---@param entry BibtexEntry
function bibtex2csl.process_titles(entry)
  local fields = entry.fields
  -- title and subtitle
  if fields.subtitle then
    if fields.title then
      fields.title = util.join_title(fields.title, fields.subtitle)
      if not fields.shorttitle then
        fields.shorttitle = fields.title
      end
    else
      fields.title = fields.subtitle
    end
    fields.subtitle = nil
  end

  -- booktitle and booksubtitle
  if fields.booksubtitle then
    if not fields["container-title-short"] then
      fields["container-title-short"] = fields.booktitle
    end
    if fields.booktitle then
      fields.booktitle = util.join_title(fields.booktitle, fields.booksubtitle)
    else
      fields.booktitle = fields.booksubtitle
    end
    fields.booksubtitle = nil
  end

  -- mainsubtitle
  if fields.mainsubtitle then
    if fields.maintitle then
      fields.maintitle = util.join_title(fields.maintitle, fields.mainsubtitle)
    else
      fields.maintitle = fields.mainsubtitle
    end
  end

  -- maintitle
  if fields.maintitle then
    if entry.type == "audio" or entry.type == "video" then
      -- maintitle is the container-title
      fields["container-title"] = fields.maintitle
    elseif fields.booktitle then
      -- maintitle is with booktitle
      if not fields["volume-title"] then
        fields["volume-title"] = fields.booktitle
        fields.booktitle = fields.maintitle
      end
    else
      -- maintitle is with title
      if fields.title then
        if not fields["volume-title"] then
          fields["volume-title"] = fields.title
          fields.title = fields.maintitle
        end
      else
        -- This is unlikelu to happen.
        fields.title = fields.maintitle
      end
    end
  end

  if fields.journalsubtitle then
    if fields.journaltitle then
      fields.journaltitle = util.join_title(fields.journaltitle, fields.journalsubtitle)
    elseif fields.journal then
      fields.journal = util.join_title(fields.journal, fields.journal)
    end
    fields.journalsubtitle = nil
  end
  if fields.issuesubtitle then
    if fields.issuetitle then
      fields.issuetitle = util.join_title(fields.issuetitle, fields.issuesubtitle)
    else
      fields.issuetitle = fields.issuesubtitle
    end
    fields.issuesubtitle = nil
  end
end


---Convert BibTeX field to CSL field
---@param bib_field string
---@param value string
---@param keep_unknown_commands boolean?
---@param case_protection boolean?
---@param sentence_case_title boolean?
---@param language string?
---@param check_sentence_case boolean?
---@return string? csl_field
---@return string | table | number?  csl_value
function bibtex2csl.convert_field(bib_field, value, keep_unknown_commands, case_protection, sentence_case_title,
    language, check_sentence_case)
  local field_data = bibtex_data.fields[bib_field]
  if not field_data then
    return nil, nil
  end
  local csl_field = field_data.csl
  if not (csl_field and type(csl_field) == "string") then
    return nil, nil
  end

  if using_luatex then
    latex_parser = latex_parser or require("citeproc-latex-parser")
  else
    latex_parser = latex_parser or require("citeproc.latex-parser")
  end

  local field_type = field_data.type
  local csl_value
  if field_type == "name" then
    -- 1. unicode 2. prify (remove LaTeX markups) 3. plain text 4. split name parts
    value = latex_parser.latex_to_unicode(value)
    local names = bibtex_parser.split_names(value)
    csl_value = {}
    for i, name_str in ipairs(names) do
      local name_dict = bibtex_parser.split_name_parts(name_str)
      csl_value[i] = bibtex2csl.convert_to_csl_name(name_dict)
    end

  elseif field_type == "date" then
    if string.match(value, "\\") then
      -- "{\noopsort{1973c}}1981"
      value = latex_parser.latex_to_pseudo_html(value, false, false)
    end
    -- "-3000~" should not be converted to "-3000 "
    csl_value = util.parse_edtf(value)

  elseif bib_field == "title" or bib_field == "shorttitle"
      or bib_field == "booktitle" or bib_field == "container-title-short" then
    -- 1. unicode 2. sentence case 3. html tag
    if sentence_case_title and (not language or util.startswith(language, "en")) then
      csl_value = latex_parser.latex_to_sentence_case_pseudo_html(value, keep_unknown_commands, case_protection,
        check_sentence_case)
    else
      csl_value = latex_parser.latex_to_pseudo_html(value, keep_unknown_commands, case_protection)
    end

  else
    -- 1. unicode 2. html tag
    csl_value = latex_parser.latex_to_pseudo_html(value, keep_unknown_commands, case_protection)
    if csl_field == "volume" or csl_field == "page" then
      csl_value = string.gsub(csl_value, util.unicode["en dash"], "-")
    end
  end

  return csl_field, csl_value
end


local function clean_name_part(name_part)
  if not name_part then
    return nil
  end
  return string.gsub(name_part, "[{}]", "")
end


function bibtex2csl.convert_to_csl_name(bibtex_name)
  if bibtex_name.last and not (bibtex_name.first or bibtex_name.von or bibtex_name.jr)
      and string.match(bibtex_name.last, "^%b{}$") then
    return {
      literal = string.sub(bibtex_name.last, 2, -2),
    }
  end
  local csl_name = {
    family = clean_name_part(bibtex_name.last),
    ["non-dropping-particle"] = clean_name_part(bibtex_name.von),
    given = clean_name_part(bibtex_name.first),
    suffix = clean_name_part(bibtex_name.jr),
  }
  return csl_name
end


local arxiv_url_prefix = "https://arxiv.org/abs/"
local pubmed_url_prefix = "https://www.ncbi.nlm.nih.gov/pubmed/"
local pubmed_central_url_prefix = "https://www.ncbi.nlm.nih.gov/pmc/articles/"


---@param item CslItem
---@param entry BibtexEntry
function bibtex2csl.post_process_special_fields(item, entry, disable_journal_abbreviation)
  local bib_type = entry.type
  local bib_fields = entry.fields

  -- biblatex-apa
  if item.type == "article-journal" and entry.fields.entrysubtype == "nonacademic" then
    item.type = "article-magazine"
    item.genre = nil

  elseif item.type == "motion_picture" then
    if entry.fields.entrysubtype == "tvseries" then
      item.type = "broadcast"
      if item.genre == "tvseries" then
        item.genre = "TV series"
      end
    elseif entry.fields.entrysubtype == "tvepisode" then
      item.type = "broadcast"
      if item.genre == "tvepisode" then
        item.genre = "TV series episode"
      end
    end

  elseif item.type == "song" then
    if entry.fields.entrysubtype == "podcast" then
      item.type = "broadcast"
      if item.genre == "podcast" then
        item.genre = "Audio podcast"
      end

    elseif entry.fields.entrysubtype == "podcastepisode" then
      item.type = "broadcast"
      if item.genre == "podcastepisode" then
        item.genre = "Audio podcast episode"
      end

    elseif entry.fields.entrysubtype == "interview" then
      item.type = "interview"

    elseif entry.fields.entrysubtype == "speech" then
      item.type = "speech"

    end

  elseif item.type == "graphic" then
    item["archive-place"] = item["publisher-place"]

    if entry.fields.entrysubtype == "map" then
      item.type = "map"
    end

  elseif item.type == "webpage" then

    -- eprinttype is mapped to
    -- - `archive` for Google books;
    -- - `container-title` for twitter post;
    -- - `publisher` for arXiv preprint;

    local eprint_type_map = {
      facebook = "post",
      instagram = "post",
      reddit = "post",
      twitter = "post",
      arxiv = "preprint",
      psyarxiv = "preprint",
      pubmed = "preprint",
      ["pubmed central"] = "preprint",
    }

    -- Biblatex's `online` type can be mapped to `post`, `preprint`
    -- local eprinttype = entry.fields.eprinttype or entry.fields.archiveprefix
    if item.archive then
      local eprint_type = eprint_type_map[string.lower(item.archive)]
      if eprint_type then
        item.type = eprint_type
      elseif item.number then
        item.type = "preprint"
      elseif entry.fields.eprint then
        item.type = "preprint"
        item.numerb = entry.fields.eprint
      elseif item.DOI then
        item.type = "preprint"
      elseif item.URL then
        if util.startswith(item.URL, arxiv_url_prefix) then
          item.type = "preprint"
        elseif util.startswith(item.URL, pubmed_url_prefix) then
          item.type = "preprint"
        elseif util.startswith(item.URL, pubmed_central_url_prefix) then
          item.type = "preprint"
        end
      end
      if item.type == "preprint" then
        if not item.publisher then
          item.publisher = item.archive
          item.archive = nil
        end
        if not item.number then
          item.number = entry.fields.eprint
        end
      else
        if not item["container-title"] then
          item["container-title"] = item.archive
          item.archive = nil
        end
      end
    end

  end

  -- event-date
  if item["event-date"] and not item.issued then
    item.issued = util.deep_copy(item["event-date"])
  end

  -- event-title: for compatibility with CSL v1.0.1 and earlier versions
  if item["event-title"] then
    item.event = item["event-title"]
  end

  -- Jounal abbreviations
  if not disable_journal_abbreviation then
    if item.type == "article-journal" or item.type == "article-magazine"
        or item.type == "article-newspaper" then
      bibtex2csl.check_journal_abbreviations(item)
    end
  end

  if not item.genre then
    if bib_type == "phdthesis" then
      -- from APA
      item.genre = "Doctoral dissertation"
    elseif bib_type == "mastersthesis" then
      item.genre = "Master’s thesis"
    end
  end

  -- month
  -- local month = bib_fields.month
  local month_text = bib_fields.month
  if month_text then
    month_text = latex_parser.latex_to_pseudo_html(month_text, false, false)
    local month, day = uni_utf8.match(month_text, "^(%a+)%.?,?%s+(%d+)%a*$")
    if not month then
      day, month = uni_utf8.match(month_text, "^(%d+)%a*%s+(%a+)%.?$")
    end
    if not month then
      month = string.match(month_text, "^(%a+)%.?$")
    end
    if month then
      month = bibtex_data.months[unicode.casefold(month)]
    end
    if month and item.issued and item.issued["date-parts"] and
        item.issued["date-parts"][1] and
        item.issued["date-parts"][1][2] == nil then
      item.issued["date-parts"][1][2] = tonumber(month)
      if day then
        item.issued["date-parts"][1][3] = tonumber(day)
      end
    end
  end

  -- number
  if bib_fields.number then
    if item.type == "article-journal" or item.type == "article-magazine" or
        item.type == "article-newspaper" or item.type == "periodical" then
      if not item.issue then
        item.issue = bib_fields.number
      end
    elseif item["collection-title"] and not item["collection-number"] then
      item["collection-number"] = bib_fields.number
    elseif not item.number then
      item.number = string.gsub(bib_fields.number, "([^-])%-([^-])", "%1\\-%2")
    end
  end

  -- organization: the `organizer` that sponsors a conference or a `publisher` that publishes a `@manual` or `@online`.
  if bib_fields.organization then
    if item.publisher or bib_type == "inproceedings" or bib_type == "proceedings" then
      if not item.organizer then
        item.organizer = {
          literal = bib_fields.organization,
        }
      end
    elseif not item.publisher then
      item.publisher = bib_fields.organization
    end
  end

  -- DOI
  if type(item.DOI) == "string" then
    item.DOI = util.remove_prefix(item.DOI, "https://doi.org/")
    item.DOI = util.remove_prefix(item.DOI, "doi.org/")
  end

  -- PMID
  if bib_fields.eprint and type(bib_fields.eprinttype) == "string" and
      string.lower(bib_fields.eprinttype) == "pubmed" and not item.PMID then
    item.PMID = bib_fields.eprint
  end

  if item.URL then
    if item.type == "preprint" and util.startswith(item.URL, arxiv_url_prefix) then
      if not item.publisher then
        item.publisher = "arXiv"
        item.archive = nil
      end
      if not item.number then
        item.number = util.remove_prefix(item.URL, pubmed_url_prefix)
      end
    end

    if util.startswith(item.URL, pubmed_url_prefix) then
      if not item.publisher then
        item.publisher = "PubMed"
        item.archive = nil
      end
      if not item.PMID then
        item.PMID = util.remove_prefix(item.URL, pubmed_url_prefix)
      end
    end

    if util.startswith(item.URL, pubmed_central_url_prefix) then
      if not item.publisher then
        item.publisher = "PubMed Central"
        item.archive = nil
      end
      if not item.PMCID then
        item.PMCID = util.remove_prefix(item.URL, pubmed_central_url_prefix)
      end
    end
  end

  -- `APA Education [@APAEducation], (2018, June 29). College students are forming menta/-health c/ubs-and they're making a difference @washingtonpost [Thumbnail with link attached]`
  -- The `[Thumbnail with link attached]` should not be italicized.
  -- if bib_fields.titleaddon and item.genre and item.genre ~= bib_fields.titleaddon then
  --   if item.title then
  --     item.title = string.format("%s [%s]", item.title, bib_fields.titleaddon)
  --   end
  -- end

end


function bibtex2csl.check_journal_abbreviations(item)
  if item["container-title"] and not item["container-title-short"] then
    if not journal_data then
      if using_luatex then
        journal_data = require("citeproc-journal-data")
      else
        journal_data = require("citeproc.journal-data")
      end
    end
    local key = unicode.casefold(string.gsub(item["container-title"], "%.", ""))
    local abbr = journal_data.abbrevs[key]
    if abbr then
      item["container-title-short"] = abbr
    else
      local full = journal_data.unabbrevs[key]
      if full then
        item["container-title-short"] = item["container-title"]
        item["container-title"] = full
      end
    end
  end
end


local original_field_dict = {
  author = "original-author",
  issued = "original-date",
  publisher = "original-publisher",
  ["publisher-place"] = "original-publisher-place",
  title = "original-title",
}

local reviewed_field_dict = {
  author = "reviewed-author",
  genre = "reviewed-genre",
  title = "reviewed-title",
}


---@param csl_item_dict table<string, CslItem>
---@param bib_entry_dict table<string, BibtexEntry>
function bibtex2csl.resolve_related(csl_item_dict, bib_entry_dict)
  for _, entry in pairs(bib_entry_dict) do
    local related_key = entry.fields.related
    local related_type = entry.fields.relatedtype
    if related_key then
      local related_bib_entry = bib_entry_dict[unicode.casefold(related_key)]
      if related_bib_entry then
        local csl_item = csl_item_dict[entry.key]
        local related_csl_item = csl_item_dict[related_bib_entry.key]
        if related_type == "reprintof" or related_type == "reprintfrom" then
          for original_field, new_field in pairs(original_field_dict) do
            if not csl_item[new_field] and related_csl_item[original_field] then
              csl_item[new_field] = util.deep_copy(related_csl_item[original_field])
            end
          end

        elseif related_type == "translationof" or related_type == "translationfrom" then
          for original_field, new_field in pairs(original_field_dict) do
            if not csl_item[new_field] and related_csl_item[original_field] then
              csl_item[new_field] = util.deep_copy(related_csl_item[original_field])
            end
          end

        elseif related_type == "reviewof" then
          for reviewed_field, new_field in pairs(reviewed_field_dict) do
            if not csl_item[new_field] and related_csl_item[reviewed_field] then
              csl_item[new_field] = util.deep_copy(related_csl_item[reviewed_field])
            end
          end

        end
      else
        util.warning(string.format('Related entry "%s" of "%s" not found.', related_key, entry.key))
      end
    end
  end
end


return bibtex2csl
