#include "IPakCreator.h"

#include "Game/T6/CommonT6.h"
#include "GitVersion.h"
#include "ObjContainer/IPak/IPakTypes.h"
#include "Utils/Alignment.h"
#include "Utils/Logging/Log.h"

#include <algorithm>
#include <cassert>
#include <format>
#include <fstream>
#include <iostream>
#include <minilzo.h>
#include <zlib.h>

namespace
{
    constexpr auto USE_IPAK_COMPRESSION = true;

    class IPakWriter
    {
        static constexpr char BRANDING[] = "Created with OpenAssetTools " GIT_VERSION;
        static constexpr auto SECTION_COUNT = 3; // Index + Data + Branding

        inline static const auto PAD_DATA = std::string(256, '\xA7');

    public:
        IPakWriter(std::ostream& stream, ISearchPath& searchPath, const std::vector<std::string>& images)
            : m_stream(stream),
              m_search_path(searchPath),
              m_images(images),
              m_current_offset(0),
              m_total_size(0),
              m_data_section_offset(0),
              m_data_section_size(0u),
              m_index_section_offset(0),
              m_branding_section_offset(0),
              m_file_offset(0u),
              m_chunk_buffer_window_start(0),
              m_current_block{},
              m_current_block_header_offset(0)
        {
            m_decompressed_buffer = std::make_unique<char[]>(ipak_consts::IPAK_CHUNK_SIZE);
            m_lzo_work_buffer = std::make_unique<char[]>(LZO1X_1_MEM_COMPRESS);
        }

        bool Write()
        {
            // We will write the header and sections later since they need complementary data
            GoTo(sizeof(IPakHeader) + sizeof(IPakSection) * SECTION_COUNT);
            AlignToChunk();

            WriteDataSection();
            WriteIndexSection();
            WriteBrandingSection();
            WriteFileEnding();

            WriteHeaderData();

            return true;
        }

    private:
        void GoTo(const int64_t offset)
        {
            m_stream.seekp(offset, std::ios::beg);
            m_current_offset = offset;
        }

        void Write(const void* data, const size_t dataSize)
        {
            m_stream.write(static_cast<const char*>(data), static_cast<std::streamsize>(dataSize));
            m_current_offset += static_cast<int64_t>(dataSize);
        }

        void Pad(const size_t paddingSize)
        {
            auto paddingSizeLeft = paddingSize;
            while (paddingSizeLeft > 0)
            {
                const auto writeSize = std::min(paddingSizeLeft, PAD_DATA.size());
                Write(PAD_DATA.data(), writeSize);

                paddingSizeLeft -= writeSize;
            }
        }

        void AlignToChunk()
        {
            Pad(static_cast<size_t>(utils::Align(m_current_offset, static_cast<int64_t>(ipak_consts::IPAK_CHUNK_SIZE)) - m_current_offset));
        }

        void AlignToBlockHeader()
        {
            Pad(static_cast<size_t>(utils::Align(m_current_offset, static_cast<int64_t>(sizeof(IPakDataBlockHeader))) - m_current_offset));
        }

        void WriteHeaderData()
        {
            GoTo(0);

            const IPakHeader header{.magic = ipak_consts::IPAK_MAGIC_LITTLE_ENDIAN,
                                    .version = ipak_consts::IPAK_VERSION,
                                    .size = static_cast<uint32_t>(m_total_size),
                                    .sectionCount = SECTION_COUNT};

            const IPakSection dataSection{
                .type = ipak_consts::IPAK_DATA_SECTION,
                .offset = static_cast<uint32_t>(m_data_section_offset),
                .size = static_cast<uint32_t>(m_data_section_size),
                .itemCount = static_cast<uint32_t>(m_index_entries.size()),
            };

            const IPakSection indexSection{
                .type = ipak_consts::IPAK_INDEX_SECTION,
                .offset = static_cast<uint32_t>(m_index_section_offset),
                .size = static_cast<uint32_t>(sizeof(IPakIndexEntry) * m_index_entries.size()),
                .itemCount = static_cast<uint32_t>(m_index_entries.size()),
            };

            const IPakSection brandingSection{
                .type = ipak_consts::IPAK_BRANDING_SECTION,
                .offset = static_cast<uint32_t>(m_branding_section_offset),
                .size = std::extent_v<decltype(BRANDING)>,
                .itemCount = 1,
            };

            Write(&header, sizeof(header));
            Write(&dataSection, sizeof(dataSection));
            Write(&indexSection, sizeof(indexSection));
            Write(&brandingSection, sizeof(brandingSection));
        }

        static std::string ImageFileName(const std::string& imageName)
        {
            return std::format("images/{}.iwi", imageName);
        }

        std::unique_ptr<char[]> ReadImageDataFromSearchPath(const std::string& imageName, size_t& imageSize) const
        {
            const auto fileName = ImageFileName(imageName);

            const auto openFile = m_search_path.Open(fileName);
            if (!openFile.IsOpen())
            {
                con::error("Failed to open file for ipak: {}", fileName);
                return nullptr;
            }

            imageSize = static_cast<size_t>(openFile.m_length);
            auto imageData = std::make_unique<char[]>(imageSize);
            openFile.m_stream->read(imageData.get(), static_cast<std::streamsize>(imageSize));

            return imageData;
        }

