/*----------------------------------------------------------------------------*/
/* Copyright (c) 2020 FIRST. All Rights Reserved.                             */
/* Open Source Software - may be modified and shared by FRC teams. The code   */
/* must be accompanied by the FIRST BSD license file in the root directory of */
/* the project.                                                               */
/*----------------------------------------------------------------------------*/

#include "Field2D.h"

#include <cmath>

#include <GL/gl3w.h>
#include <hal/SimDevice.h>
#include <imgui.h>

#define IMGUI_DEFINE_MATH_OPERATORS
#include <hal/simulation/SimDeviceData.h>
#include <imgui_internal.h>
#include <units/angle.h>
#include <units/length.h>
#include <wpi/Path.h>
#include <wpi/SmallString.h>
#include <wpi/json.h>
#include <wpi/raw_istream.h>
#include <wpi/raw_ostream.h>

#include "GuiUtil.h"
#include "HALSimGui.h"
#include "SimDeviceGui.h"
#include "portable-file-dialogs.h"

using namespace halsimgui;

namespace {

// Per-frame field data (not persistent)
struct FieldFrameData {
  // in window coordinates
  ImVec2 imageMin;
  ImVec2 imageMax;
  ImVec2 min;
  ImVec2 max;

  float scale;  // scaling from field units to screen units
};

class FieldInfo {
 public:
  static constexpr float kDefaultWidth = 15.98f;
  static constexpr float kDefaultHeight = 8.21f;

  std::unique_ptr<pfd::open_file> m_fileOpener;
  float m_width = kDefaultWidth;
  float m_height = kDefaultHeight;

  void Reset();
  void LoadImage();
  void LoadJson(const wpi::Twine& jsonfile);
  FieldFrameData GetFrameData() const;
  void Draw(ImDrawList* drawList, const ImVec2& windowPos,
            const FieldFrameData& frameData) const;

  bool ReadIni(wpi::StringRef name, wpi::StringRef value);
  void WriteIni(ImGuiTextBuffer* out) const;

 private:
  bool LoadImageImpl(const wpi::Twine& fn);

  std::string m_filename;
  GLuint m_texture = 0;
  int m_imageWidth = 0;
  int m_imageHeight = 0;
  int m_top = 0;
  int m_left = 0;
  int m_bottom = -1;
  int m_right = -1;
};

// Per-frame robot data (not persistent)
struct RobotFrameData {
  // in window coordinates
  ImVec2 center;
  ImVec2 corners[4];
  ImVec2 arrow[3];

  // scaled width/2 and length/2, in screen units
  float width2;
  float length2;
};

class RobotInfo {
 public:
  static constexpr float kDefaultWidth = 0.6858f;
  static constexpr float kDefaultLength = 0.8204f;

  std::unique_ptr<pfd::open_file> m_fileOpener;
  float m_width = kDefaultWidth;
  float m_length = kDefaultLength;

  void Reset();
  void LoadImage();
  void UpdateFromSimDevice();
  void SetPosition(double x, double y);
  // set and get rotation in radians
  void SetRotation(double rot);
  double GetRotation() const {
    return units::convert<units::degrees, units::radians>(m_rot);
  }
  RobotFrameData GetFrameData(const FieldFrameData& ffd) const;
  void Draw(ImDrawList* drawList, const ImVec2& windowPos,
            const RobotFrameData& frameData, int hit, float hitRadius) const;

  bool ReadIni(wpi::StringRef name, wpi::StringRef value);
  void WriteIni(ImGuiTextBuffer* out) const;

 private:
  bool LoadImageImpl(const wpi::Twine& fn);

  std::string m_filename;
  GLuint m_texture = 0;

  HAL_SimDeviceHandle m_devHandle = 0;
  hal::SimDouble m_xHandle;
  hal::SimDouble m_yHandle;
  hal::SimDouble m_rotHandle;

  double m_x = 0;
  double m_y = 0;
  double m_rot = 0;
};

}  // namespace

static FieldInfo gField;
static RobotInfo gRobot;
static int gDragRobot = 0;
static ImVec2 gDragInitialOffset;
static double gDragInitialAngle;

