package io.github.shirainbown.sharelink.protect;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.Set;
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.data.domain.Sort;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.security.AdditionalWebFilter;
import io.github.shirainbown.sharelink.download.InternalRequestSecret;
import io.github.shirainbown.sharelink.model.DownloadResource;

/**
 * Blocks direct access to {@code /upload/**} permalinks of attachments that are
 * registered as (enabled) download resources, so visitors must go through the download
 * page. Loopback requests from {@code FileStreamer} carrying the internal secret header
 * are always allowed. The protected permalink set is cached for 60 seconds and can be
 * invalidated explicitly after console mutations.
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class UploadProtectFilter implements AdditionalWebFilter {

    private static final String UPLOAD_PREFIX = "/upload/";
    private static final String CACHE_KEY = "protected-permalinks";
    private static final Duration CACHE_TTL = Duration.ofSeconds(60);
    private static final int PERMALINK_LOAD_CONCURRENCY = 8;

    private static final byte[] NOT_FOUND_BODY = ("{\"type\":\"about:blank\","
        + "\"title\":\"Not Found\",\"status\":404,\"detail\":\"资源不存在\"}")
        .getBytes(StandardCharsets.UTF_8);

    private final ReactiveExtensionClient client;
    private final InternalRequestSecret internalRequestSecret;

    private final Cache<String, Mono<Set<String>>> permalinkCache =
        CacheBuilder.newBuilder()
            .expireAfterWrite(CACHE_TTL)
            .maximumSize(1)
            .build();

    /**
     * Drops the cached permalink set; called by the console endpoint after resource
     * create/update/delete.
     */
    public void invalidate() {
        permalinkCache.invalidateAll();
    }

    @Override
    @NonNull
    public Mono<Void> filter(@NonNull ServerWebExchange exchange,
                             @NonNull WebFilterChain chain) {
        var request = exchange.getRequest();
        var method = request.getMethod();
        if (method != HttpMethod.GET && method != HttpMethod.HEAD) {
            return chain.filter(exchange);
        }
        var rawPath = request.getURI().getRawPath();
        if (rawPath == null || !rawPath.startsWith(UPLOAD_PREFIX)) {
            return chain.filter(exchange);
        }
        if (internalRequestSecret.matches(
            request.getHeaders().getFirst(InternalRequestSecret.HEADER))) {
            return chain.filter(exchange);
        }
        var decodedPath = request.getURI().getPath();
        return protectedPermalinks()
            .flatMap(permalinks -> {
                if (permalinks.contains(rawPath) || permalinks.contains(decodedPath)) {
                    return notFound(exchange);
                }
                return chain.filter(exchange);
            })
            // Fail open: a lookup failure must not break unrelated /upload/ requests.
            .onErrorResume(e -> {
                log.warn("查询受保护附件列表失败，放行本次请求: {}", rawPath, e);
                return chain.filter(exchange);
            });
    }

    private Mono<Void> notFound(ServerWebExchange exchange) {
        var response = exchange.getResponse();
        response.setStatusCode(HttpStatus.NOT_FOUND);
        response.getHeaders().setContentType(MediaType.APPLICATION_PROBLEM_JSON);
        return response.writeWith(
            Mono.just(response.bufferFactory().wrap(NOT_FOUND_BODY)));
    }

    private Mono<Set<String>> protectedPermalinks() {
        var cached = permalinkCache.getIfPresent(CACHE_KEY);
        if (cached != null) {
            return cached;
        }
        var loading = client.listAll(DownloadResource.class, new ListOptions(),
                Sort.unsorted())
            .filter(resource -> resource.getSpec() != null
                && resource.getSpec().isEnabled()
                && StringUtils.isNotBlank(resource.getSpec().getAttachmentName()))
            .flatMap(resource -> client.fetch(Attachment.class,
                        resource.getSpec().getAttachmentName())
                    .flatMap(attachment -> Mono.justOrEmpty(
                        attachment.getStatus() == null
                            ? null : attachment.getStatus().getPermalink()))
                    .onErrorResume(e -> Mono.empty()),
                PERMALINK_LOAD_CONCURRENCY)
            .filter(permalink -> permalink.startsWith(UPLOAD_PREFIX))
            .collect(Collectors.toSet())
            .map(Collections::unmodifiableSet)
            .cache()
            // Never keep a failed load in the cache.
            .doOnError(e -> permalinkCache.invalidate(CACHE_KEY));
        permalinkCache.put(CACHE_KEY, loading);
        return loading;
    }

    @Override
    public int getOrder() {
        return SecurityWebFiltersOrder.AUTHORIZATION.getOrder() + 1;
    }
}
