package dev.photosync.core.provider.immich;

import com.google.gson.reflect.TypeToken;
import dev.photosync.core.provider.Album;
import dev.photosync.core.provider.AlbumRef;
import dev.photosync.core.provider.AssetKind;
import dev.photosync.core.provider.BucketPage;
import dev.photosync.core.provider.PhotoProvider;
import dev.photosync.core.provider.ProviderConnection;
import dev.photosync.core.provider.ProviderDescriptor;
import dev.photosync.core.provider.ProviderException;
import dev.photosync.core.provider.ProviderIdentity;
import dev.photosync.core.provider.RemoteAsset;
import dev.photosync.core.provider.ThumbnailSize;
import dev.photosync.core.provider.TimelineBucket;
import dev.photosync.core.provider.TransferProgress;
import dev.photosync.core.provider.UploadReceipt;
import dev.photosync.core.provider.UploadRequest;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

/**
 * Immich, expressed in PhotoSync's terms.
 *
 * <p>Two Immich design choices carry the whole browsing experience and are worth
 * naming. Its timeline is exposed as monthly buckets with counts, so the grid
 * knows its exact total height before fetching a single asset -- that is what
 * makes a real virtual list possible rather than an endless scroll. And each
 * bucket page carries a per-asset aspect ratio and thumbhash, so a tile can be
 * laid out and filled with a recognisable blur before its image request is even
 * queued.
 */
@Slf4j
public final class ImmichProvider implements PhotoProvider {

    private static final Type ALBUM_LIST = new TypeToken<List<ImmichDtos.AlbumSummary>>() {
    }.getType();
    private static final Type BUCKET_LIST = new TypeToken<List<ImmichDtos.TimeBucket>>() {
    }.getType();
    private static final Type BULK_RESULT = new TypeToken<List<ImmichDtos.BulkIdResponse>>() {
    }.getType();

    /**
     * What to ask for when a tile in the grid needs filling, and what to ask for
     * when one is opened, most detailed first. Both end in {@code preview}, the
     * only rendition Immich always has. See {@link #thumbnail}.
     */
    private static final List<Rendition> GRID_LADDER = List.of(Rendition.THUMBNAIL, Rendition.PREVIEW);
    private static final List<Rendition> DETAIL_LADDER = List.of(Rendition.FULLSIZE, Rendition.PREVIEW);

    private final ProviderDescriptor descriptor;
    private final ImmichApi api;

    /**
     * Renditions this server turned out not to serve in a form the game can
     * decode. Learned from the first attempt and skipped from then on, so a
     * server missing one costs a single wasted request per session rather than
     * one per image. See {@link #thumbnail}.
     */
    private final Set<Rendition> unusable = Collections.synchronizedSet(EnumSet.noneOf(Rendition.class));

    ImmichProvider(ProviderDescriptor descriptor, ProviderConnection connection) {
        this.descriptor = descriptor;
        this.api = new ImmichApi(connection);
    }

    @Override
    public ProviderDescriptor descriptor() {
        return descriptor;
    }

    @Override
    public ProviderIdentity identify() throws ProviderException {
        // The profile call first: a wrong API key should say "not authorised",
        // not "could not read the version".
        ImmichDtos.UserProfile me = api.get("/users/me", Map.of(), ImmichDtos.UserProfile.class);
        ImmichDtos.ServerAbout about = api.get("/server/about", Map.of(), ImmichDtos.ServerAbout.class);
        String account = Optional.ofNullable(me.name())
                .filter(name -> !name.isBlank())
                .orElseGet(() -> Optional.ofNullable(me.email()).orElse("unknown"));
        return new ProviderIdentity(Optional.ofNullable(about.version()).orElse("unknown"), account);
    }

    @Override
    public List<Album> albums() throws ProviderException {
        List<ImmichDtos.AlbumSummary> response = api.get("/albums", Map.of(), ALBUM_LIST);
        return response.stream()
                .filter(summary -> summary.id() != null)
                .map(summary -> new Album(
                        summary.id(),
                        Optional.ofNullable(summary.albumName()).orElse(summary.id()),
                        Optional.ofNullable(summary.assetCount()).orElse(0),
                        Optional.ofNullable(summary.albumThumbnailAssetId())))
                .sorted(Comparator.comparing(Album::name, String.CASE_INSENSITIVE_ORDER))
                .toList();
    }