// read/write settings to ini file
static void* Field2DReadOpen(ImGuiContext* ctx, ImGuiSettingsHandler* handler,
                             const char* name) {
  if (name == wpi::StringRef{"Field"}) return &gField;
  if (name == wpi::StringRef{"Robot"}) return &gRobot;
  return nullptr;
}

static void Field2DReadLine(ImGuiContext* ctx, ImGuiSettingsHandler* handler,
                            void* entry, const char* lineStr) {
  wpi::StringRef line{lineStr};
  auto [name, value] = line.split('=');
  name = name.trim();
  value = value.trim();
  if (entry == &gField)
    gField.ReadIni(name, value);
  else if (entry == &gRobot)
    gRobot.ReadIni(name, value);
}

static void Field2DWriteAll(ImGuiContext* ctx, ImGuiSettingsHandler* handler,
                            ImGuiTextBuffer* out_buf) {
  gField.WriteIni(out_buf);
  gRobot.WriteIni(out_buf);
}

void FieldInfo::Reset() {
  if (m_texture != 0) glDeleteTextures(1, &m_texture);
  m_texture = 0;
  m_filename.clear();
  m_imageWidth = 0;
  m_imageHeight = 0;
  m_top = 0;
  m_left = 0;
  m_bottom = -1;
  m_right = -1;
}

void FieldInfo::LoadImage() {
  if (m_fileOpener && m_fileOpener->ready(0)) {
    auto result = m_fileOpener->result();
    if (!result.empty()) {
      if (wpi::StringRef(result[0]).endswith(".json")) {
        LoadJson(result[0]);
      } else {
        LoadImageImpl(result[0]);
        m_top = 0;
        m_left = 0;
        m_bottom = -1;
        m_right = -1;
      }
    }
    m_fileOpener.reset();
  }
  if (m_texture == 0 && !m_filename.empty()) {
    if (!LoadImageImpl(m_filename)) m_filename.clear();
  }
}

void FieldInfo::LoadJson(const wpi::Twine& jsonfile) {
  std::error_code ec;
  wpi::raw_fd_istream f(jsonfile, ec);
  if (ec) {
    wpi::errs() << "GUI: could not open field JSON file\n";
    return;
  }

  // parse file
  wpi::json j;
  try {
    j = wpi::json::parse(f);
  } catch (const wpi::json::parse_error& e) {
    wpi::errs() << "GUI: JSON: could not parse: " << e.what() << '\n';
  }

  // top level must be an object
  if (!j.is_object()) {
    wpi::errs() << "GUI: JSON: does not contain a top object\n";
    return;
  }

  // image filename
  std::string image;
  try {
    image = j.at("field-image").get<std::string>();
  } catch (const wpi::json::exception& e) {
    wpi::errs() << "GUI: JSON: could not read field-image: " << e.what()
                << '\n';
    return;
  }

  // corners
  int top, left, bottom, right;
  try {
    top = j.at("field-corners").at("top-left").at(1).get<int>();
    left = j.at("field-corners").at("top-left").at(0).get<int>();
    bottom = j.at("field-corners").at("bottom-right").at(1).get<int>();
    right = j.at("field-corners").at("bottom-right").at(0).get<int>();
  } catch (const wpi::json::exception& e) {
    wpi::errs() << "GUI: JSON: could not read field-corners: " << e.what()
                << '\n';
    return;
  }

  // size
  float width;
  float height;
  try {
    width = j.at("field-size").at(0).get<float>();
    height = j.at("field-size").at(1).get<float>();
  } catch (const wpi::json::exception& e) {
    wpi::errs() << "GUI: JSON: could not read field-size: " << e.what() << '\n';
    return;
  }

  // units for size
  std::string unit;
  try {
    unit = j.at("field-unit").get<std::string>();
  } catch (const wpi::json::exception& e) {
    wpi::errs() << "GUI: JSON: could not read field-unit: " << e.what() << '\n';
    return;
  }

  // convert size units to meters
  if (unit == "foot" || unit == "feet") {
    width = units::convert<units::feet, units::meters>(width);
    height = units::convert<units::feet, units::meters>(height);
  }

  // the image filename is relative to the json file
  wpi::SmallString<128> pathname;
  jsonfile.toVector(pathname);
  wpi::sys::path::remove_filename(pathname);
  wpi::sys::path::append(pathname, image);

  // load field image
  if (!LoadImageImpl(pathname)) return;

  // save to field info
  m_filename = pathname.str();
  m_top = top;
  m_left = left;
  m_bottom = bottom;
  m_right = right;
  m_width = width;
  m_height = height;
}

