#include "media/ad_detective.hpp"

#include <algorithm>
#include <cmath>
#include <cstdint>
#include <iomanip>
#include <limits>
#include <sstream>
#include <vector>

#include "media/ffmpeg_raii.hpp"
#include "media/hardware_decode.hpp"
#include "media/video_frame_index.hpp"

#ifdef SMARTCUT_HAS_FFMPEG
extern "C" {
#include <libavutil/pixfmt.h>
#include <libswscale/swscale.h>
}
#endif

namespace smartcut::media {
namespace {

#ifdef SMARTCUT_HAS_FFMPEG
struct ScanResult {
  std::vector<AdFrameStatistics> frames;
  std::size_t decoded{};
  std::size_t skipped{};
  std::string decode_backend{"software"};
  std::string error;
};

char picture_type_name(AVPictureType type) {
  if (type == AV_PICTURE_TYPE_I) return 'I';
  if (type == AV_PICTURE_TYPE_P) return 'P';
  if (type == AV_PICTURE_TYPE_B) return 'B';
  return '?';
}

ScanResult scan_statistics(const std::filesystem::path& path, std::size_t first_frame,
                           std::size_t end_frame, const AdDetectiveParameters& parameters,
                           const AdProgress& progress, bool allow_hardware) {
  ScanResult result;
  auto format = ffmpeg::open_input(path);
  if (!format || avformat_find_stream_info(format.get(), nullptr) < 0) {
    result.error = "input failed";
    return result;
  }
  int video_stream = -1;
  for (unsigned index = 0; index < format->nb_streams; ++index)
    if (format->streams[index]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
      video_stream = static_cast<int>(index);
      break;
    }
  if (video_stream < 0) {
    result.error = "video stream missing";
    return result;
  }
  auto* stream = format->streams[video_stream];
  const auto* software_decoder = ffmpeg::preferred_video_decoder(stream->codecpar->codec_id);
  HardwareVideoDecoder decoder;
  if (!decoder.open(stream->codecpar, stream->codecpar->codec_id,
                    software_decoder, allow_hardware)) {
    result.error = "decoder failed";
    return result;
  }

  const auto target_pts = first_frame > 0 ? indexed_frame_pts(path.string(), first_frame)
                                           : std::optional<std::int64_t>{};
  if (target_pts && av_seek_frame(format.get(), video_stream, *target_pts, AVSEEK_FLAG_BACKWARD) >= 0)
    avcodec_flush_buffers(decoder.context());

  constexpr int analysis_width = 160;
  constexpr int analysis_height = 90;
  std::vector<std::uint8_t> gray(static_cast<std::size_t>(analysis_width * analysis_height));
  std::vector<std::uint8_t> previous;
  auto packet = ffmpeg::make_packet();
  auto frame = ffmpeg::make_frame();
  auto gpu_scaled = ffmpeg::make_frame();
  auto transferred = ffmpeg::make_frame();
  HardwareFrameScaler hardware_scaler;
  bool hardware_scaling_used = false;
  SwsContext* scaler = nullptr;
  std::size_t frame_index = first_frame;
  const int left = std::clamp(static_cast<int>(std::lround(parameters.region.left * analysis_width)), 0, analysis_width - 1);
  const int right = std::clamp(analysis_width - static_cast<int>(std::lround(parameters.region.right * analysis_width)), left + 1, analysis_width);
  const int top = std::clamp(static_cast<int>(std::lround(parameters.region.top * analysis_height)), 0, analysis_height - 1);
  const int bottom = std::clamp(analysis_height - static_cast<int>(std::lround(parameters.region.bottom * analysis_height)), top + 1, analysis_height);

  auto receive = [&] {
    while (result.error.empty() && avcodec_receive_frame(decoder.context(), frame.get()) >= 0) {
      const auto pts = frame->best_effort_timestamp != AV_NOPTS_VALUE ? frame->best_effort_timestamp : frame->pts;
      if (target_pts && (pts == AV_NOPTS_VALUE || pts < *target_pts)) {
        av_frame_unref(frame.get());
        continue;
      }
      if (end_frame != std::numeric_limits<std::size_t>::max() && frame_index >= end_frame) {
        av_frame_unref(frame.get());
        return;
      }
      ++result.decoded;
      const auto picture_type = picture_type_name(frame->pict_type);
      if (parameters.i_and_p_frames_only && picture_type != 'I' && picture_type != 'P') {
        ++result.skipped;
        ++frame_index;
        av_frame_unref(frame.get());
        continue;
      }
      const AVFrame* conversion_source = frame.get();
      if (decoder.is_hardware_frame(frame.get())) {
        if (const auto* scaled = hardware_scaler.scale(frame.get(), gpu_scaled.get(),
                                                       analysis_width, analysis_height)) {
          conversion_source = scaled;
          hardware_scaling_used = true;
        }
      }
      const auto* cpu = decoder.cpu_frame(conversion_source, transferred.get());
      if (!cpu) {
        result.error = "hardware frame transfer failed";
        av_frame_unref(frame.get());
        return;
      }
      scaler = sws_getCachedContext(scaler, cpu->width, cpu->height,
                                    static_cast<AVPixelFormat>(cpu->format), analysis_width,
                                    analysis_height, AV_PIX_FMT_GRAY8, SWS_BILINEAR,
                                    nullptr, nullptr, nullptr);
      if (!scaler) {
        ++frame_index;
        av_frame_unref(gpu_scaled.get());
        av_frame_unref(frame.get());
        continue;
      }
      std::uint8_t* destination[] = {gray.data(), nullptr, nullptr, nullptr};
      int destination_lines[] = {analysis_width, 0, 0, 0};
      sws_scale(scaler, cpu->data, cpu->linesize, 0, cpu->height,
                destination, destination_lines);
      std::uint64_t sum = 0;
      std::uint64_t difference = 0;
      std::uint8_t minimum = 255;
      std::uint8_t maximum = 0;
      std::size_t pixels = 0;
      for (int y = top; y < bottom; ++y)
        for (int x = left; x < right; ++x) {
          const auto offset = static_cast<std::size_t>(y * analysis_width + x);
          const auto value = gray[offset];
          sum += value;
          minimum = std::min(minimum, value);
          maximum = std::max(maximum, value);
          if (!previous.empty()) difference += static_cast<std::uint64_t>(
              std::abs(static_cast<int>(value) - static_cast<int>(previous[offset])));
          ++pixels;
        }
      AdFrameStatistics statistics;
      statistics.frame = frame_index;
      statistics.pts = pts == AV_NOPTS_VALUE ? 0 : pts;
      statistics.average = static_cast<double>(sum) / (static_cast<double>(pixels) * 255.0);
      statistics.peak = static_cast<double>(maximum) / 255.0;
      statistics.contrast = static_cast<double>(maximum - minimum) / 255.0;
      statistics.scene_score = previous.empty() ? 0.0 :
          static_cast<double>(difference) / (static_cast<double>(pixels) * 255.0);
      statistics.black = statistics.average <= std::clamp(parameters.average_black_level, 0.0, 1.0) &&
                         statistics.peak <= std::clamp(parameters.peak_black_level, 0.0, 1.0) &&
                         statistics.contrast <= std::clamp(parameters.contrast_threshold, 0.0, 1.0);
      statistics.picture_type = picture_type;
      result.frames.push_back(statistics);
      previous = gray;
      if (progress && (result.frames.size() == 1 || result.frames.size() % 25 == 0)) progress(statistics);
      ++frame_index;
      av_frame_unref(gpu_scaled.get());
      av_frame_unref(frame.get());
    }
  };
  bool done = false;
  while (!done && result.error.empty() && av_read_frame(format.get(), packet.get()) >= 0) {
    if (packet->stream_index == video_stream && avcodec_send_packet(decoder.context(), packet.get()) >= 0) {
      receive();
      done = end_frame != std::numeric_limits<std::size_t>::max() && frame_index >= end_frame;
    }
    av_packet_unref(packet.get());
  }
  if (!done && result.error.empty()) {
    avcodec_send_packet(decoder.context(), nullptr);
    receive();
  }
  result.decode_backend = decoder.hardware_active()
      ? (hardware_scaling_used ? decoder.backend_name() + "+" + hardware_scaler.backend_name()
                               : decoder.backend_name())
      : "software";
  if (progress && !result.frames.empty()) progress(result.frames.back());
  sws_freeContext(scaler);
  return result;
}

double percentile(std::vector<double> values, double fraction) {
  if (values.empty()) return 0.0;
  std::sort(values.begin(), values.end());
  const auto index = std::min(values.size() - 1,
      static_cast<std::size_t>(std::lround(std::clamp(fraction, 0.0, 1.0) * (values.size() - 1))));
  return values[index];
}
#endif

}  // namespace

std::string detect_ad_transitions(const std::filesystem::path& path, std::size_t start_frame,
                                  const AdDetectiveParameters& parameters,
                                  const AdProgress& progress) {
#ifndef SMARTCUT_HAS_FFMPEG
  (void)path; (void)start_frame; (void)parameters; (void)progress;
  return "{\"schema\":\"ad-detective-v2\",\"status\":\"FAILED\",\"error\":\"FFmpeg unavailable\"}";
#else
  auto scan = scan_statistics(path, start_frame, std::numeric_limits<std::size_t>::max(),
                              parameters, progress, true);
  if (!scan.error.empty() || scan.decoded == 0)
    scan = scan_statistics(path, start_frame, std::numeric_limits<std::size_t>::max(),
                           parameters, progress, false);
  if (!scan.error.empty())
    return "{\"schema\":\"ad-detective-v2\",\"status\":\"FAILED\",\"error\":\"" + scan.error + "\"}";
  std::vector<AdFrameStatistics> markers;
  bool have_previous = false;
  bool previous_black = false;
  std::size_t last_marker = 0;
  for (const auto& frame : scan.frames) {
    const bool black_transition = parameters.detect_black_transitions && have_previous && frame.black != previous_black;
    const bool scene_transition = have_previous && frame.scene_score >=
        std::clamp(parameters.scene_threshold, 0.01, 1.0);
    if ((black_transition || scene_transition) &&
        (markers.empty() || frame.frame >= last_marker + std::max<std::size_t>(parameters.minimum_frame_spacing, 1))) {
      markers.push_back(frame);
      last_marker = frame.frame;
    }
    previous_black = frame.black;
    have_previous = true;
  }
  std::ostringstream output;
  output << "{\"schema\":\"ad-detective-v2\",\"status\":\""
         << (scan.decoded > 0 ? "PASSED" : "FAILED") << "\",\"start_frame\":" << start_frame
         << ",\"decode_backend\":\"" << scan.decode_backend << "\",\"scanned_frames\":" << scan.decoded << ",\"analyzed_frames\":" << scan.frames.size()
         << ",\"skipped_frames\":" << scan.skipped << ",\"parameters\":{\"scene_threshold\":"
         << std::fixed << std::setprecision(5) << parameters.scene_threshold
         << ",\"black_detection\":" << (parameters.detect_black_transitions ? "true" : "false")
         << ",\"average_black_level\":" << parameters.average_black_level
         << ",\"peak_black_level\":" << parameters.peak_black_level
         << ",\"contrast_threshold\":" << parameters.contrast_threshold
         << ",\"minimum_frame_spacing\":" << parameters.minimum_frame_spacing
         << ",\"i_and_p_frames_only\":" << (parameters.i_and_p_frames_only ? "true" : "false")
         << ",\"region\":{\"left\":" << parameters.region.left << ",\"top\":" << parameters.region.top
         << ",\"right\":" << parameters.region.right << ",\"bottom\":" << parameters.region.bottom
         << "}},\"markers\":[";
  for (std::size_t index = 0; index < markers.size(); ++index) {
    if (index) output << ',';
    const auto& marker = markers[index];
    output << "{\"frame\":" << marker.frame << ",\"pts\":" << marker.pts
           << ",\"score\":" << marker.scene_score << ",\"average\":" << marker.average
           << ",\"peak\":" << marker.peak << ",\"contrast\":" << marker.contrast
           << ",\"black\":" << (marker.black ? "true" : "false")
           << ",\"picture_type\":\"" << marker.picture_type << "\"}";
  }
  output << "]}";
  return output.str();
#endif
}

std::string calibrate_ad_thresholds(const std::filesystem::path& path, std::size_t first_frame,
                                    std::size_t end_frame, const AdScanRegion& region,
                                    bool i_and_p_frames_only) {
#ifndef SMARTCUT_HAS_FFMPEG
  (void)path; (void)first_frame; (void)end_frame; (void)region; (void)i_and_p_frames_only;
  return "{\"schema\":\"ad-threshold-calibration-v1\",\"status\":\"FAILED\"}";
#else
  if (end_frame <= first_frame)
    return "{\"schema\":\"ad-threshold-calibration-v1\",\"status\":\"FAILED\",\"error\":\"selection is empty\"}";
  AdDetectiveParameters parameters;
  parameters.region = region;
  parameters.i_and_p_frames_only = i_and_p_frames_only;
  auto scan = scan_statistics(path, first_frame, end_frame, parameters, {}, true);
  if (!scan.error.empty() || scan.decoded == 0)
    scan = scan_statistics(path, first_frame, end_frame, parameters, {}, false);
  if (!scan.error.empty() || scan.frames.empty())
    return "{\"schema\":\"ad-threshold-calibration-v1\",\"status\":\"FAILED\",\"error\":\"analysis failed\"}";
  std::vector<double> averages, peaks, contrasts;
  for (const auto& frame : scan.frames) {
    averages.push_back(frame.average);
    peaks.push_back(frame.peak);
    contrasts.push_back(frame.contrast);
  }
  const auto average = std::clamp(percentile(averages, 0.90) + 0.01, 0.0, 1.0);
  const auto peak = std::clamp(percentile(peaks, 0.90) + 0.02, 0.0, 1.0);
  const auto contrast = std::clamp(percentile(contrasts, 0.90) + 0.02, 0.0, 1.0);
  std::ostringstream output;
  output << "{\"schema\":\"ad-threshold-calibration-v1\",\"status\":\"PASSED\",\"first_frame\":"
         << first_frame << ",\"end_frame_exclusive\":" << end_frame << ",\"sampled_frames\":"
         << scan.frames.size() << ",\"average_black_level\":" << std::fixed << std::setprecision(5)
         << average << ",\"peak_black_level\":" << peak << ",\"contrast_threshold\":" << contrast << '}';
  return output.str();
#endif
}

std::string detect_ad_transitions(const std::filesystem::path& path, std::size_t start_frame,
                                  double scene_threshold, double black_level,
                                  std::size_t minimum_frame_spacing,
                                  const std::function<void(std::size_t)>& progress) {
  AdDetectiveParameters parameters;
  parameters.scene_threshold = scene_threshold;
  parameters.average_black_level = black_level;
  parameters.peak_black_level = 1.0;
  parameters.contrast_threshold = 1.0;
  parameters.minimum_frame_spacing = minimum_frame_spacing;
  return detect_ad_transitions(path, start_frame, parameters,
      progress ? AdProgress([progress](const AdFrameStatistics& statistics) { progress(statistics.frame); })
               : AdProgress{});
}

}  // namespace smartcut::media