        void FlushBlock()
        {
            if (m_current_block_header_offset > 0)
            {
                const auto previousOffset = m_current_offset;

                GoTo(m_current_block_header_offset);
                Write(&m_current_block, sizeof(m_current_block));
                GoTo(previousOffset);
            }
        }

        void FlushChunk()
        {
            FlushBlock();
            AlignToBlockHeader();

            const auto nextChunkOffset = utils::Align(m_current_offset, static_cast<int64_t>(ipak_consts::IPAK_CHUNK_SIZE));
            const auto sizeToSkip = static_cast<size_t>(nextChunkOffset - m_current_offset);

            if (sizeToSkip >= sizeof(IPakDataBlockHeader))
            {
                IPakDataBlockHeader skipBlockHeader{};
                skipBlockHeader.countAndOffset.count = 1;
                skipBlockHeader.commands[0].compressed = ipak_consts::IPAK_COMMAND_SKIP;
                skipBlockHeader.commands[0].size = static_cast<uint32_t>(sizeToSkip - sizeof(IPakDataBlockHeader));
                Write(&skipBlockHeader, sizeof(skipBlockHeader));
            }

            AlignToChunk();
            m_chunk_buffer_window_start = m_current_offset;
        }

        void StartNewBlock()
        {
            AlignToBlockHeader();

            // Skip to the next chunk when only the header could fit into the current chunk anyway
            if (static_cast<size_t>(utils::Align(m_current_offset, static_cast<int64_t>(ipak_consts::IPAK_CHUNK_SIZE)) - m_current_offset)
                <= sizeof(IPakDataBlockHeader))
                FlushChunk();

            m_current_block_header_offset = m_current_offset;
            m_current_block = {};
            m_current_block.countAndOffset.offset = static_cast<uint32_t>(m_file_offset);

            // Reserve space to later write actual block header data
            GoTo(static_cast<int64_t>(m_current_offset + sizeof(IPakDataBlockHeader)));
        }

        void WriteChunkData(const void* data, const size_t dataSize)
        {
            auto dataOffset = 0uz;
            while (dataOffset < dataSize)
            {
                if (m_current_block.countAndOffset.count >= std::extent_v<decltype(IPakDataBlockHeader::commands)>)
                {
                    FlushBlock();
                    StartNewBlock();
                }

                const auto remainingSize = dataSize - dataOffset;
                const auto remainingChunkBufferWindowSize = std::max((ipak_consts::IPAK_CHUNK_COUNT_PER_READ * ipak_consts::IPAK_CHUNK_SIZE)
                                                                         - static_cast<size_t>(m_current_offset - m_chunk_buffer_window_start),
                                                                     0uz);

                if (remainingChunkBufferWindowSize == 0)
                {
                    FlushChunk();
                    StartNewBlock();
                    continue;
                }

                const auto commandSize = std::min({remainingSize, ipak_consts::IPAK_COMMAND_DEFAULT_SIZE, remainingChunkBufferWindowSize});

                auto writeUncompressed = true;
                if (USE_IPAK_COMPRESSION)
                {
                    auto outLen = static_cast<lzo_uint>(ipak_consts::IPAK_CHUNK_SIZE);
                    const auto result = lzo1x_1_compress(&static_cast<const unsigned char*>(data)[dataOffset],
                                                         commandSize,
                                                         reinterpret_cast<unsigned char*>(m_decompressed_buffer.get()),
                                                         &outLen,
                                                         m_lzo_work_buffer.get());

                    if (result == LZO_E_OK && outLen < commandSize)
                    {
                        writeUncompressed = false;
                        Write(m_decompressed_buffer.get(), outLen);

                        const auto currentCommand = m_current_block.countAndOffset.count;
                        m_current_block.commands[currentCommand].size = static_cast<uint32_t>(outLen);
                        m_current_block.commands[currentCommand].compressed = ipak_consts::IPAK_COMMAND_COMPRESSED;
                        m_current_block.countAndOffset.count = currentCommand + 1u;
                    }
                }

                if (writeUncompressed)
                {
                    Write(&static_cast<const char*>(data)[dataOffset], commandSize);

                    const auto currentCommand = m_current_block.countAndOffset.count;
                    m_current_block.commands[currentCommand].size = static_cast<uint32_t>(commandSize);
                    m_current_block.commands[currentCommand].compressed = ipak_consts::IPAK_COMMAND_UNCOMPRESSED;
                    m_current_block.countAndOffset.count = currentCommand + 1u;
                }

                dataOffset += commandSize;
                m_file_offset += commandSize;
            }
        }

        void StartNewFile()
        {
            FlushBlock();

            m_file_offset = 0u;
            StartNewBlock();
            m_chunk_buffer_window_start = utils::AlignToPrevious(m_current_offset, static_cast<int64_t>(ipak_consts::IPAK_CHUNK_SIZE));
        }