bool FieldInfo::LoadImageImpl(const wpi::Twine& fn) {
  wpi::outs() << "GUI: loading field image '" << fn << "'\n";
  GLuint oldTexture = m_texture;
  if (!LoadTextureFromFile(fn, &m_texture, &m_imageWidth, &m_imageHeight)) {
    wpi::errs() << "GUI: could not read field image\n";
    return false;
  }
  if (oldTexture != 0) glDeleteTextures(1, &oldTexture);
  m_filename = fn.str();
  return true;
}

FieldFrameData FieldInfo::GetFrameData() const {
  FieldFrameData ffd;

  // get window content region
  ffd.imageMin = ImGui::GetWindowContentRegionMin();
  ffd.imageMax = ImGui::GetWindowContentRegionMax();

  // fit the image into the window
  if (m_texture != 0 && m_imageHeight != 0 && m_imageWidth != 0)
    MaxFit(&ffd.imageMin, &ffd.imageMax, m_imageWidth, m_imageHeight);

  ImVec2 min = ffd.imageMin;
  ImVec2 max = ffd.imageMax;

  // size down the box by the image corners (if any)
  if (m_bottom > 0 && m_right > 0) {
    min.x += m_left * (max.x - min.x) / m_imageWidth;
    min.y += m_top * (max.y - min.y) / m_imageHeight;
    max.x -= (m_imageWidth - m_right) * (max.x - min.x) / m_imageWidth;
    max.y -= (m_imageHeight - m_bottom) * (max.y - min.y) / m_imageHeight;
  }

  // draw the field "active area" as a yellow boundary box
  MaxFit(&min, &max, m_width, m_height);

  ffd.min = min;
  ffd.max = max;
  ffd.scale = (max.x - min.x) / m_width;
  return ffd;
}

void FieldInfo::Draw(ImDrawList* drawList, const ImVec2& windowPos,
                     const FieldFrameData& ffd) const {
  if (m_texture != 0 && m_imageHeight != 0 && m_imageWidth != 0) {
    drawList->AddImage(
        reinterpret_cast<ImTextureID>(static_cast<uintptr_t>(m_texture)),
        windowPos + ffd.imageMin, windowPos + ffd.imageMax);
  }

  // draw the field "active area" as a yellow boundary box
  drawList->AddRect(windowPos + ffd.min, windowPos + ffd.max,
                    IM_COL32(255, 255, 0, 255));
}

bool FieldInfo::ReadIni(wpi::StringRef name, wpi::StringRef value) {
  if (name == "image") {
    m_filename = value;
  } else if (name == "top") {
    int num;
    if (value.getAsInteger(10, num)) return true;
    m_top = num;
  } else if (name == "left") {
    int num;
    if (value.getAsInteger(10, num)) return true;
    m_left = num;
  } else if (name == "bottom") {
    int num;
    if (value.getAsInteger(10, num)) return true;
    m_bottom = num;
  } else if (name == "right") {
    int num;
    if (value.getAsInteger(10, num)) return true;
    m_right = num;
  } else if (name == "width") {
    std::sscanf(value.data(), "%f", &m_width);
  } else if (name == "height") {
    std::sscanf(value.data(), "%f", &m_height);
  } else {
    return false;
  }
  return true;
}

void FieldInfo::WriteIni(ImGuiTextBuffer* out) const {
  out->appendf(
      "[Field2D][Field]\nimage=%s\ntop=%d\nleft=%d\nbottom=%d\nright=%d\nwidth="
      "%f\nheight=%f\n\n",
      m_filename.c_str(), m_top, m_left, m_bottom, m_right, m_width, m_height);
}

