package io.github.shirainbown.sharelink.link;

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.download.LinkPageRenderer;
import io.github.shirainbown.sharelink.model.LinkVisitRecord;
import io.github.shirainbown.sharelink.model.ProtectedLink;

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

    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 Cache<String, Instant> dedupeCache =
        CacheBuilder.newBuilder()
            .expireAfterWrite(DEDUPE_CACHE_TTL)
            .maximumSize(50_000)
            .build();

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

    @Bean
    RouterFunction<ServerResponse> linkGoRoute() {
        return RouterFunctions.route()
            .GET("/link/{slug}/go", this::redirectToTarget)
            .build();
    }

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

    private Mono<ServerResponse> redirectToTarget(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_LINK.equals(payload.scope())) {
                    // Token missing, reused or expired: send the visitor back to the
                    // gate page to obtain a fresh one.
                    return redirectToGate(slug);
                }
                return findBySlug(slug)
                    .filter(link -> link.getSpec().isEnabled()
                        && StringUtils.isNotBlank(link.getSpec().getTargetUrl()))
                    .flatMap(link -> recordVisit(request, link, payload, basic)
                        .then(ServerResponse.status(HttpStatus.FOUND)
                            .location(URI.create(link.getSpec().getTargetUrl()))
                            .cacheControl(CacheControl.noStore())
                            .build()))
                    .switchIfEmpty(notFoundPage(slug));
            });
    }

    /**
     * Records the visit (LinkVisitRecord + redundant counter). Best effort: failures are
     * logged and never block the redirect. Links without email verification dedupe
     * repeated visits from the same IP inside the configured window (no record, no
     * count, but the redirect still proceeds).
     */
    private Mono<Void> recordVisit(ServerRequest request, ProtectedLink link,
                                   OneTimeTokenManager.TokenPayload payload,
                                   SharelinkSettingConfigGetter.BasicConfig basic) {
        var spec = link.getSpec();
        if (!spec.isRequireEmailVerify()
            && isDuplicate(spec.getSlug(), payload.ip(), basic.getDedupeWindowMinutes())) {
            return Mono.empty();
        }
        var record = new LinkVisitRecord();
        var metadata = new Metadata();
        metadata.setName(UUID.randomUUID().toString());
        record.setMetadata(metadata);
        var recordSpec = new LinkVisitRecord.Spec();
        recordSpec.setLinkSlug(spec.getSlug());
        recordSpec.setEmail(payload.email());
        recordSpec.setIp(payload.ip());
        recordSpec.setUserAgent(
            StringUtils.truncate(request.headers().firstHeader("User-Agent"), 500));
        recordSpec.setVisitedAt(Instant.now());
        record.setSpec(recordSpec);
        return client.create(record)
            .then(incrementVisitCount(link.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> incrementVisitCount(String linkName) {
        return Mono.defer(() -> client.fetch(ProtectedLink.class, linkName)
                .flatMap(link -> {
                    var status = link.getStatus();
                    if (status == null) {
                        status = new ProtectedLink.Status();
                        link.setStatus(status);
                    }
                    status.setVisitCount(status.getVisitCount() + 1);
                    return client.update(link);
                }))
            // Optimistic-lock retry for concurrent visits of the same link.
            .retryWhen(Retry.max(3)
                .filter(OptimisticLockingFailureException.class::isInstance))
            .onErrorResume(e -> {
                log.warn("更新链接访问计数失败: {}", linkName, e);
                return Mono.empty();
            })
            .then();
    }

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

    private static Mono<ServerResponse> redirectToGate(String slug) {
        return ServerResponse.status(HttpStatus.FOUND)
            .location(URI.create("/link/" + 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(LinkPageRenderer.renderNotFoundPage(slug));
    }
}
