#include "core/videoredo_project.hpp"
#include "core/build_info.hpp"

#include <algorithm>
#include <charconv>
#include <fstream>
#include <iomanip>
#include <sstream>
#include <string_view>

namespace smartcut::core {
namespace {

std::string xml_decode(std::string value) {
  const std::pair<std::string_view, std::string_view> entities[] = {
      {"&amp;", "&"}, {"&lt;", "<"}, {"&gt;", ">"},
      {"&quot;", "\""}, {"&apos;", "'"}};
  for (const auto& [encoded, decoded] : entities) {
    std::size_t cursor = 0;
    while ((cursor = value.find(encoded, cursor)) != std::string::npos) {
      value.replace(cursor, encoded.size(), decoded);
      cursor += decoded.size();
    }
  }
  return value;
}

std::string xml_encode(std::string_view value) {
  std::string output;
  output.reserve(value.size());
  for (const char character : value) {
    switch (character) {
      case '&': output += "&amp;"; break;
      case '<': output += "&lt;"; break;
      case '>': output += "&gt;"; break;
      case '\"': output += "&quot;"; break;
      case '\'': output += "&apos;"; break;
      default: output.push_back(character); break;
    }
  }
  return output;
}

std::optional<std::string> element_text(std::string_view document,
                                        std::string_view tag,
                                        std::size_t start = 0) {
  const std::string open = "<" + std::string(tag) + ">";
  const std::string close = "</" + std::string(tag) + ">";
  const auto begin = document.find(open, start);
  if (begin == std::string_view::npos) return std::nullopt;
  const auto content = begin + open.size();
  const auto end = document.find(close, content);
  if (end == std::string_view::npos) return std::nullopt;
  return xml_decode(std::string(document.substr(content, end - content)));
}

std::optional<std::int64_t> signed_element(std::string_view document,
                                           std::string_view tag,
                                           std::size_t start = 0) {
  const auto text = element_text(document, tag, start);
  if (!text) return std::nullopt;
  std::int64_t value{};
  const auto parsed = std::from_chars(text->data(), text->data() + text->size(), value);
  if (parsed.ec != std::errc{} || parsed.ptr != text->data() + text->size()) return std::nullopt;
  return value;
}

std::optional<std::uint64_t> unsigned_element(std::string_view document,
                                              std::string_view tag,
                                              std::size_t start = 0) {
  const auto value = signed_element(document, tag, start);
  if (!value || *value < 0) return std::nullopt;
  return static_cast<std::uint64_t>(*value);
}

std::optional<double> double_element(std::string_view document,
                                     std::string_view tag) {
  const auto text = element_text(document, tag);
  if (!text) return std::nullopt;
  try {
    std::size_t consumed{};
    const double value = std::stod(*text, &consumed);
    return consumed == text->size() ? std::optional<double>{value} : std::nullopt;
  } catch (...) {
    return std::nullopt;
  }
}

std::vector<VideoReDoTimeRange> read_ranges(std::string_view document) {
  std::vector<VideoReDoTimeRange> ranges;
  std::size_t cursor = 0;
  while ((cursor = document.find("<cut", cursor)) != std::string_view::npos) {
    const auto open_end = document.find('>', cursor);
    const auto end = open_end == std::string_view::npos
                         ? std::string_view::npos
                         : document.find("</cut>", open_end + 1);
    if (end == std::string_view::npos) break;
    const auto item = document.substr(open_end + 1, end - open_end - 1);
    const auto start_us = unsigned_element(item, "CutTimeStart");
    const auto end_us = unsigned_element(item, "CutTimeEnd");
    if (start_us && end_us && *start_us < *end_us)
      ranges.push_back({*start_us / 10ULL, *end_us / 10ULL});
    cursor = end + 6;
  }
  std::sort(ranges.begin(), ranges.end(), [](const auto& left, const auto& right) {
    return left.start_us < right.start_us;
  });
  return ranges;
}

std::vector<std::uint64_t> read_markers(std::string_view document,
                                        const std::vector<std::string_view>& tags) {
  std::vector<std::uint64_t> markers;
  for (const auto tag : tags) {
    std::size_t cursor = 0;
    const std::string open = "<" + std::string(tag) + ">";
    while ((cursor = document.find(open, cursor)) != std::string_view::npos) {
      if (const auto value = unsigned_element(document, tag, cursor)) markers.push_back(*value / 10ULL);
      cursor += open.size();
    }
  }
  std::sort(markers.begin(), markers.end());
  markers.erase(std::unique(markers.begin(), markers.end()), markers.end());
  return markers;
}

void append_direct_markers(std::string_view document, std::string_view tag,
                           std::vector<std::uint64_t>& markers) {
  const std::string open = "<" + std::string(tag);
  const std::string close = "</" + std::string(tag) + ">";
  std::size_t cursor{};
  while ((cursor = document.find(open, cursor)) != std::string_view::npos) {
    const auto name_end = cursor + open.size();
    if (name_end >= document.size() ||
        (document[name_end] != '>' && document[name_end] != ' ' &&
         document[name_end] != '\t' && document[name_end] != '\r' &&
         document[name_end] != '\n')) {
      cursor = name_end;
      continue;
    }
    const auto open_end = document.find('>', cursor + open.size());
    const auto close_start = open_end == std::string_view::npos
                                 ? std::string_view::npos
                                 : document.find(close, open_end + 1);
    if (close_start == std::string_view::npos) break;
    const auto text = document.substr(open_end + 1, close_start - open_end - 1);
    std::uint64_t ticks{};
    const auto parsed = std::from_chars(text.data(), text.data() + text.size(), ticks);
    if (parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size())
      markers.push_back(ticks / 10ULL);
    cursor = close_start + close.size();
  }
}

std::string timecode(std::uint64_t microseconds) {
  const auto seconds = microseconds / 1'000'000ULL;
  const auto frames = (microseconds % 1'000'000ULL) * 25ULL / 1'000'000ULL;
  std::ostringstream output;
  output << std::setfill('0') << std::setw(2) << seconds / 3600ULL << ':'
         << std::setw(2) << (seconds / 60ULL) % 60ULL << ':'
         << std::setw(2) << seconds % 60ULL << ';' << std::setw(2) << frames;
  return output.str();
}

void set_error(std::string* error, std::string value) {
  if (error) *error = std::move(value);
}

}  // namespace

std::optional<VideoReDoProject> read_videoredo_project(
    const std::filesystem::path& path, std::string* error) {
  std::ifstream input(path, std::ios::binary);
  if (!input) {
    set_error(error, "could not open VideoReDo project");
    return std::nullopt;
  }
  const std::string document((std::istreambuf_iterator<char>(input)), {});
  return parse_videoredo_project(document, error);
}

std::optional<VideoReDoProject> parse_videoredo_project(
    std::string_view document, std::string* error) {
  if (document.find("<VideoReDoProject") == std::string::npos) {
    set_error(error, "not a VideoReDo project document");
    return std::nullopt;
  }
  VideoReDoProject project;
  if (const auto filename = element_text(document, "Filename")) project.source_path = *filename;
  if (project.source_path.empty()) {
    set_error(error, "VideoReDo project has no source filename");
    return std::nullopt;
  }
  if (const auto value = element_text(document, "Description")) project.description = *value;
  if (const auto value = unsigned_element(document, "Duration")) project.duration_us = *value / 10ULL;
  if (const auto value = signed_element(document, "SyncAdjustment")) project.sync_adjustment_ms = *value;
  if (const auto value = double_element(document, "AudioVolumeAdjust")) project.audio_volume_adjust = *value;
  if (const auto value = signed_element(document, "CutMode")) project.cut_mode = *value != 0;
  if (const auto value = signed_element(document, "StreamType")) project.stream_type = static_cast<int>(*value);
  if (const auto value = signed_element(document, "VideoStreamPID")) project.video_pid = static_cast<int>(*value);
  if (const auto value = signed_element(document, "AudioStreamPID")) project.audio_pid = static_cast<int>(*value);
  else if (const auto legacy_value = signed_element(document, "AudioStreamPID_0")) project.audio_pid = static_cast<int>(*legacy_value);
  project.selections = read_ranges(document);
  project.scene_markers_us = read_markers(document, {"SceneTime", "SceneMarkerTime", "SceneTimeStart"});
  project.chapter_markers_us = read_markers(document, {"ChapterTime", "ChapterMarkerTime"});
  append_direct_markers(document, "SceneMarker", project.scene_markers_us);
  append_direct_markers(document, "ChapterMarker", project.chapter_markers_us);
  std::sort(project.scene_markers_us.begin(), project.scene_markers_us.end());
  project.scene_markers_us.erase(std::unique(project.scene_markers_us.begin(), project.scene_markers_us.end()), project.scene_markers_us.end());
  std::sort(project.chapter_markers_us.begin(), project.chapter_markers_us.end());
  project.chapter_markers_us.erase(std::unique(project.chapter_markers_us.begin(), project.chapter_markers_us.end()), project.chapter_markers_us.end());
  return project;
}

std::string serialize_videoredo_project(const VideoReDoProject& project,
                                        bool include_xml_declaration) {
  if (project.source_path.empty()) return {};
  std::ostringstream output;
  if (include_xml_declaration)
    output << "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n";
  output << "<VideoReDoProject Version=\"5\">\n"
         << "\t<VideoReDoVersion BuildNumber=\"0\">SmartCut64 " << smartcut::build_info::application_version << "</VideoReDoVersion>\n"
         << "\t<Filename>" << xml_encode(project.source_path) << "</Filename>\n"
         << "\t<Description>" << xml_encode(project.description) << "</Description>\n"
         << "\t<StreamType>" << project.stream_type << "</StreamType>\n"
         << "\t<Duration>" << project.duration_us * 10ULL << "</Duration>\n"
         << "\t<SyncAdjustment>" << project.sync_adjustment_ms << "</SyncAdjustment>\n"
         << "\t<AudioVolumeAdjust>" << std::fixed << std::setprecision(6)
         << project.audio_volume_adjust << "</AudioVolumeAdjust>\n"
         << "\t<CutMode>" << (project.cut_mode ? 1 : 0) << "</CutMode>\n";
  if (project.video_pid >= 0) output << "\t<VideoStreamPID>" << project.video_pid << "</VideoStreamPID>\n";
  if (project.audio_pid >= 0) output << "\t<AudioStreamPID>" << project.audio_pid << "</AudioStreamPID>\n";
  output << "\t<ProjectTime>" << project.duration_us * 10ULL << "</ProjectTime>\n\t<CutList>\n";
  for (std::size_t index = 0; index < project.selections.size(); ++index) {
    const auto& range = project.selections[index];
    if (range.start_us >= range.end_us) continue;
    output << "\t\t<cut Sequence=\"" << index + 1 << "\" CutStart=\""
           << timecode(range.start_us) << "\" CutEnd=\"" << timecode(range.end_us)
           << "\" Elapsed=\"00:00:00;00\">\n"
           << "\t\t\t<CutTimeStart>" << range.start_us * 10ULL << "</CutTimeStart>\n"
           << "\t\t\t<CutTimeEnd>" << range.end_us * 10ULL << "</CutTimeEnd>\n"
           << "\t\t\t<CutByteStart>0</CutByteStart>\n"
           << "\t\t\t<CutByteEnd>0</CutByteEnd>\n\t\t</cut>\n";
  }
  output << "\t</CutList>\n\t<SceneList>\n";
  for (std::size_t index = 0; index < project.scene_markers_us.size(); ++index) {
    const auto marker = project.scene_markers_us[index];
    output << "\t\t<SceneMarker Sequence=\"" << index + 1 << "\" Timecode=\""
           << timecode(marker) << "\">" << marker * 10ULL << "</SceneMarker>\n";
  }
  output << "\t</SceneList>\n\t<ChapterList>\n";
  for (std::size_t index = 0; index < project.chapter_markers_us.size(); ++index) {
    const auto marker = project.chapter_markers_us[index];
    output << "\t\t<ChapterMarker Sequence=\"" << index + 1 << "\" Timecode=\""
           << timecode(marker) << "\">" << marker * 10ULL << "</ChapterMarker>\n";
  }
  output << "\t</ChapterList>\n</VideoReDoProject>\n";
  return output.str();
}

bool write_videoredo_project(const std::filesystem::path& path,
                             const VideoReDoProject& project,
                             std::string* error) {
  if (project.source_path.empty()) {
    set_error(error, "VideoReDo project source filename is empty");
    return false;
  }
  const auto temporary = path.string() + ".tmp";
  std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
  if (!output) {
    set_error(error, "could not create VideoReDo project");
    return false;
  }
  output << serialize_videoredo_project(project);
  output.close();
  if (!output) {
    std::filesystem::remove(temporary);
    set_error(error, "could not write VideoReDo project");
    return false;
  }
  const auto backup = path.string() + ".bak";
  std::error_code rename_error;
  std::filesystem::remove(backup, rename_error);
  rename_error.clear();
  const bool replacing = std::filesystem::exists(path);
  if (replacing) {
    std::filesystem::rename(path, backup, rename_error);
    if (rename_error) {
      std::filesystem::remove(temporary);
      set_error(error, "could not preserve existing VideoReDo project");
      return false;
    }
  }
  std::filesystem::rename(temporary, path, rename_error);
  if (rename_error) {
    std::filesystem::remove(temporary);
    if (replacing) {
      std::error_code restore_error;
      std::filesystem::rename(backup, path, restore_error);
    }
    set_error(error, "could not finalize VideoReDo project");
    return false;
  }
  if (replacing) std::filesystem::remove(backup, rename_error);
  return true;
}

}  // namespace smartcut::core
