#include <Gosu/Graphics.hpp>
#include "DrawOp.hpp"
#include "DrawOpQueue.hpp"
#include "GraphicsImpl.hpp"
#include "LargeImageData.hpp"
#include "Macro.hpp"
#include "TexChunk.hpp"
#include "Texture.hpp"
#include <Gosu/Bitmap.hpp>
#include <Gosu/Image.hpp>
#include <Gosu/Platform.hpp>
#include <cmath>
#include <algorithm>
#include <functional>
#include <memory>
using namespace std;

namespace Gosu
{
    namespace
    {
        Graphics* current_graphics_pointer = nullptr;
        
        Graphics& current_graphics()
        {
            if (current_graphics_pointer == nullptr) {
                throw logic_error("Gosu::Graphics can only be drawn to while rendering");
            }
            
            return *current_graphics_pointer;
        }
        
        vector<shared_ptr<Texture>> textures;
        
        DrawOpQueueStack queues;
        
        DrawOpQueue& current_queue()
        {
            if (queues.empty()) {
                throw logic_error("There is no rendering queue for this operation");
            }
            return queues.back();
        }
    }
}

struct Gosu::Graphics::Impl
{
    unsigned virt_width, virt_height;
    unsigned phys_width, phys_height;
    double black_width, black_height;
    Transform base_transform;
    
    DrawOpQueueStack warmed_up_queues;

    void update_base_transform()
    {
        double scale_x = 1.0 * phys_width / virt_width;
        double scale_y = 1.0 * phys_height / virt_height;
        double scale_factor = min(scale_x, scale_y);

        Transform scale_transform = scale(scale_factor);
        Transform translate_transform = translate(black_width, black_height);
        base_transform = concat(translate_transform, scale_transform);
    }
    
#ifndef GOSU_IS_OPENGLES
    void begin_gl()
    {
        glPushAttrib(GL_ALL_ATTRIB_BITS);
        glDisable(GL_BLEND);
        // Reset the colour to white to avoid surprises.
        // https://www.libgosu.org/cgi-bin/mwf/topic_show.pl?pid=9115#pid9115
        glColor4ubv(reinterpret_cast<const GLubyte*>(&Color::WHITE));
        while (glGetError() != GL_NO_ERROR);
    }
    
    void end_gl()
    {
        glPopAttrib();
        
        // Restore matrices.
        // TODO: Should be merged into RenderState and removed from Graphics.
        
        glMatrixMode(GL_PROJECTION);
        glLoadIdentity();
        glViewport(0, 0, phys_width, phys_height);
        glOrtho(0, phys_width, phys_height, 0, -1, 1);
        
        glMatrixMode(GL_MODELVIEW);
        glLoadIdentity();
        glEnable(GL_BLEND);
    }
#endif
};

Gosu::Graphics::Graphics(unsigned phys_width, unsigned phys_height)
: pimpl(new Impl)
{
    pimpl->virt_width   = phys_width;
    pimpl->virt_height  = phys_height;
    pimpl->black_width  = 0;
    pimpl->black_height = 0;

    // TODO: Should be merged into RenderState and removed from Graphics.
    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();
    glEnable(GL_BLEND);

    set_physical_resolution(phys_width, phys_height);
}

Gosu::Graphics::~Graphics()
{
    if (current_graphics_pointer == this) {
        current_graphics_pointer = nullptr;
    }
}

unsigned Gosu::Graphics::width() const
{
    return pimpl->virt_width;
}

unsigned Gosu::Graphics::height() const
{
    return pimpl->virt_height;
}

void Gosu::Graphics::set_resolution(unsigned virtual_width, unsigned virtual_height,
    double horizontal_black_bar_width, double vertical_black_bar_height)
{
    if (virtual_width == 0 || virtual_height == 0) {
        throw invalid_argument("Invalid virtual resolution.");
    }
    
    pimpl->virt_width   = virtual_width;
    pimpl->virt_height  = virtual_height;
    pimpl->black_width  = horizontal_black_bar_width;
    pimpl->black_height = vertical_black_bar_height;

    pimpl->update_base_transform();
}

