package io.github.shirainbown.sharelink.console;

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.attachment.Attachment;
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.ListResult;
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.DownloadRecord;
import io.github.shirainbown.sharelink.model.DownloadResource;
import io.github.shirainbown.sharelink.protect.UploadProtectFilter;
import io.github.shirainbown.sharelink.reference.PostReferenceService;

/**
 * Console endpoints for download resource administration, mounted under
 * {@code /apis/console.api.sharelink.halo.run/v1alpha1}, so authentication and RBAC are
 * enforced by Halo.
 */
@Component
@RequiredArgsConstructor
public class ConsoleEndpoint 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=\"download-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;
    private final UploadProtectFilter uploadProtectFilter;

    @Override
    public RouterFunction<ServerResponse> endpoint() {
        return RouterFunctions.route()
            .GET("download-resources", this::listResources)
            .POST("download-resources", this::createResource)
            .GET("download-resources/{name}", this::getResource)
            .PUT("download-resources/{name}", this::updateResource)
            .DELETE("download-resources/{name}", this::deleteResource)
            .GET("download-records", this::listRecords)
            .DELETE("download-records/{name}", this::deleteRecord)
            .GET("download-records/-/export", this::exportRecords)
            .GET("references", this::getReferences)
            .POST("references/-/refresh", this::refreshReferences)
            .build();
    }

    // ---------- resources ----------

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

    private Mono<ServerResponse> createResource(ServerRequest request) {
        return request.bodyToMono(ResourceRequest.class)
            .switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
            .flatMap(body -> validateResourceRequest(body, null)
                .then(Mono.defer(() -> {
                    var resource = new DownloadResource();
                    var metadata = new Metadata();
                    metadata.setName(UUID.randomUUID().toString());
                    resource.setMetadata(metadata);
                    var spec = new DownloadResource.Spec();
                    applyRequest(spec, body);
                    resource.setSpec(spec);
                    var status = new DownloadResource.Status();
                    status.setDownloadCount(0);
                    resource.setStatus(status);
                    return client.create(resource);
                })))
            .doOnSuccess(created -> uploadProtectFilter.invalidate())
            .flatMap(this::toVo)
            .flatMap(vo -> ServerResponse.ok().bodyValue(vo));
    }

    private Mono<ServerResponse> getResource(ServerRequest request) {
        return client.fetch(DownloadResource.class, request.pathVariable("name"))
            .switchIfEmpty(Mono.error(new ResponseStatusException(
                HttpStatus.NOT_FOUND, "下载资源不存在")))
            .flatMap(this::toVo)
            .flatMap(vo -> ServerResponse.ok().bodyValue(vo));
    }

    private Mono<ServerResponse> updateResource(ServerRequest request) {
        var name = request.pathVariable("name");
        return request.bodyToMono(ResourceRequest.class)
            .switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
            .flatMap(body -> client.fetch(DownloadResource.class, name)
                .switchIfEmpty(Mono.error(new ResponseStatusException(
                    HttpStatus.NOT_FOUND, "下载资源不存在")))
                .flatMap(resource -> validateResourceRequest(body, name)
                    .then(Mono.defer(() -> {
                        applyRequest(resource.getSpec(), body);
                        return client.update(resource);
                    }))))
            .doOnSuccess(updated -> uploadProtectFilter.invalidate())
            .flatMap(this::toVo)
            .flatMap(vo -> ServerResponse.ok().bodyValue(vo));
    }

    private Mono<ServerResponse> deleteResource(ServerRequest request) {
        var name = request.pathVariable("name");
        return client.fetch(DownloadResource.class, name)
            .switchIfEmpty(Mono.error(new ResponseStatusException(
                HttpStatus.NOT_FOUND, "下载资源不存在")))
            .flatMap(resource -> {
                var slug = resource.getSpec().getSlug();
                return client.delete(resource)
                    .then(deleteRecordsOfSlug(slug));
            })
            .doOnSuccess(ignored -> uploadProtectFilter.invalidate())
            .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 attachment existence.
     *
     * @param selfName the name of the resource being updated, excluded from the
     * uniqueness check; {@code null} on creation
     */
    private Mono<Void> validateResourceRequest(ResourceRequest 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 (StringUtils.isBlank(body.attachmentName())) {
            return Mono.error(new ServerWebInputException("附件不能为空"));
        }
        var slugAvailable = findBySlug(slug)
            .filter(existing -> selfName == null
                || !selfName.equals(existing.getMetadata().getName()))
            .flatMap(existing -> Mono.error(new ResponseStatusException(
                HttpStatus.CONFLICT, "slug 已被其他资源占用")))
            .then();
        var attachmentExists = client.fetch(Attachment.class, body.attachmentName())
            .switchIfEmpty(Mono.error(new ServerWebInputException("附件不存在")))
            .then();
        return Mono.when(slugAvailable, attachmentExists);
    }

    private static void applyRequest(DownloadResource.Spec spec, ResourceRequest body) {
        spec.setSlug(body.slug().trim());
        spec.setDisplayName(StringUtils.defaultIfBlank(
            StringUtils.trimToNull(body.displayName()), spec.getSlug()));
        spec.setDescription(StringUtils.trimToNull(body.description()));
        spec.setAttachmentName(body.attachmentName().trim());
        spec.setRequireEmailVerify(body.requireEmailVerify());
        spec.setEnabled(body.enabled());
    }

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

    private Mono<ServerResponse> listRecords(ServerRequest request) {
        var resourceSlug = request.queryParam("resourceSlug")
            .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 (resourceSlug != null) {
            optionsBuilder.fieldQuery(QueryFactory.equal("spec.resourceSlug", resourceSlug));
        }
        var pageRequest = PageRequestImpl.of(page, size,
            Sort.by(Sort.Direction.DESC, "spec.downloadedAt"));
        return client.listBy(DownloadRecord.class, optionsBuilder.build(), pageRequest)
            .flatMap(result -> ServerResponse.ok().bodyValue(result));
    }

    private Mono<ServerResponse> deleteRecord(ServerRequest request) {
        return client.fetch(DownloadRecord.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 resourceSlug = request.queryParam("resourceSlug")
            .map(StringUtils::trimToNull)
            .orElse(null);
        var records = resourceSlug == null
            ? client.listAll(DownloadRecord.class, new ListOptions(), Sort.unsorted())
            : recordsOfSlug(resourceSlug);
        return records
            .sort(Comparator.comparing(ConsoleEndpoint::downloadedAt,
                Comparator.nullsLast(Comparator.reverseOrder())))
            .collectList()
            .map(ConsoleEndpoint::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.references()
            .flatMap(refs -> ServerResponse.ok().bodyValue(refs));
    }

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

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

    private Mono<ResourceVo> toVo(DownloadResource resource) {
        return referenceService.references()
            .onErrorResume(e -> Mono.just(Map.of()))
            .flatMap(refMap -> toVo(resource, refMap));
    }

    private Mono<ResourceVo> toVo(DownloadResource resource,
                                  Map<String, List<PostReferenceService.PostRef>> refMap) {
        var spec = resource.getSpec();
        var slug = spec == null ? null : spec.getSlug();
        var records = StringUtils.isBlank(slug)
            ? Mono.just(List.<DownloadRecord>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 ResourceStats(recordList.size(), identities.size(),
                referenceCount);
            return ResourceVo.from(resource, stats);
        });
    }

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

    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 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(DownloadResource resource) {
        return resource.getMetadata() == null ? null
            : resource.getMetadata().getCreationTimestamp();
    }

    private static Instant downloadedAt(DownloadRecord record) {
        return record.getSpec() == null ? null : record.getSpec().getDownloadedAt();
    }

    private static String toCsv(List<DownloadRecord> 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(downloadedAt(record)))).append(',')
                .append(csvField(spec == null ? null : spec.getResourceSlug())).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 ResourceRequest(String slug, String displayName, String description,
                                  String attachmentName, boolean requireEmailVerify,
                                  boolean enabled) {
    }

    public record ResourceStats(long downloadCount, long downloaderCount,
                                long referenceCount) {
    }

    public record ResourceVo(String name, String slug, String displayName,
                             String description, String attachmentName,
                             boolean requireEmailVerify, boolean enabled,
                             String downloadUrl, Instant creationTimestamp,
                             ResourceStats stats) {

        static ResourceVo from(DownloadResource resource, ResourceStats stats) {
            var spec = resource.getSpec();
            var name = resource.getMetadata() == null ? null
                : resource.getMetadata().getName();
            var creationTimestamp = ConsoleEndpoint.creationTimestamp(resource);
            if (spec == null) {
                return new ResourceVo(name, null, null, null, null, false, false, null,
                    creationTimestamp, stats);
            }
            return new ResourceVo(name, spec.getSlug(), spec.getDisplayName(),
                spec.getDescription(), spec.getAttachmentName(),
                spec.isRequireEmailVerify(), spec.isEnabled(),
                "/download/" + spec.getSlug(), creationTimestamp, stats);
        }
    }
}
