package dev.photosync.core.provider.immich;

import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
import dev.photosync.core.provider.ProviderConnection;
import dev.photosync.core.provider.ProviderException;
import dev.photosync.core.provider.TransferCancelledException;
import dev.photosync.core.provider.TransferProgress;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.Type;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.StringJoiner;

/**
 * Everything HTTP about talking to Immich: URL shape, authentication, timeouts,
 * and turning a status code into a {@link ProviderException} the upload queue
 * can act on.
 *
 * <p>Keeping this apart from {@link ImmichProvider} means the provider reads as
 * a list of API calls and their mapping to PhotoSync's model, with no transport
 * noise in between.
 */
final class ImmichApi {

    private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
    private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
    /** Uploads get their own budget: a screenshot over a slow uplink is not a hung server. */
    private static final Duration UPLOAD_TIMEOUT = Duration.ofMinutes(10);

    private final HttpClient http;
    private final URI root;
    private final String apiKey;
    private final Gson gson = new Gson();

    ImmichApi(ProviderConnection connection) {
        this.root = apiRoot(connection.endpoint());
        this.apiKey = connection.secret();
        this.http = HttpClient.newBuilder()
                .connectTimeout(CONNECT_TIMEOUT)
                // HTTP/1.1 on purpose. Immich is nearly always behind a
                // self-hosted reverse proxy, and multipart uploads over an
                // upgraded HTTP/2 connection are the first thing to break when
                // that proxy is misconfigured.
                .version(HttpClient.Version.HTTP_1_1)
                .followRedirects(HttpClient.Redirect.NORMAL)
                .build();
    }

    URI root() {
        return root;
    }

    /**
     * Turns whatever the player typed into the API root.
     *
     * <p>People paste {@code immich.example.com}, {@code https://immich.example.com/},
     * and {@code https://example.com/immich/api} in roughly equal measure, and
     * being told "connection failed" because of a missing scheme is a miserable
     * first five minutes with a mod.
     */
    static URI apiRoot(String endpoint) {
        String value = endpoint.trim();
        if (!value.matches("(?i)^[a-z][a-z0-9+.-]*://.*")) {
            value = "https://" + value;
        }
        while (value.endsWith("/")) {
            value = value.substring(0, value.length() - 1);
        }
        if (!value.endsWith("/api")) {
            value = value + "/api";
        }
        return URI.create(value);
    }

    <T> T get(String path, Map<String, String> query, Type type) throws ProviderException {
        return decode(send(request(path, query, REQUEST_TIMEOUT).GET().build()), type);
    }

    byte[] getBytes(String path, Map<String, String> query) throws ProviderException {
        return send(request(path, query, REQUEST_TIMEOUT).GET().build());
    }