void Gosu::Graphics::frame(const function<void ()>& f)
{
    if (current_graphics_pointer != nullptr) {
        throw logic_error("Cannot nest calls to Gosu::Graphics::begin()");
    }
    
    // Cancel all recording or whatever that might still be in progress...
    queues.clear();
    
    if (pimpl->warmed_up_queues.size() == 1) {
        // If we already have a "warmed up" queue, use that instead.
        // -> All internal std::vectors will already have a lot of capacity.
        // This helps reduce allocations during normal operation.
        queues.clear();
        queues.swap(pimpl->warmed_up_queues);
    }
    else {
        // Create default draw-op queue.
        queues.resize(1);
    }
    
    queues.back().set_base_transform(pimpl->base_transform);
    
    glClearColor(0, 0, 0, 1);
    glClear(GL_COLOR_BUFFER_BIT);
    
    current_graphics_pointer = this;
    
    f();
    
    // If recording is in process, cancel it.
    while (current_queue().recording()) {
        queues.pop_back();
    }
    
    flush();
    
    if (pimpl->black_height || pimpl->black_width) {
        if (pimpl->black_height) {
            draw_quad(0, -pimpl->black_height, Color::BLACK,
                     width(), -pimpl->black_height, Color::BLACK,
                     0, 0, Color::BLACK,
                     width(), 0, Color::BLACK, 0);
            draw_quad(0, height(), Color::BLACK,
                     width(), height(), Color::BLACK,
                     0, height() + pimpl->black_height, Color::BLACK,
                     width(), height() + pimpl->black_height, Color::BLACK, 0);
        }
        if (pimpl->black_width) {
            draw_quad(-pimpl->black_width, 0, Color::BLACK,
                     0, 0, Color::BLACK,
                     -pimpl->black_width, height(), Color::BLACK,
                     0, height(), Color::BLACK, 0);
            draw_quad(width(), 0, Color::BLACK,
                     width() + pimpl->black_width, 0, Color::BLACK,
                     width(), height(), Color::BLACK,
                     width() + pimpl->black_width, height(), Color::BLACK, 0);
        }
        flush();
    }
    
    glFlush();
    
    current_graphics_pointer = nullptr;
    
    // Clear leftover transforms, clip rects etc.
    if (queues.size() == 1) {
        queues.swap(pimpl->warmed_up_queues);
        pimpl->warmed_up_queues.back().reset();
    }
    else {
        queues.clear();
    }
}

void Gosu::Graphics::flush()
{
    current_queue().perform_draw_ops_andCode();
    current_queue().clear_queue();
}

void Gosu::Graphics::gl(const function<void ()>& f)
{
    if (current_queue().recording()) {
        throw logic_error("Custom OpenGL is not allowed while creating a macro");
    }
    
#ifdef GOSU_IS_OPENGLES
    throw logic_error("Custom OpenGL ES is not supported yet");
#else
    Graphics& cg = current_graphics();

    flush();
    
    cg.pimpl->begin_gl();

    f();

    cg.pimpl->end_gl();
#endif
}

void Gosu::Graphics::gl(Gosu::ZPos z, const function<void ()>& f)
{
#ifdef GOSU_IS_OPENGLES
    throw logic_error("Custom OpenGL ES is not supported yet");
#else
    current_queue().gl([f] {
        Graphics& cg = current_graphics();
        cg.pimpl->begin_gl();
        f();
        cg.pimpl->end_gl();
    }, z);
#endif
}

void Gosu::Graphics::clip_to(double x, double y, double width, double height,
                             const function<void ()>& f)
{
    double screen_height = current_graphics().pimpl->phys_height;
    current_queue().begin_clipping(x, y, width, height, screen_height);
    f();
    current_queue().end_clipping();
}

unique_ptr<Gosu::ImageData> Gosu::Graphics::record(int width, int height,
                                                   const function<void ()>& f)
{
    queues.resize(queues.size() + 1);
    current_queue().set_recording();

    f();
    
    unique_ptr<ImageData> result(new Macro(current_queue(), width, height));
    queues.pop_back();
    return result;
}

void Gosu::Graphics::transform(const Gosu::Transform& transform, const function<void ()>& f)
{
    current_queue().push_transform(transform);
    f();
    current_queue().pop_transform();
}

void Gosu::Graphics::draw_line(double x1, double y1, Color c1,
    double x2, double y2, Color c2, ZPos z, AlphaMode mode)
{
    DrawOp op;
    op.render_state.mode = mode;
    op.vertices_or_block_index = 2;
    op.vertices[0] = DrawOp::Vertex(x1, y1, c1);
    op.vertices[1] = DrawOp::Vertex(x2, y2, c2);
    op.z = z;
    
    current_queue().schedule_draw_op(op);
}

void Gosu::Graphics::draw_triangle(double x1, double y1, Color c1, double x2, double y2, Color c2,
    double x3, double y3, Color c3, ZPos z, AlphaMode mode)
{
    DrawOp op;
    op.render_state.mode = mode;
    op.vertices_or_block_index = 3;
    op.vertices[0] = DrawOp::Vertex(x1, y1, c1);
    op.vertices[1] = DrawOp::Vertex(x2, y2, c2);
    op.vertices[2] = DrawOp::Vertex(x3, y3, c3);
#ifdef GOSU_IS_OPENGLES
    op.vertices_or_block_index = 4;
    op.vertices[3] = op.vertices[2];
#endif
    op.z = z;
    
    current_queue().schedule_draw_op(op);
}

