// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package io.gitea.sdk;

import io.gitea.sdk.exceptions.GiteaException;
import io.gitea.sdk.exceptions.UploadException;
import io.gitea.sdk.models.*;

import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

/**
 * Handles chunked file uploads with parallel workers.
 */
public class ChunkedUpload {
    private final GiteaClient client;
    private final String owner;
    private final String repo;
    private final long releaseId;
    private final ChunkedUploadOptions options;
    private UploadSession session;

    ChunkedUpload(
            GiteaClient client,
            String owner,
            String repo,
            long releaseId,
            ChunkedUploadOptions options) {
        this.client = client;
        this.owner = owner;
        this.repo = repo;
        this.releaseId = releaseId;
        this.options = options != null ? options : ChunkedUploadOptions.defaults();
    }

    /**
     * Gets the current upload session.
     */
    public UploadSession getSession() {
        return session;
    }

    /**
     * Uploads a file using chunked upload.
     */
    public UploadResult upload(InputStream fileStream, String filename, long fileSize) throws GiteaException {
        long chunkSize = options.getChunkSize();
        int totalChunks = (int) Math.ceil((double) fileSize / chunkSize);

        // Read all data and optionally compute checksum
        byte[] fileData;
        String checksum = null;

        try {
            if (options.isVerifyChecksum()) {
                MessageDigest digest = MessageDigest.getInstance("SHA-256");
                DigestInputStream dis = new DigestInputStream(fileStream, digest);
                fileData = dis.readAllBytes();
                checksum = bytesToHex(digest.digest());
            } else {
                fileData = fileStream.readAllBytes();
            }
        } catch (IOException | NoSuchAlgorithmException e) {
            throw new UploadException("Failed to read file", e, null, null);
        }

        // Create upload session
        session = createSession(filename, fileSize, chunkSize, totalChunks, checksum);

        // Prepare chunks
        List<ChunkData> chunks = new ArrayList<>();
        for (int i = 0; i < totalChunks; i++) {
            long offset = i * chunkSize;
            int size = (int) Math.min(chunkSize, fileSize - offset);
            byte[] data = new byte[size];
            System.arraycopy(fileData, (int) offset, data, 0, size);
            chunks.add(new ChunkData(i, data));
        }

        // Track progress
        long startTime = System.nanoTime();
        AtomicInteger chunksCompleted = new AtomicInteger(0);
        AtomicLong bytesCompleted = new AtomicLong(0);

        // Upload chunks in parallel
        ExecutorService executor = Executors.newFixedThreadPool(options.getParallel());
        List<Future<Void>> futures = new ArrayList<>();

        for (ChunkData chunk : chunks) {
            futures.add(executor.submit(() -> {
                uploadChunk(session.getId(), chunk.index, chunk.data);

                int done = chunksCompleted.incrementAndGet();
                long bytes = bytesCompleted.addAndGet(chunk.data.length);

                if (options.getOnProgress() != null) {
                    double elapsed = (System.nanoTime() - startTime) / 1_000_000_000.0;
                    double speed = elapsed > 0 ? bytes / elapsed : 0;
                    long remaining = fileSize - bytes;
                    Duration eta = speed > 0 ? Duration.ofSeconds((long) (remaining / speed)) : Duration.ZERO;

                    options.getOnProgress().accept(new Progress(
                            bytes,
                            fileSize,
                            done,
                            totalChunks,
                            (double) bytes / fileSize * 100,
                            speed,
                            eta
                    ));
                }

                return null;
            }));
        }

        // Wait for all uploads
        try {
            for (Future<Void> future : futures) {
                future.get();
            }
        } catch (InterruptedException | ExecutionException e) {
            throw new UploadException("Upload failed", e, session.getId(), null);
        } finally {
            executor.shutdown();
        }

        // Complete the upload
        return completeUpload(session.getId());
    }

    private UploadSession createSession(
            String filename,
            long fileSize,
            long chunkSize,
            int totalChunks,
            String checksum) throws GiteaException {
        String path = "/api/v1/repos/" + encode(owner) + "/" + encode(repo) + "/releases/" + releaseId + "/assets/upload";
        Map<String, Object> body = new HashMap<>();
        body.put("filename", filename);
        body.put("file_size", fileSize);
        body.put("chunk_size", chunkSize);
        body.put("total_chunks", totalChunks);
        if (checksum != null) {
            body.put("checksum", checksum);
        }
        return client.post(path, body, UploadSession.class);
    }

    private void uploadChunk(String sessionId, int chunkIndex, byte[] data) throws GiteaException {
        String path = "/api/v1/repos/uploads/" + encode(sessionId) + "/chunks/" + chunkIndex;
        client.putBinary(path, data);
    }

    private UploadResult completeUpload(String sessionId) throws GiteaException {
        String path = "/api/v1/repos/uploads/" + encode(sessionId) + "/complete";
        return client.post(path, null, UploadResult.class);
    }

    private static String encode(String value) {
        return URLEncoder.encode(value, StandardCharsets.UTF_8);
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }

    private static class ChunkData {
        final int index;
        final byte[] data;

        ChunkData(int index, byte[] data) {
            this.index = index;
            this.data = data;
        }
    }
}