void RobotInfo::Reset() {
  if (m_texture != 0) glDeleteTextures(1, &m_texture);
  m_texture = 0;
  m_filename.clear();
}

void RobotInfo::LoadImage() {
  if (m_fileOpener && m_fileOpener->ready(0)) {
    auto result = m_fileOpener->result();
    if (!result.empty()) LoadImageImpl(result[0]);
    m_fileOpener.reset();
  }
  if (m_texture == 0 && !m_filename.empty()) {
    if (!LoadImageImpl(m_filename)) m_filename.clear();
  }
}

bool RobotInfo::LoadImageImpl(const wpi::Twine& fn) {
  wpi::outs() << "GUI: loading robot image '" << fn << "'\n";
  GLuint oldTexture = m_texture;
  if (!LoadTextureFromFile(fn, &m_texture, nullptr, nullptr)) {
    wpi::errs() << "GUI: could not read robot image\n";
    return false;
  }
  if (oldTexture != 0) glDeleteTextures(1, &oldTexture);
  m_filename = fn.str();
  return true;
}

void RobotInfo::UpdateFromSimDevice() {
  if (m_devHandle == 0) m_devHandle = HALSIM_GetSimDeviceHandle("Field2D");
  if (m_devHandle == 0) return;

  if (!m_xHandle) m_xHandle = HALSIM_GetSimValueHandle(m_devHandle, "x");
  if (m_xHandle) m_x = m_xHandle.Get();

  if (!m_yHandle) m_yHandle = HALSIM_GetSimValueHandle(m_devHandle, "y");
  if (m_yHandle) m_y = m_yHandle.Get();

  if (!m_rotHandle) m_rotHandle = HALSIM_GetSimValueHandle(m_devHandle, "rot");
  if (m_rotHandle) m_rot = m_rotHandle.Get();
}

void RobotInfo::SetPosition(double x, double y) {
  m_x = x;
  m_y = y;
  if (m_xHandle) m_xHandle.Set(x);
  if (m_yHandle) m_yHandle.Set(y);
}

void RobotInfo::SetRotation(double rot) {
  double rotDegrees = units::convert<units::radians, units::degrees>(rot);
  // force to -180 to +180 range
  rotDegrees = rotDegrees + std::ceil((-rotDegrees - 180) / 360) * 360;
  m_rot = rotDegrees;
  if (m_rotHandle) m_rotHandle.Set(rotDegrees);
}

RobotFrameData RobotInfo::GetFrameData(const FieldFrameData& ffd) const {
  RobotFrameData rfd;
  float width2 = ffd.scale * m_width / 2;
  float length2 = ffd.scale * m_length / 2;

  // (0,0) origin is bottom left
  ImVec2 center(ffd.min.x + ffd.scale * m_x, ffd.max.y - ffd.scale * m_y);

  // build rotated points around center
  double rot = GetRotation();
  float cos_a = std::cos(-rot);
  float sin_a = std::sin(-rot);

  rfd.corners[0] = center + ImRotate(ImVec2(-length2, -width2), cos_a, sin_a);
  rfd.corners[1] = center + ImRotate(ImVec2(length2, -width2), cos_a, sin_a);
  rfd.corners[2] = center + ImRotate(ImVec2(length2, width2), cos_a, sin_a);
  rfd.corners[3] = center + ImRotate(ImVec2(-length2, width2), cos_a, sin_a);
  rfd.arrow[0] =
      center + ImRotate(ImVec2(-length2 / 2, -width2 / 2), cos_a, sin_a);
  rfd.arrow[1] = center + ImRotate(ImVec2(length2 / 2, 0), cos_a, sin_a);
  rfd.arrow[2] =
      center + ImRotate(ImVec2(-length2 / 2, width2 / 2), cos_a, sin_a);

  rfd.center = center;
  rfd.width2 = width2;
  rfd.length2 = length2;
  return rfd;
}