void Gosu::Graphics::draw_quad(double x1, double y1, Color c1, double x2, double y2, Color c2,
    double x3, double y3, Color c3, double x4, double y4, Color c4, ZPos z, AlphaMode mode)
{
    normalize_coordinates(x1, y1, x2, y2, x3, y3, c3, x4, y4, c4);

    DrawOp op;
    op.render_state.mode = mode;
    op.vertices_or_block_index = 4;
    op.vertices[0] = DrawOp::Vertex(x1, y1, c1);
    op.vertices[1] = DrawOp::Vertex(x2, y2, c2);
// TODO: Should be harmonized
#ifdef GOSU_IS_OPENGLES
    op.vertices[2] = DrawOp::Vertex(x3, y3, c3);
    op.vertices[3] = DrawOp::Vertex(x4, y4, c4);
#else
    op.vertices[3] = DrawOp::Vertex(x3, y3, c3);
    op.vertices[2] = DrawOp::Vertex(x4, y4, c4);
#endif
    op.z = z;
    
    current_queue().schedule_draw_op(op);
}

void Gosu::Graphics::draw_rect(double x, double y, double width, double height, Color c,
                               ZPos z, Gosu::AlphaMode mode)
{
    draw_quad(x, y, c, x + width, y, c, x, y + height, c, x + width, y + height, c, z, mode);
}

void Gosu::Graphics::schedule_draw_op(const Gosu::DrawOp& op)
{
    current_queue().schedule_draw_op(op);
}

void Gosu::Graphics::set_physical_resolution(unsigned phys_width, unsigned phys_height)
{
    pimpl->phys_width  = phys_width;
    pimpl->phys_height = phys_height;
    // TODO: Should be merged into RenderState and removed from Graphics.
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    glViewport(0, 0, phys_width, phys_height);
#ifdef GOSU_IS_OPENGLES
    glOrthof(0, phys_width, phys_height, 0, -1, 1);
#else
    glOrtho(0, phys_width, phys_height, 0, -1, 1);
#endif

    pimpl->update_base_transform();
}

unique_ptr<Gosu::ImageData> Gosu::Graphics::create_image(const Bitmap& src,
    unsigned src_x, unsigned src_y, unsigned src_width, unsigned src_height, unsigned flags)
{
    static const unsigned max_size = MAX_TEXTURE_SIZE;
    
    // Backward compatibility: This used to be 'bool tileable'.
    if (flags == 1) flags = IF_TILEABLE;

    bool wants_retro = (flags & IF_RETRO);
    
    // Special case: If the texture is supposed to have hard borders, is
    // quadratic, has a size that is at least 64 pixels but no more than max_size
    // pixels and a power of two, create a single texture just for this image.
    if ((flags & IF_TILEABLE) == IF_TILEABLE &&
            src_width == src_height &&
            (src_width & (src_width - 1)) == 0 &&
            src_width >= 64 && src_width <= max_size) {
        shared_ptr<Texture> texture(new Texture(src_width, wants_retro));
        unique_ptr<ImageData> data;
        
        // Use the source bitmap directly if the source area completely covers
        // it.
        if (src_x == 0 && src_width == src.width() && src_y == 0 && src_height == src.height()) {
            data = texture->try_alloc(texture, src, 0);
        }
        else {
            Bitmap bmp(src_width, src_height);
            bmp.insert(src, 0, 0, src_x, src_y, src_width, src_height);
            data = texture->try_alloc(texture, bmp, 0);
        }
        
        if (!data.get()) throw logic_error("Internal texture block allocation error");
        return data;
    }
    
    // Too large to fit on a single texture.
    if (src_width > max_size - 2 || src_height > max_size - 2) {
        Bitmap bmp(src_width, src_height);
        bmp.insert(src, 0, 0, src_x, src_y, src_width, src_height);
        unique_ptr<ImageData> lidi;
        lidi.reset(new LargeImageData(bmp, max_size - 2, max_size - 2, flags));
        return lidi;
    }
    
    Bitmap bmp;
    apply_border_flags(bmp, src, src_x, src_y, src_width, src_height, flags);

    // Try to put the bitmap into one of the already allocated textures.
    for (auto texture : textures) {
        if (texture->retro() != wants_retro) continue;
        
        unique_ptr<ImageData> data;
        data = texture->try_alloc(texture, bmp, 1);
        if (data.get()) return data;
    }
    
    // All textures are full: Create a new one.
    
    shared_ptr<Texture> texture;
    texture.reset(new Texture(max_size, wants_retro));
    textures.push_back(texture);
    
    unique_ptr<ImageData> data;
    data = texture->try_alloc(texture, bmp, 1);
    if (!data.get()) throw logic_error("Internal texture block allocation error");
    return data;
}