package io.github.shirainbown.sharelink.console;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
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 org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import io.github.shirainbown.sharelink.model.LinkVisitRecord;
import io.github.shirainbown.sharelink.model.ProtectedLink;
import io.github.shirainbown.sharelink.reference.PostReferenceService;

/**
 * Console endpoints for protected-link administration, mounted under
 * {@code /apis/console.api.sharelink.halo.run/v1alpha1}, so authentication and RBAC are
 * enforced by Halo.
 */
@Component
@RequiredArgsConstructor
public class LinkConsoleEndpoint implements CustomEndpoint {

    private static final Pattern SLUG_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,63}");
    private static final int STATS_CONCURRENCY = 8;
    private static final int MAX_PAGE_SIZE = 200;
    private static final MediaType CSV_MEDIA_TYPE =
        MediaType.parseMediaType("text/csv; charset=UTF-8");
    private static final String CSV_DISPOSITION =
        "attachment; filename=\"link-visit-records.csv\"";
    /**
     * BOM prefix so spreadsheet applications detect the UTF-8 encoding.
     */
    private static final String CSV_BOM = "\uFEFF";

    private final ReactiveExtensionClient client;
    private final PostReferenceService referenceService;

    @Override
    public RouterFunction<ServerResponse> endpoint() {
        return RouterFunctions.route()
            .GET("protected-links", this::listLinks)
            .POST("protected-links", this::createLink)
            .GET("protected-links/{name}", this::getLink)
            .PUT("protected-links/{name}", this::updateLink)
            .DELETE("protected-links/{name}", this::deleteLink)
            .GET("link-visit-records", this::listRecords)
            .DELETE("link-visit-records/{name}", this::deleteRecord)
            .GET("link-visit-records/-/export", this::exportRecords)
            .GET("link-references", this::getReferences)
            .POST("link-references/-/refresh", this::refreshReferences)
            .build();
    }

    // ---------- links ----------

    private Mono<ServerResponse> listLinks(ServerRequest request) {
        var references = referenceService.linkReferences()
            .onErrorResume(e -> Mono.just(Map.<String, List<PostReferenceService.PostRef>>of()));
        return references.flatMap(refMap -> client.listAll(ProtectedLink.class,
                    new ListOptions(), Sort.unsorted())
                .sort(Comparator.comparing(LinkConsoleEndpoint::creationTimestamp,
                    Comparator.nullsLast(Comparator.reverseOrder())))
                .flatMap(link -> toVo(link, refMap), STATS_CONCURRENCY)
                .collectList())
            .flatMap(vos -> ServerResponse.ok().bodyValue(vos));
    }

    private Mono<ServerResponse> createLink(ServerRequest request) {
        return request.bodyToMono(LinkRequest.class)
            .switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
            .flatMap(body -> validateLinkRequest(body, null)
                .then(Mono.defer(() -> {
                    var link = new ProtectedLink();
                    var metadata = new Metadata();
                    metadata.setName(UUID.randomUUID().toString());
                    link.setMetadata(metadata);
                    var spec = new ProtectedLink.Spec();
                    applyRequest(spec, body);
                    link.setSpec(spec);
                    var status = new ProtectedLink.Status();
                    status.setVisitCount(0);
                    link.setStatus(status);
                    return client.create(link);
                })))
            .flatMap(this::toVo)
            .flatMap(vo -> ServerResponse.ok().bodyValue(vo));
    }

    private Mono<ServerResponse> getLink(ServerRequest request) {
        return client.fetch(ProtectedLink.class, request.pathVariable("name"))
            .switchIfEmpty(Mono.error(new ResponseStatusException(
                HttpStatus.NOT_FOUND, "链接不存在")))
            .flatMap(this::toVo)
            .flatMap(vo -> ServerResponse.ok().bodyValue(vo));
    }

    private Mono<ServerResponse> updateLink(ServerRequest request) {
        var name = request.pathVariable("name");
        return request.bodyToMono(LinkRequest.class)
            .switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
            .flatMap(body -> client.fetch(ProtectedLink.class, name)
                .switchIfEmpty(Mono.error(new ResponseStatusException(
                    HttpStatus.NOT_FOUND, "链接不存在")))
                .flatMap(link -> validateLinkRequest(body, name)
                    .then(Mono.defer(() -> {
                        applyRequest(link.getSpec(), body);
                        return client.update(link);
                    }))))
            .flatMap(this::toVo)
            .flatMap(vo -> ServerResponse.ok().bodyValue(vo));
    }

    private Mono<ServerResponse> deleteLink(ServerRequest request) {
        var name = request.pathVariable("name");
        return client.fetch(ProtectedLink.class, name)
            .switchIfEmpty(Mono.error(new ResponseStatusException(
                HttpStatus.NOT_FOUND, "链接不存在")))
            .flatMap(link -> {
                var slug = link.getSpec().getSlug();
                return client.delete(link)
                    .then(deleteRecordsOfSlug(slug));
            })
            .then(ServerResponse.ok().build());
    }

    private Mono<Void> deleteRecordsOfSlug(String slug) {
        if (StringUtils.isBlank(slug)) {
            return Mono.empty();
        }
        return recordsOfSlug(slug)
            .flatMap(client::delete)
            .then();
    }

    /**
     * Validates slug format/uniqueness and target URL.
     *
     * @param selfName the name of the link being updated, excluded from the uniqueness
     * check; {@code null} on creation
     */
    private Mono<Void> validateLinkRequest(LinkRequest body, String selfName) {
        var slug = StringUtils.trimToNull(body.slug());
        if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) {
            return Mono.error(new ServerWebInputException(
                "slug 格式不正确，需匹配 [a-z0-9][a-z0-9-]{0,63}"));
        }
        if (!isValidTargetUrl(body.targetUrl())) {
            return Mono.error(new ServerWebInputException(
                "目标链接必须是合法的 http/https 地址"));
        }
        return findBySlug(slug)
            .filter(existing -> selfName == null
                || !selfName.equals(existing.getMetadata().getName()))
            .flatMap(existing -> Mono.error(new ResponseStatusException(
                HttpStatus.CONFLICT, "slug 已被其他链接占用")))
            .then();
    }

    private static boolean isValidTargetUrl(String url) {
        if (StringUtils.isBlank(url)) {
            return false;
        }
        try {
            var uri = URI.create(url.trim());
            var scheme = uri.getScheme();
            return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
                && StringUtils.isNotBlank(uri.getHost());
        } catch (IllegalArgumentException e) {
            return false;
        }
    }

    private static void applyRequest(ProtectedLink.Spec spec, LinkRequest body) {
        spec.setSlug(body.slug().trim());
        spec.setDisplayName(StringUtils.defaultIfBlank(
            StringUtils.trimToNull(body.displayName()), spec.getSlug()));
        spec.setDescription(StringUtils.trimToNull(body.description()));
        spec.setTargetUrl(body.targetUrl().trim());
        spec.setRequireEmailVerify(body.requireEmailVerify());
        spec.setEnabled(body.enabled());
    }

    // ---------- records ----------

    private Mono<ServerResponse> listRecords(ServerRequest request) {
        var linkSlug = request.queryParam("linkSlug")
            .map(StringUtils::trimToNull)
            .orElse(null);
        var page = parsePositiveInt(request, "page", 1);
        var size = Math.min(parsePositiveInt(request, "size", 20), MAX_PAGE_SIZE);
        var optionsBuilder = ListOptions.builder();
        if (linkSlug != null) {
            optionsBuilder.fieldQuery(QueryFactory.equal("spec.linkSlug", linkSlug));
        }
        var pageRequest = PageRequestImpl.of(page, size,
            Sort.by(Sort.Direction.DESC, "spec.visitedAt"));
        return client.listBy(LinkVisitRecord.class, optionsBuilder.build(), pageRequest)
            .flatMap(result -> ServerResponse.ok().bodyValue(result));
    }

    private Mono<ServerResponse> deleteRecord(ServerRequest request) {
        return client.fetch(LinkVisitRecord.class, request.pathVariable("name"))
            .switchIfEmpty(Mono.error(new ResponseStatusException(
                HttpStatus.NOT_FOUND, "访问记录不存在")))
            .flatMap(client::delete)
            .then(ServerResponse.ok().build());
    }

    private Mono<ServerResponse> exportRecords(ServerRequest request) {
        var linkSlug = request.queryParam("linkSlug")
            .map(StringUtils::trimToNull)
            .orElse(null);
        var records = linkSlug == null
            ? client.listAll(LinkVisitRecord.class, new ListOptions(), Sort.unsorted())
            : recordsOfSlug(linkSlug);
        return records
            .sort(Comparator.comparing(LinkConsoleEndpoint::visitedAt,
                Comparator.nullsLast(Comparator.reverseOrder())))
            .collectList()
            .map(LinkConsoleEndpoint::toCsv)
            .flatMap(csv -> ServerResponse.ok()
                .contentType(CSV_MEDIA_TYPE)
                .header(HttpHeaders.CONTENT_DISPOSITION, CSV_DISPOSITION)
                .bodyValue(csv.getBytes(StandardCharsets.UTF_8)));
    }

    // ---------- references ----------

    private Mono<ServerResponse> getReferences(ServerRequest request) {
        return referenceService.linkReferences()
            .flatMap(refs -> ServerResponse.ok().bodyValue(refs));
    }

    private Mono<ServerResponse> refreshReferences(ServerRequest request) {
        return referenceService.refreshLinkReferences()
            .flatMap(refs -> ServerResponse.ok().bodyValue(refs));
    }

    // ---------- helpers ----------

    private Mono<LinkVo> toVo(ProtectedLink link) {
        return referenceService.linkReferences()
            .onErrorResume(e -> Mono.just(Map.of()))
            .flatMap(refMap -> toVo(link, refMap));
    }

    private Mono<LinkVo> toVo(ProtectedLink link,
                              Map<String, List<PostReferenceService.PostRef>> refMap) {
        var spec = link.getSpec();
        var slug = spec == null ? null : spec.getSlug();
        var records = StringUtils.isBlank(slug)
            ? Mono.just(List.<LinkVisitRecord>of())
            : recordsOfSlug(slug).collectList();
        return records.map(recordList -> {
            var identities = new LinkedHashSet<String>();
            for (var record : recordList) {
                var recordSpec = record.getSpec();
                if (recordSpec == null) {
                    continue;
                }
                if (StringUtils.isNotBlank(recordSpec.getEmail())) {
                    identities.add("e:" + recordSpec.getEmail());
                } else if (StringUtils.isNotBlank(recordSpec.getIp())) {
                    identities.add("i:" + recordSpec.getIp());
                }
            }
            var referenceCount = slug == null ? 0
                : refMap.getOrDefault(slug, List.of()).size();
            var stats = new LinkStats(recordList.size(), identities.size(),
                referenceCount);
            return LinkVo.from(link, stats);
        });
    }

    private Flux<LinkVisitRecord> recordsOfSlug(String slug) {
        var options = ListOptions.builder()
            .fieldQuery(QueryFactory.equal("spec.linkSlug", slug))
            .build();
        return client.listAll(LinkVisitRecord.class, options, Sort.unsorted());
    }

    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 int parsePositiveInt(ServerRequest request, String name, int fallback) {
        return request.queryParam(name)
            .map(value -> {
                try {
                    return Math.max(1, Integer.parseInt(value.trim()));
                } catch (NumberFormatException e) {
                    return fallback;
                }
            })
            .orElse(fallback);
    }

    private static Instant creationTimestamp(ProtectedLink link) {
        return link.getMetadata() == null ? null
            : link.getMetadata().getCreationTimestamp();
    }

    private static Instant visitedAt(LinkVisitRecord record) {
        return record.getSpec() == null ? null : record.getSpec().getVisitedAt();
    }

    private static String toCsv(List<LinkVisitRecord> records) {
        var csv = new StringBuilder(CSV_BOM);
        csv.append("访问时间,链接Slug,邮箱,IP,User-Agent");
        for (var record : records) {
            var spec = record.getSpec();
            csv.append('\n')
                .append(csvField(instantText(visitedAt(record)))).append(',')
                .append(csvField(spec == null ? null : spec.getLinkSlug())).append(',')
                .append(csvField(spec == null ? null : spec.getEmail())).append(',')
                .append(csvField(spec == null ? null : spec.getIp())).append(',')
                .append(csvField(spec == null ? null : spec.getUserAgent()));
        }
        return csv.toString();
    }

    private static String instantText(Instant instant) {
        return instant == null ? "" : instant.toString();
    }

    private static String csvField(String value) {
        if (value == null) {
            return "";
        }
        if (value.contains(",") || value.contains("\"") || value.contains("\n")
            || value.contains("\r")) {
            return '"' + value.replace("\"", "\"\"") + '"';
        }
        return value;
    }

    @Override
    public GroupVersion groupVersion() {
        return GroupVersion.parseAPIVersion("console.api.sharelink.halo.run/v1alpha1");
    }

    public record LinkRequest(String slug, String displayName, String description,
                              String targetUrl, boolean requireEmailVerify,
                              boolean enabled) {
    }

    public record LinkStats(long visitCount, long visitorCount, long referenceCount) {
    }

    public record LinkVo(String name, String slug, String displayName, String description,
                         String targetUrl, boolean requireEmailVerify, boolean enabled,
                         String visitUrl, Instant creationTimestamp, LinkStats stats) {

        static LinkVo from(ProtectedLink link, LinkStats stats) {
            var spec = link.getSpec();
            var name = link.getMetadata() == null ? null
                : link.getMetadata().getName();
            var creationTimestamp = LinkConsoleEndpoint.creationTimestamp(link);
            if (spec == null) {
                return new LinkVo(name, null, null, null, null, false, false, null,
                    creationTimestamp, stats);
            }
            return new LinkVo(name, spec.getSlug(), spec.getDisplayName(),
                spec.getDescription(), spec.getTargetUrl(), spec.isRequireEmailVerify(),
                spec.isEnabled(), "/link/" + spec.getSlug(), creationTimestamp, stats);
        }
    }
}