void RobotInfo::Draw(ImDrawList* drawList, const ImVec2& windowPos,
                     const RobotFrameData& rfd, int hit,
                     float hitRadius) const {
  if (m_texture != 0) {
    drawList->AddImageQuad(
        reinterpret_cast<ImTextureID>(static_cast<uintptr_t>(m_texture)),
        windowPos + rfd.corners[0], windowPos + rfd.corners[1],
        windowPos + rfd.corners[2], windowPos + rfd.corners[3]);
  } else {
    drawList->AddQuad(windowPos + rfd.corners[0], windowPos + rfd.corners[1],
                      windowPos + rfd.corners[2], windowPos + rfd.corners[3],
                      IM_COL32(255, 0, 0, 255), 4.0);
    drawList->AddTriangle(windowPos + rfd.arrow[0], windowPos + rfd.arrow[1],
                          windowPos + rfd.arrow[2], IM_COL32(0, 255, 0, 255),
                          4.0);
  }

  if (hit > 0) {
    if (hit == 1) {
      drawList->AddCircle(windowPos + rfd.center, hitRadius,
                          IM_COL32(0, 255, 0, 255));
    } else {
      drawList->AddCircle(windowPos + rfd.corners[hit - 2], hitRadius,
                          IM_COL32(0, 255, 0, 255));
    }
  }
}

bool RobotInfo::ReadIni(wpi::StringRef name, wpi::StringRef value) {
  if (name == "image") {
    m_filename = value;
  } else if (name == "width") {
    std::sscanf(value.data(), "%f", &m_width);
  } else if (name == "length") {
    std::sscanf(value.data(), "%f", &m_length);
  } else {
    return false;
  }
  return true;
}

void RobotInfo::WriteIni(ImGuiTextBuffer* out) const {
  out->appendf("[Field2D][Robot]\nimage=%s\nwidth=%f\nlength=%f\n\n",
               m_filename.c_str(), m_width, m_length);
}

static void OptionMenuField2D() {
  if (ImGui::BeginMenu("2D Field View")) {
    if (ImGui::MenuItem("Choose field image...")) {
      gField.m_fileOpener = std::make_unique<pfd::open_file>(
          "Choose field image", "",
          std::vector<std::string>{"Image File",
                                   "*.jpg *.jpeg *.png *.bmp *.psd *.tga *.gif "
                                   "*.hdr *.pic *.ppm *.pgm",
                                   "PathWeaver JSON File", "*.json"});
    }
    if (ImGui::MenuItem("Reset field image")) {
      gField.Reset();
    }
    if (ImGui::MenuItem("Choose robot image...")) {
      gRobot.m_fileOpener = std::make_unique<pfd::open_file>(
          "Choose robot image", "",
          std::vector<std::string>{"Image File",
                                   "*.jpg *.jpeg *.png *.bmp *.psd *.tga *.gif "
                                   "*.hdr *.pic *.ppm *.pgm"});
    }
    if (ImGui::MenuItem("Reset robot image")) {
      gRobot.Reset();
    }
    ImGui::EndMenu();
  }
}

static void DisplayField2DSettings() {
  ImGui::PushItemWidth(ImGui::GetFontSize() * 4);
  ImGui::InputFloat("Field Width", &gField.m_width);
  ImGui::InputFloat("Field Height", &gField.m_height);
  // ImGui::InputInt("Field Top", &gField.m_top);
  // ImGui::InputInt("Field Left", &gField.m_left);
  // ImGui::InputInt("Field Right", &gField.m_right);
  // ImGui::InputInt("Field Bottom", &gField.m_bottom);
  ImGui::InputFloat("Robot Width", &gRobot.m_width);
  ImGui::InputFloat("Robot Length", &gRobot.m_length);
  ImGui::PopItemWidth();
}

