package io.github.shirainbown.sharelink.download;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;
import io.github.shirainbown.sharelink.common.OneTimeTokenManager;
import io.github.shirainbown.sharelink.model.DownloadRecord;
import io.github.shirainbown.sharelink.model.DownloadResource;

/**
 * Public download routes, registered as {@link RouterFunction} beans which Halo collects
 * from the plugin context:
 * <ul>
 *   <li>{@code GET /download/{slug}} — the self-contained HTML download page</li>
 *   <li>{@code GET /download/{slug}/file?token=...} — consumes a one-time token, records
 *   the download, then streams the file</li>
 * </ul>
 * All responses are {@code Cache-Control: no-store}.
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class DownloadWebRouter {

    private static final MediaType HTML_UTF8 =
        new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8);

    /**
     * Fallback eviction for dedupe entries; the effective window is checked against the
     * configured value on each hit.
     */
    private static final Duration DEDUPE_CACHE_TTL = Duration.ofDays(1);

    private final ReactiveExtensionClient client;
    private final SharelinkSettingConfigGetter settingConfigGetter;
    private final OneTimeTokenManager tokenManager;
    private final FileStreamer fileStreamer;

    private final Cache<String, Instant> dedupeCache =
        CacheBuilder.newBuilder()
            .expireAfterWrite(DEDUPE_CACHE_TTL)
            .maximumSize(50_000)
            .build();

    @Bean
    RouterFunction<ServerResponse> downloadPageRoute() {
        return RouterFunctions.route()
            .GET("/download/{slug}", this::renderPage)
            .build();
    }

    @Bean
    RouterFunction<ServerResponse> downloadFileRoute() {
        return RouterFunctions.route()
            .GET("/download/{slug}/file", this::streamFile)
            .build();
    }

    private Mono<ServerResponse> renderPage(ServerRequest request) {
        var slug = request.pathVariable("slug");
        return findBySlug(slug)
            .filter(resource -> resource.getSpec().isEnabled())
            .flatMap(resource -> {
                var spec = resource.getSpec();
                var displayName = StringUtils.firstNonBlank(spec.getDisplayName(),
                    spec.getSlug());
                var html = DownloadPageRenderer.renderDownloadPage(spec.getSlug(),
                    displayName, spec.getDescription(), spec.isRequireEmailVerify());
                return ServerResponse.ok()
                    .contentType(HTML_UTF8)
                    .cacheControl(CacheControl.noStore())
                    .bodyValue(html);
            })
            .switchIfEmpty(notFoundPage(slug));
    }

    private Mono<ServerResponse> streamFile(ServerRequest request) {
        var slug = request.pathVariable("slug");
        var token = request.queryParam("token").orElse(null);
        return settingConfigGetter.getBasicConfig()
            .flatMap(basic -> {
                var payload = tokenManager.consume(token, basic.getTokenTtlSeconds());
                if (payload == null || !slug.equals(payload.slug())
                    || !OneTimeTokenManager.SCOPE_DOWNLOAD.equals(payload.scope())) {
                    // Token missing, reused or expired: send the visitor back to the
                    // download page to obtain a fresh one.
                    return redirectToPage(slug);
                }
                return findBySlug(slug)
                    .filter(resource -> resource.getSpec().isEnabled())
                    .flatMap(resource -> fileStreamer.resolveAvailable(resource)
                        .flatMap(attachment -> recordDownload(request, resource, payload,
                                basic)
                            .then(fileStreamer.stream(request, resource.getSpec(),
                                attachment)))
                        .switchIfEmpty(FileStreamer.errorPage(HttpStatus.NOT_FOUND,
                            "附件暂不可用")))
                    .switchIfEmpty(notFoundPage(slug));
            });
    }

    /**
     * Records the download (DownloadRecord + redundant counter). Best effort: failures
     * are logged and never block the download itself. Resources without email
     * verification dedupe repeated downloads from the same IP inside the configured
     * window (no record, no count, but the download still proceeds).
     */
    private Mono<Void> recordDownload(ServerRequest request, DownloadResource resource,
                                      OneTimeTokenManager.TokenPayload payload,
                                      SharelinkSettingConfigGetter.BasicConfig basic) {
        var spec = resource.getSpec();
        if (!spec.isRequireEmailVerify()
            && isDuplicate(spec.getSlug(), payload.ip(), basic.getDedupeWindowMinutes())) {
            return Mono.empty();
        }
        var record = new DownloadRecord();
        var metadata = new Metadata();
        metadata.setName(UUID.randomUUID().toString());
        record.setMetadata(metadata);
        var recordSpec = new DownloadRecord.Spec();
        recordSpec.setResourceSlug(spec.getSlug());
        recordSpec.setEmail(payload.email());
        recordSpec.setIp(payload.ip());
        recordSpec.setUserAgent(
            StringUtils.truncate(request.headers().firstHeader("User-Agent"), 500));
        recordSpec.setDownloadedAt(Instant.now());
        record.setSpec(recordSpec);
        return client.create(record)
            .then(incrementDownloadCount(resource.getMetadata().getName()))
            .onErrorResume(e -> {
                log.warn("记录下载失败: slug={}", spec.getSlug(), e);
                return Mono.empty();
            });
    }

    /**
     * @return {@code true} when this slug+ip pair was already seen inside the dedupe
     * window
     */
    private boolean isDuplicate(String slug, String ip, int windowMinutes) {
        if (windowMinutes <= 0) {
            return false;
        }
        var key = slug + "|" + (ip == null ? "-" : ip);
        var now = Instant.now();
        var last = dedupeCache.getIfPresent(key);
        if (last != null && now.isBefore(last.plusSeconds(windowMinutes * 60L))) {
            return true;
        }
        dedupeCache.put(key, now);
        return false;
    }

    private Mono<Void> incrementDownloadCount(String resourceName) {
        return Mono.defer(() -> client.fetch(DownloadResource.class, resourceName)
                .flatMap(resource -> {
                    var status = resource.getStatus();
                    if (status == null) {
                        status = new DownloadResource.Status();
                        resource.setStatus(status);
                    }
                    status.setDownloadCount(status.getDownloadCount() + 1);
                    return client.update(resource);
                }))
            // Optimistic-lock retry for concurrent downloads of the same resource.
            .retryWhen(Retry.max(3)
                .filter(OptimisticLockingFailureException.class::isInstance))
            .onErrorResume(e -> {
                log.warn("更新下载计数失败: {}", resourceName, e);
                return Mono.empty();
            })
            .then();
    }

    private Mono<DownloadResource> findBySlug(String slug) {
        var options = ListOptions.builder()
            .fieldQuery(QueryFactory.equal("spec.slug", slug))
            .build();
        return client.listAll(DownloadResource.class, options, Sort.unsorted()).next();
    }

    private static Mono<ServerResponse> redirectToPage(String slug) {
        return ServerResponse.status(HttpStatus.FOUND)
            .location(URI.create("/download/" + slug))
            .cacheControl(CacheControl.noStore())
            .build();
    }

    private static Mono<ServerResponse> notFoundPage(String slug) {
        return ServerResponse.status(HttpStatus.NOT_FOUND)
            .contentType(HTML_UTF8)
            .cacheControl(CacheControl.noStore())
            .bodyValue(DownloadPageRenderer.renderNotFoundPage(slug));
    }
}
