package dev.photosync.core.provider.immich;

import dev.photosync.core.provider.TransferProgress;

import java.io.ByteArrayInputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.SequenceInputStream;
import java.io.UncheckedIOException;
import java.net.http.HttpRequest;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

/**
 * A {@code multipart/form-data} body that streams one file from disk.
 *
 * <p>The obvious implementation -- read the screenshot into a byte array and
 * hand it to {@code BodyPublishers.ofByteArray} -- would double the mod's peak
 * memory for every concurrent upload, on a heap the game is already competing
 * for. So the body is assembled as a preamble, the file, and an epilogue, and
 * the file part is never held in memory.
 *
 * <p>The length is computed up front so the request can carry a real
 * {@code Content-Length}. Streaming with an unknown length would force chunked
 * transfer encoding, which self-hosted reverse proxies in front of Immich are
 * not reliably configured for.
 */
final class MultipartBody {

    /** Report progress at most this often, so a fast local server is not drowned in callbacks. */
    private static final int PROGRESS_STRIDE_BYTES = 64 * 1024;

    private final String boundary;
    private final byte[] preamble;
    private final byte[] epilogue;
    private final Path file;
    private final long fileSize;

    private MultipartBody(String boundary, byte[] preamble, byte[] epilogue, Path file, long fileSize) {
        this.boundary = boundary;
        this.preamble = preamble;
        this.epilogue = epilogue;
        this.file = file;
        this.fileSize = fileSize;
    }

    static Builder with(Path file, String fieldName, String fileName, String contentType) throws IOException {
        return new Builder(file, fieldName, fileName, contentType);
    }

    String contentType() {
        return "multipart/form-data; boundary=" + boundary;
    }

    long contentLength() {
        return preamble.length + fileSize + epilogue.length;
    }

    HttpRequest.BodyPublisher publisher(TransferProgress progress) {
        long total = contentLength();
        return HttpRequest.BodyPublishers.fromPublisher(
                HttpRequest.BodyPublishers.ofInputStream(() -> open(progress, total)),
                total);
    }

    private InputStream open(TransferProgress progress, long total) {
        try {
            InputStream body = new SequenceInputStream(Collections.enumeration(List.<InputStream>of(
                    new ByteArrayInputStream(preamble),
                    Files.newInputStream(file),
                    new ByteArrayInputStream(epilogue))));
            return new CountingStream(body, progress, total);
        } catch (IOException e) {
            // The supplier cannot throw a checked exception; the HttpClient
            // unwraps this back into an IOException on send().
            throw new UncheckedIOException(e);
        }
    }

    /**
     * Counts bytes as the HTTP client pulls them, and tells the queue.
     *
     * <p>The callback may throw -- that is how a cancelled upload unwinds -- so
     * it is invoked where the exception can propagate out of {@code read}.
     */
    private static final class CountingStream extends FilterInputStream {

        private final TransferProgress progress;
        private final long total;
        private long sent;
        private long lastReported;

        CountingStream(InputStream delegate, TransferProgress progress, long total) {
            super(delegate);
            this.progress = progress;
            this.total = total;
        }

        @Override
        public int read() throws IOException {
            int value = super.read();
            if (value >= 0) {
                advance(1);
            }
            return value;
        }

        @Override
        public int read(byte[] buffer, int offset, int length) throws IOException {
            int count = super.read(buffer, offset, length);
            if (count > 0) {
                advance(count);
            }
            return count;
        }

        private void advance(int count) {
            sent += count;
            if (sent - lastReported >= PROGRESS_STRIDE_BYTES || sent == total) {
                lastReported = sent;
                progress.onProgress(sent, total);
            }
        }
    }

    static final class Builder {

        private final String boundary = "PhotoSync" + UUID.randomUUID().toString().replace("-", "");
        private final List<String> fields = new ArrayList<>();
        private final Path file;
        private final String fieldName;
        private final String fileName;
        private final String contentType;
        private final long fileSize;

        private Builder(Path file, String fieldName, String fileName, String contentType) throws IOException {
            this.file = file;
            this.fieldName = fieldName;
            this.fileName = fileName;
            this.contentType = contentType;
            this.fileSize = Files.size(file);
        }

        Builder field(String name, String value) {
            fields.add("--" + boundary + "\r\n"
                    + "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"
                    + value + "\r\n");
            return this;
        }

        MultipartBody build() {
            StringBuilder head = new StringBuilder();
            fields.forEach(head::append);
            head.append("--").append(boundary).append("\r\n")
                    .append("Content-Disposition: form-data; name=\"").append(fieldName)
                    .append("\"; filename=\"").append(escape(fileName)).append("\"\r\n")
                    .append("Content-Type: ").append(contentType).append("\r\n\r\n");

            return new MultipartBody(
                    boundary,
                    head.toString().getBytes(StandardCharsets.UTF_8),
                    ("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8),
                    file,
                    fileSize);
        }

        /** Quotes and backslashes in a file name would end the header early. */
        private static String escape(String raw) {
            return raw.replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "").replace("\n", "");
        }
    }
}