static void DisplayField2D() {
  // load images
  gField.LoadImage();
  gRobot.LoadImage();

  // get robot coordinates from SimDevice
  gRobot.UpdateFromSimDevice();

  FieldFrameData ffd = gField.GetFrameData();
  RobotFrameData rfd = gRobot.GetFrameData(ffd);

  ImVec2 windowPos = ImGui::GetWindowPos();

  // for dragging to work, there needs to be a button (otherwise the window is
  // dragged)
  ImGui::InvisibleButton("field", ImGui::GetContentRegionAvail());

  // allow dragging the robot around
  ImVec2 cursor = ImGui::GetIO().MousePos - windowPos;

  int hit = 0;
  float hitRadius = (std::min)(rfd.width2, rfd.length2) / 2;
  // only allow initiation of dragging when invisible button is hovered; this
  // prevents the window resize handles from simultaneously activating the drag
  // functionality
  if (ImGui::IsItemHovered()) {
    float hitRadiusSquared = hitRadius * hitRadius;
    // it's within the hit radius of the center?
    if (GetDistSquared(cursor, rfd.center) < hitRadiusSquared)
      hit = 1;
    else if (GetDistSquared(cursor, rfd.corners[0]) < hitRadiusSquared)
      hit = 2;
    else if (GetDistSquared(cursor, rfd.corners[1]) < hitRadiusSquared)
      hit = 3;
    else if (GetDistSquared(cursor, rfd.corners[2]) < hitRadiusSquared)
      hit = 4;
    else if (GetDistSquared(cursor, rfd.corners[3]) < hitRadiusSquared)
      hit = 5;
    if (hit > 0 && ImGui::IsMouseClicked(0)) {
      if (hit == 1) {
        gDragRobot = hit;
        gDragInitialOffset = cursor - rfd.center;
      } else {
        gDragRobot = hit;
        ImVec2 off = cursor - rfd.center;
        gDragInitialAngle = std::atan2(off.y, off.x) + gRobot.GetRotation();
      }
    }
  }

  if (gDragRobot > 0 && ImGui::IsMouseDown(0)) {
    if (gDragRobot == 1) {
      ImVec2 newPos = cursor - gDragInitialOffset;
      gRobot.SetPosition(
          (std::clamp(newPos.x, ffd.min.x, ffd.max.x) - ffd.min.x) / ffd.scale,
          (ffd.max.y - std::clamp(newPos.y, ffd.min.y, ffd.max.y)) / ffd.scale);
      rfd = gRobot.GetFrameData(ffd);
    } else {
      ImVec2 off = cursor - rfd.center;
      gRobot.SetRotation(gDragInitialAngle - std::atan2(off.y, off.x));
    }
    hit = gDragRobot;  // keep it highlighted
  } else {
    gDragRobot = 0;
  }

  // draw
  auto drawList = ImGui::GetWindowDrawList();
  gField.Draw(drawList, windowPos, ffd);
  gRobot.Draw(drawList, windowPos, rfd, hit, hitRadius);
}

void Field2D::Initialize() {
  // hook ini handler to save settings
  ImGuiSettingsHandler iniHandler;
  iniHandler.TypeName = "Field2D";
  iniHandler.TypeHash = ImHashStr(iniHandler.TypeName);
  iniHandler.ReadOpenFn = Field2DReadOpen;
  iniHandler.ReadLineFn = Field2DReadLine;
  iniHandler.WriteAllFn = Field2DWriteAll;
  ImGui::GetCurrentContext()->SettingsHandlers.push_back(iniHandler);

  HALSimGui::AddOptionMenu(OptionMenuField2D);

  HALSimGui::AddWindow("2D Field Settings", DisplayField2DSettings,
                       ImGuiWindowFlags_AlwaysAutoResize);
  HALSimGui::SetWindowVisibility("2D Field Settings", HALSimGui::kHide);
  HALSimGui::SetDefaultWindowPos("2D Field Settings", 200, 150);

  HALSimGui::AddWindow("2D Field View", DisplayField2D);
  HALSimGui::SetWindowVisibility("2D Field View", HALSimGui::kHide);
  HALSimGui::SetDefaultWindowPos("2D Field View", 200, 200);
  HALSimGui::SetDefaultWindowSize("2D Field View", 400, 200);
  HALSimGui::SetWindowPadding("2D Field View", 0, 0);

  // SimDeviceGui::Hide("Field2D");
}