    <T> T post(String path, Object body, Type type) throws ProviderException {
        HttpRequest request = request(path, Map.of(), REQUEST_TIMEOUT)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8))
                .build();
        return decode(send(request), type);
    }

    <T> T put(String path, Object body, Type type) throws ProviderException {
        HttpRequest request = request(path, Map.of(), REQUEST_TIMEOUT)
                .header("Content-Type", "application/json")
                .PUT(HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8))
                .build();
        return decode(send(request), type);
    }

    /**
     * PATCHes JSON and discards the response body -- the only PATCH PhotoSync
     * makes is the asset description update, whose reply is the whole asset we
     * do not need.
     */
    void patch(String path, Object body) throws ProviderException {
        HttpRequest request = request(path, Map.of(), REQUEST_TIMEOUT)
                .header("Content-Type", "application/json")
                .method("PATCH", HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8))
                .build();
        send(request);
    }

    <T> T upload(String path, MultipartBody body, String checksumBase64, TransferProgress progress, Type type)
            throws ProviderException {
        HttpRequest.Builder builder = request(path, Map.of(), UPLOAD_TIMEOUT)
                .header("Content-Type", body.contentType())
                // Lets the server recognise bytes it already has and answer
                // "duplicate" instead of storing a second copy -- which is what
                // makes retrying an interrupted upload safe.
                .header("x-immich-checksum", checksumBase64)
                .POST(body.publisher(progress));
        return decode(send(builder.build()), type);
    }

    private HttpRequest.Builder request(String path, Map<String, String> query, Duration timeout) {
        return HttpRequest.newBuilder(uri(path, query))
                .header("x-api-key", apiKey)
                .header("Accept", "application/json")
                .timeout(timeout);
    }

    private URI uri(String path, Map<String, String> query) {
        StringBuilder url = new StringBuilder(root.toString()).append(path);
        if (!query.isEmpty()) {
            StringJoiner joiner = new StringJoiner("&");
            query.forEach((key, value) -> joiner.add(encode(key) + "=" + encode(value)));
            url.append('?').append(joiner);
        }
        return URI.create(url.toString());
    }

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

    private byte[] send(HttpRequest request) throws ProviderException {
        try {
            HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
            int status = response.statusCode();
            if (status >= 200 && status < 300) {
                return response.body();
            }
            throw failure(status, response.body());
        } catch (IOException e) {
            // A cancelled transfer unwinds through the request body stream, and
            // the client hands it back wrapped. It is not a network failure and
            // must not be retried, so dig it out before classifying anything.
            TransferCancelledException cancelled = findCancellation(e);
            if (cancelled != null) {
                throw cancelled;
            }
            throw new ProviderException(ProviderException.Kind.NETWORK,
                    "Could not reach " + root.getHost() + ": " + e.getMessage(), e);
        } catch (UncheckedIOException e) {
            throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE,
                    "Could not read the file to upload: " + e.getMessage(), e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ProviderException(ProviderException.Kind.NETWORK, "Interrupted while waiting for Immich", e);
        }
    }

    private ProviderException failure(int status, byte[] body) {
        String detail = describe(status, body);
        ProviderException.Kind kind = switch (status) {
            case 401, 403 -> ProviderException.Kind.AUTHENTICATION;
            case 404 -> ProviderException.Kind.NOT_FOUND;
            case 408, 429 -> ProviderException.Kind.RATE_LIMITED;
            default -> status >= 500 ? ProviderException.Kind.SERVER : ProviderException.Kind.PROTOCOL;
        };
        return new ProviderException(kind, detail);
    }

    /** Immich answers errors as JSON; fall back to the raw text when it does not. */
    private String describe(int status, byte[] body) {
        String text = new String(body, StandardCharsets.UTF_8).trim();
        Optional<String> message = Optional.empty();
        if (text.startsWith("{")) {
            try {
                ImmichDtos.ApiError error = gson.fromJson(text, ImmichDtos.ApiError.class);
                message = Optional.ofNullable(error).map(ImmichDtos.ApiError::message);
            } catch (JsonSyntaxException ignored) {
                // Fall through to the raw body below.
            }
        }
        String summary = message.filter(value -> !value.isBlank())
                .orElseGet(() -> text.isEmpty() ? "no details" : abbreviate(text));
        return "Immich returned HTTP " + status + " (" + summary + ")";
    }

    private <T> T decode(byte[] body, Type type) throws ProviderException {
        try {
            T value = gson.fromJson(new String(body, StandardCharsets.UTF_8), type);
            if (value == null) {
                throw new ProviderException(ProviderException.Kind.PROTOCOL, "Immich returned an empty response");
            }
            return value;
        } catch (JsonSyntaxException e) {
            throw new ProviderException(ProviderException.Kind.PROTOCOL,
                    "Immich returned something that is not the JSON we expected", e);
        }
    }

    private static TransferCancelledException findCancellation(Throwable thrown) {
        for (Throwable cause = thrown; cause != null; cause = cause.getCause()) {
            if (cause instanceof TransferCancelledException cancelled) {
                return cancelled;
            }
            if (cause.getCause() == cause) {
                break;
            }
        }
        return null;
    }

    private static String abbreviate(String text) {
        String flat = text.replaceAll("\\s+", " ");
        return flat.length() <= 160 ? flat : flat.substring(0, 157) + "...";
    }
}