        void WriteImageData(const std::string& imageName)
        {
            size_t imageSize;
            const auto imageData = ReadImageDataFromSearchPath(imageName, imageSize);
            if (!imageData)
                return;

            const auto nameHash = T6::Common::R_HashString(imageName.c_str(), 0);
            const auto dataHash = static_cast<unsigned>(crc32(0u, reinterpret_cast<const Bytef*>(imageData.get()), static_cast<unsigned>(imageSize)));

            StartNewFile();
            const auto startOffset = m_current_block_header_offset;

            IPakIndexEntry indexEntry;
            indexEntry.key.nameHash = nameHash;
            indexEntry.key.dataHash = dataHash & 0x1FFFFFFF;
            indexEntry.offset = static_cast<uint32_t>(startOffset - m_data_section_offset);

            WriteChunkData(imageData.get(), imageSize);
            const auto writtenImageSize = static_cast<size_t>(m_current_offset - startOffset);

            indexEntry.size = static_cast<uint32_t>(writtenImageSize);
            m_index_entries.emplace_back(indexEntry);
        }

        void WriteDataSection()
        {
            AlignToChunk();
            m_data_section_offset = m_current_offset;
            m_data_section_size = 0u;

            m_index_entries.reserve(m_images.size());

            for (const auto& imageName : m_images)
                WriteImageData(imageName);

            FlushBlock();
            m_data_section_size = static_cast<size_t>(m_current_offset - m_data_section_offset);
        }

        static bool CompareIndices(const IPakIndexEntry& entry1, const IPakIndexEntry& entry2)
        {
            return entry1.key.combinedKey < entry2.key.combinedKey;
        }

        void SortIndexSectionEntries()
        {
            std::ranges::sort(m_index_entries, CompareIndices);
        }

        void WriteIndexSection()
        {
            AlignToChunk();
            m_index_section_offset = m_current_offset;

            SortIndexSectionEntries();

            for (const auto& indexEntry : m_index_entries)
                Write(&indexEntry, sizeof(indexEntry));
        }

        void WriteBrandingSection()
        {
            AlignToChunk();
            m_branding_section_offset = m_current_offset;

            Write(BRANDING, std::extent_v<decltype(BRANDING)>);
        }

        void WriteFileEnding()
        {
            AlignToChunk();
            m_total_size = m_current_offset;
        }

        std::ostream& m_stream;
        ISearchPath& m_search_path;
        const std::vector<std::string>& m_images;

        int64_t m_current_offset;
        std::vector<IPakIndexEntry> m_index_entries;
        int64_t m_total_size;
        int64_t m_data_section_offset;
        size_t m_data_section_size;
        int64_t m_index_section_offset;
        int64_t m_branding_section_offset;

        std::unique_ptr<char[]> m_decompressed_buffer;
        std::unique_ptr<char[]> m_lzo_work_buffer;
        size_t m_file_offset;
        int64_t m_chunk_buffer_window_start;
        IPakDataBlockHeader m_current_block;
        int64_t m_current_block_header_offset;
    };
} // namespace

namespace image
{
    IPakToCreate::IPakToCreate(std::string name)
        : m_name(std::move(name))
    {
    }

    void IPakToCreate::AddImage(std::string imageName)
    {
        m_image_names.emplace_back(std::move(imageName));
    }

    void IPakToCreate::Build(ISearchPath& searchPath, IOutputPath& outPath)
    {
        const auto file = outPath.Open(std::format("{}.ipak", m_name));
        if (!file)
        {
            con::error("Failed to open file for ipak {}", m_name);
            return;
        }

        IPakWriter writer(*file, searchPath, m_image_names);
        writer.Write();

        con::info("Created ipak {} with {} entries", m_name, m_image_names.size());
    }

    const std::vector<std::string>& IPakToCreate::GetImageNames() const
    {
        return m_image_names;
    }

    IPakCreator::IPakCreator()
        : m_kvp_creator(nullptr)
    {
    }

    void IPakCreator::Inject(ZoneAssetCreationInjection& inject)
    {
        m_kvp_creator = &inject.m_zone_states.GetZoneAssetCreationState<key_value_pairs::Creator>();
    }

    IPakToCreate* IPakCreator::GetOrAddIPak(const std::string& ipakName)
    {
        const auto existingIPak = m_ipak_lookup.find(ipakName);
        if (existingIPak != m_ipak_lookup.end())
            return existingIPak->second;

        auto newIPak = std::make_unique<IPakToCreate>(ipakName);
        auto* result = newIPak.get();
        m_ipak_lookup.emplace(ipakName, result);
        m_ipaks.emplace_back(std::move(newIPak));

        assert(m_kvp_creator);
        m_kvp_creator->AddKeyValuePair(key_value_pairs::CommonKeyValuePair("ipak_read", ipakName));

        return result;
    }

    void IPakCreator::Finalize(ISearchPath& searchPath, IOutputPath& outPath)
    {
        for (const auto& ipakToCreate : m_ipaks)
            ipakToCreate->Build(searchPath, outPath);

        m_ipaks.clear();
        m_ipak_lookup.clear();
    }
} // namespace image