    @Override
    public Album createAlbum(String name) throws ProviderException {
        ImmichDtos.AlbumSummary created =
                api.post("/albums", new ImmichDtos.CreateAlbum(name), ImmichDtos.AlbumSummary.class);
        if (created.id() == null) {
            throw new ProviderException(ProviderException.Kind.PROTOCOL, "Immich created an album without an id");
        }
        return new Album(created.id(), name, 0, Optional.empty());
    }

    @Override
    public UploadReceipt upload(UploadRequest request, TransferProgress progress) throws ProviderException {
        Path file = request.file();
        if (!Files.isReadable(file)) {
            throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file);
        }

        MultipartBody body;
        try {
            body = MultipartBody.with(file, "assetData", request.fileName(), mediaTypeOf(request.fileName()))
                    .field("fileCreatedAt", request.capturedAt().toString())
                    .field("fileModifiedAt", request.modifiedAt().toString())
                    .field("filename", request.fileName())
                    .build();
        } catch (IOException e) {
            throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file, e);
        }

        ImmichDtos.AssetMediaResponse response =
                api.upload("/assets", body, sha1Base64(file), progress, ImmichDtos.AssetMediaResponse.class);
        if (response.id() == null) {
            throw new ProviderException(ProviderException.Kind.PROTOCOL, "Immich accepted the upload without an id");
        }

        UploadReceipt.Outcome outcome = "duplicate".equalsIgnoreCase(response.status())
                ? UploadReceipt.Outcome.DUPLICATE
                : UploadReceipt.Outcome.CREATED;

        Optional<String> album = request.album().id();
        if (album.isPresent()) {
            addToAlbum(album.get(), response.id());
        }
        if (!request.description().isBlank()) {
            setDescription(response.id(), request.description());
        }
        return new UploadReceipt(response.id(), outcome);
    }

    @Override
    public List<TimelineBucket> timeline(AlbumRef album) throws ProviderException {
        List<ImmichDtos.TimeBucket> response = api.get("/timeline/buckets", scope(album), BUCKET_LIST);
        return response.stream()
                .filter(bucket -> bucket.timeBucket() != null)
                .map(bucket -> monthOf(bucket.timeBucket())
                        .map(month -> new TimelineBucket(
                                bucket.timeBucket(), month, Math.max(0, Optional.ofNullable(bucket.count()).orElse(0)))))
                .flatMap(Optional::stream)
                .filter(bucket -> bucket.assetCount() > 0)
                .sorted()
                .toList();
    }

    @Override
    public BucketPage page(AlbumRef album, TimelineBucket bucket) throws ProviderException {
        Map<String, String> query = scope(album);
        query.put("timeBucket", bucket.key());
        ImmichDtos.BucketAssets columns = api.get("/timeline/bucket", query, ImmichDtos.BucketAssets.class);

        int count = columns.size();
        List<RemoteAsset> assets = new ArrayList<>(count);
        for (int index = 0; index < count; index++) {
            String id = columns.id().get(index);
            if (id == null) {
                continue;
            }
            Optional<Instant> takenAt = instantOf(column(columns.fileCreatedAt(), index));
            if (takenAt.isEmpty()) {
                // Without a timestamp the asset has no day to live under.
                continue;
            }
            double offsetHours = column(columns.localOffsetHours(), index, 0.0);
            boolean image = column(columns.isImage(), index, Boolean.TRUE);
            assets.add(new RemoteAsset(
                    id,
                    image ? AssetKind.IMAGE : AssetKind.VIDEO,
                    takenAt.get(),
                    localTimeOf(takenAt.get(), offsetHours),
                    column(columns.ratio(), index, 1.0),
                    Optional.ofNullable(column(columns.thumbhash(), index)),
                    Duration.ZERO));
        }
        assets.sort(Comparator.comparing(RemoteAsset::localCapturedAt).reversed());
        return new BucketPage(bucket, assets);
    }

    /**
     * Walks the ladder for the requested size, taking the first rendition that
     * comes back as something the game can decode.
     *
     * <p>The rungs above the last are optional on a real server, so a failure
     * there is not the caller's problem: it retires that rendition and drops to
     * the next one. The last rung is {@code preview}, which every Immich
     * installation serves and defaults to JPEG, so its failures are the asset's
     * or the server's and go back to the caller unchanged.
     */
    @Override
    public byte[] thumbnail(String assetId, ThumbnailSize size) throws ProviderException {
        // Videos have no still of their own to serve, but Immich renders one for
        // them at the same endpoint -- which is exactly what a video tile needs.
        String path = "/assets/" + assetId + "/thumbnail";
        List<Rendition> ladder = size == ThumbnailSize.DETAIL ? DETAIL_LADDER : GRID_LADDER;

        for (Rendition rendition : ladder.subList(0, ladder.size() - 1)) {
            if (unusable.contains(rendition)) {
                continue;
            }
            try {
                byte[] bytes = api.getBytes(path, Map.of("size", rendition.query()));
                if (!isWebP(bytes)) {
                    return bytes;
                }
                retire(rendition, "it is WebP, which the game's image decoder does not read");
            } catch (ProviderException e) {
                switch (e.kind()) {
                    // Not generated, or this API key may not ask for it. Neither
                    // changes while the game is running, so stop asking.
                    case NOT_FOUND, AUTHENTICATION, PROTOCOL -> retire(rendition, e.getMessage());
                    // Asking a second time is precisely what we were told not to do.
                    case RATE_LIMITED -> throw e;
                    // Might be this asset, might be this minute. Take the smaller
                    // image now and try the big one again next time.
                    default -> log.debug("Immich would not serve the {} of {}: {}",
                            rendition.query(), assetId, e.getMessage());
                }
            }
        }

        Rendition floor = ladder.get(ladder.size() - 1);
        byte[] bytes = api.getBytes(path, Map.of("size", floor.query()));
        if (isWebP(bytes)) {
            // Only reachable on a server whose preview format has been changed
            // from the default. Nothing below this rung would help, so say what
            // is wrong instead of handing the screen bytes it cannot draw.
            throw new ProviderException(ProviderException.Kind.PROTOCOL,
                    "This Immich server returns WebP previews, and Minecraft decodes only PNG and JPEG. "
                            + "Set Administration -> Settings -> Image -> Preview format to JPEG.");
        }
        return bytes;
    }

    private void retire(Rendition rendition, String reason) {
        if (unusable.add(rendition)) {
            log.info("Not using Immich's {} images this session, because {}", rendition.query(), reason);
        }
    }

    /** RIFF container with a WEBP fourcc, per the WebP specification. */
    private static boolean isWebP(byte[] bytes) {
        return bytes.length >= 12
                && bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F'
                && bytes[8] == 'W' && bytes[9] == 'E' && bytes[10] == 'B' && bytes[11] == 'P';
    }

    /**
     * The three sizes Immich renders an asset at, named as the {@code size} query
     * parameter spells them.
     *
     * <p>Only {@code preview} can be relied on. {@code thumbnail} is 250px and
     * WebP by default, which is both too small to open and undecodable here;
     * {@code fullsize} is off by default, and where it is on it can redirect to
     * a download endpoint that a restricted API key is not allowed to follow.
     */
    private enum Rendition {
        FULLSIZE("fullsize"),
        PREVIEW("preview"),
        THUMBNAIL("thumbnail");

        private final String query;

        Rendition(String query) {
            this.query = query;
        }

        String query() {
            return query;
        }
    }

    @Override
    public void close() {
        // Nothing to release: java.net.http.HttpClient has no close() before
        // Java 21, and we compile this module to 17. It uses daemon threads and
        // is collected with the provider.
    }

    // -----------------------------------------------------------------------

    private void addToAlbum(String albumId, String assetId) throws ProviderException {
        List<ImmichDtos.BulkIdResponse> results =
                api.put("/albums/" + albumId + "/assets", new ImmichDtos.BulkIds(List.of(assetId)), BULK_RESULT);
        for (ImmichDtos.BulkIdResponse result : results) {
            boolean added = Boolean.TRUE.equals(result.success());
            if (!added && !"duplicate".equalsIgnoreCase(result.error())) {
                // The asset itself is safely stored, so this is a warning rather
                // than a failure -- but the player asked for an album, so say so.
                log.warn("Uploaded {} but Immich would not add it to album {}: {}",
                        assetId, albumId, Optional.ofNullable(result.error()).orElse("no reason given"));
            }
        }
    }

    /**
     * Writes the one-line description after the asset exists, even when the
     * upload answered {@code duplicate} -- a retried upload whose first
     * response was lost reaches the server twice, and the description must land
     * on whichever attempt gets there.
     *
     * <p>Failures are warnings, not errors: the screenshot is the point, and the
     * queue must not show a failed upload for an asset the server is holding.
     */
    private void setDescription(String assetId, String description) {
        try {
            api.patch("/assets/" + assetId, new ImmichDtos.UpdateAsset(description));
        } catch (ProviderException e) {
            log.warn("Uploaded {} but Immich would not set its description: {}",
                    assetId, e.getMessage());
        }
    }

    /** Album filter plus the flags that keep archived and trashed assets out of the library view. */
    private static Map<String, String> scope(AlbumRef album) {
        Map<String, String> query = new LinkedHashMap<>();
        album.id().ifPresent(id -> query.put("albumId", id));
        if (album.isLibrary()) {
            query.put("visibility", "timeline");
        }
        query.put("order", "desc");
        return query;
    }

    /**
     * Immich has returned the bucket token as both {@code 2024-01-01} and
     * {@code 2024-01-01T00:00:00.000Z} across releases. Only the date part is
     * ever meaningful, and the token itself goes back to the server untouched.
     */
    private static Optional<LocalDate> monthOf(String bucketToken) {
        if (bucketToken.length() < 10) {
            return Optional.empty();
        }
        try {
            return Optional.of(LocalDate.parse(bucketToken.substring(0, 10)));
        } catch (DateTimeParseException e) {
            log.debug("Ignoring bucket with an unparseable token: {}", bucketToken);
            return Optional.empty();
        }
    }

    private static Optional<Instant> instantOf(String iso) {
        if (iso == null) {
            return Optional.empty();
        }
        try {
            return Optional.of(Instant.parse(iso));
        } catch (DateTimeParseException e) {
            try {
                return Optional.of(LocalDateTime.parse(iso).toInstant(ZoneOffset.UTC));
            } catch (DateTimeParseException ignored) {
                return Optional.empty();
            }
        }
    }

    /**
     * The wall-clock time where the photo was taken. Immich stores the capture
     * instant in UTC alongside the offset that was in force, and some zones are
     * on a fractional hour, so the offset is converted through seconds.
     */
    private static LocalDateTime localTimeOf(Instant instant, double offsetHours) {
        double clamped = Math.max(-18.0, Math.min(18.0, offsetHours));
        return instant.atOffset(ZoneOffset.ofTotalSeconds((int) Math.round(clamped * 3600))).toLocalDateTime();
    }

    private static <T> T column(List<T> values, int index) {
        return values == null || index >= values.size() ? null : values.get(index);
    }

    private static <T> T column(List<T> values, int index, T fallback) {
        T value = column(values, index);
        return value == null ? fallback : value;
    }

    private static String mediaTypeOf(String fileName) {
        String lower = fileName.toLowerCase(Locale.ROOT);
        if (lower.endsWith(".png")) {
            return "image/png";
        }
        if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
            return "image/jpeg";
        }
        if (lower.endsWith(".webp")) {
            return "image/webp";
        }
        return "application/octet-stream";
    }

    /**
     * SHA-1 of the file, base64 encoded, for the {@code x-immich-checksum}
     * header. Reading the screenshot a second time costs a millisecond and buys
     * the server the ability to recognise a retry of an upload it already has.
     */
    private static String sha1Base64(Path file) throws ProviderException {
        try (InputStream stream = Files.newInputStream(file)) {
            MessageDigest digest = MessageDigest.getInstance("SHA-1");
            byte[] buffer = new byte[16 * 1024];
            int read;
            while ((read = stream.read(buffer)) > 0) {
                digest.update(buffer, 0, read);
            }
            return Base64.getEncoder().encodeToString(digest.digest());
        } catch (IOException e) {
            throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file, e);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("Every JVM is required to provide SHA-1", e);
        }
    }
}
