package io.github.shirainbown.sharelink.download;

import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.env.Environment;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.CacheControl;
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.client.WebClient;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.attachment.Constant;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.ExternalUrlSupplier;
import io.github.shirainbown.sharelink.model.DownloadResource;

/**
 * Streams the attachment bytes of a download resource to the visitor.
 *
 * <p>For the local storage policy the file is read directly from disk
 * ({work-dir}/attachments/{local-relative-path}); for other policies the file is fetched
 * through a loopback HTTP request to its permalink (carrying the internal secret header
 * so {@code UploadProtectFilter} lets it pass). The body is forwarded as a
 * {@link DataBuffer} stream without buffering the whole file in memory.
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class FileStreamer {

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

    private static final int BUFFER_SIZE = 64 * 1024;

    private final ReactiveExtensionClient client;
    private final ExternalUrlSupplier externalUrlSupplier;
    private final InternalRequestSecret internalRequestSecret;
    private final Environment environment;

    private final WebClient webClient = WebClient.builder().build();

    /**
     * Resolves the attachment for the resource and emits it only when its bytes are
     * actually available (local file exists, or a permalink is present for the loopback
     * fallback). Emits empty otherwise, so callers can avoid counting failed downloads.
     */
    public Mono<Attachment> resolveAvailable(DownloadResource resource) {
        return client.fetch(Attachment.class, resource.getSpec().getAttachmentName())
            .filter(attachment -> {
                if (resolveLocalFile(attachment) != null) {
                    return true;
                }
                var permalink = attachment.getStatus() == null
                    ? null : attachment.getStatus().getPermalink();
                return StringUtils.isNotBlank(permalink);
            });
    }

    /**
     * Streams an attachment previously resolved via {@link #resolveAvailable}.
     */
    public Mono<ServerResponse> stream(ServerRequest request, DownloadResource.Spec spec,
                                       Attachment attachment) {
        var localFile = resolveLocalFile(attachment);
        if (localFile != null) {
            return streamLocalFile(localFile, spec, attachment);
        }
        return streamViaLoopback(request, spec, attachment);
    }

    /**
     * @deprecated kept for compatibility; prefer {@link #resolveAvailable} +
     * {@link #stream(ServerRequest, DownloadResource.Spec, Attachment)}.
     */
    @Deprecated
    public Mono<ServerResponse> stream(ServerRequest request, DownloadResource resource) {
        var spec = resource.getSpec();
        return client.fetch(Attachment.class, spec.getAttachmentName())
            .flatMap(attachment -> {
                var localFile = resolveLocalFile(attachment);
                if (localFile != null) {
                    return streamLocalFile(localFile, spec, attachment);
                }
                return streamViaLoopback(request, spec, attachment);
            })
            .switchIfEmpty(errorPage(HttpStatus.NOT_FOUND, "附件不存在"));
    }

    /**
     * Resolves the on-disk path for attachments stored by the local storage policy,
     * or {@code null} when the attachment is not a local file.
     */
    private Path resolveLocalFile(Attachment attachment) {
        var annotations = MetadataUtil.nullSafeAnnotations(attachment);
        var relPath = annotations.get(Constant.LOCAL_REL_PATH_ANNO_KEY);
        if (StringUtils.isBlank(relPath)) {
            return null;
        }
        var workDir = environment.getProperty("halo.work-dir",
            System.getProperty("user.home") + "/.halo2");
        var attachmentsRoot = Paths.get(workDir).resolve("attachments").normalize();
        var file = attachmentsRoot.resolve(relPath).normalize();
        if (!file.startsWith(attachmentsRoot)) {
            log.warn("附件路径越界，已拒绝: {}", relPath);
            return null;
        }
        if (!Files.isRegularFile(file)) {
            log.warn("附件文件不存在: {}", file);
            return null;
        }
        return file;
    }

    private Mono<ServerResponse> streamLocalFile(Path file, DownloadResource.Spec spec,
        Attachment attachment) {
        return Mono.fromCallable(() -> Files.size(file))
            .subscribeOn(Schedulers.boundedElastic())
            .flatMap(size -> {
                var filename = resolveFilename(spec, attachment);
                var builder = ServerResponse.ok()
                    .cacheControl(CacheControl.noStore())
                    .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition(filename))
                    .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(size));
                var mediaType = mediaTypeOf(attachment);
                if (mediaType != null) {
                    builder.contentType(mediaType);
                }
                var body = DataBufferUtils.read(file,
                    DefaultDataBufferFactory.sharedInstance, BUFFER_SIZE);
                return builder.body(body, DataBuffer.class);
            });
    }

    private Mono<ServerResponse> streamViaLoopback(ServerRequest request,
        DownloadResource.Spec spec, Attachment attachment) {
        var permalink = attachment.getStatus() == null
            ? null : attachment.getStatus().getPermalink();
        if (StringUtils.isBlank(permalink)) {
            return errorPage(HttpStatus.NOT_FOUND, "附件暂不可用");
        }
        final URI fileUri;
        try {
            fileUri = externalUrlSupplier.getURL(request.exchange().getRequest())
                .toURI().resolve(permalink);
        } catch (URISyntaxException e) {
            log.warn("拼接附件下载地址失败: {}", permalink, e);
            return errorPage(HttpStatus.INTERNAL_SERVER_ERROR, "附件地址解析失败");
        }
        return webClient.get()
            .uri(fileUri)
            .header(InternalRequestSecret.HEADER, internalRequestSecret.value())
            .exchangeToMono(response -> {
                if (!response.statusCode().is2xxSuccessful()) {
                    log.warn("回环请求附件失败: {} -> {}", fileUri, response.statusCode());
                    return response.releaseBody()
                        .then(errorPage(HttpStatus.NOT_FOUND, "附件暂不可用"));
                }
                var filename = resolveFilename(spec, attachment);
                var builder = ServerResponse.ok()
                    .cacheControl(CacheControl.noStore())
                    .header(HttpHeaders.CONTENT_DISPOSITION,
                        contentDisposition(filename));
                response.headers().contentType().ifPresent(builder::contentType);
                var contentLength = response.headers().contentLength();
                if (contentLength.isPresent()) {
                    builder.header(HttpHeaders.CONTENT_LENGTH,
                        String.valueOf(contentLength.getAsLong()));
                }
                return builder.body(response.bodyToFlux(DataBuffer.class),
                    DataBuffer.class);
            });
    }

    private static MediaType mediaTypeOf(Attachment attachment) {
        var mediaType = attachment.getSpec() == null
            ? null : attachment.getSpec().getMediaType();
        if (StringUtils.isBlank(mediaType)) {
            return null;
        }
        try {
            return MediaType.parseMediaType(mediaType);
        } catch (Exception e) {
            return null;
        }
    }

    private static String resolveFilename(DownloadResource.Spec spec, Attachment attachment) {
        var attachmentName = attachment.getSpec() == null
            ? null : attachment.getSpec().getDisplayName();
        var filename = StringUtils.firstNonBlank(spec.getDisplayName(), attachmentName,
            "download");
        // Append the original extension when the display name carries none.
        if (StringUtils.isNotBlank(attachmentName)
            && attachmentName.contains(".")
            && !filename.contains(".")) {
            filename += attachmentName.substring(attachmentName.lastIndexOf('.'));
        }
        return filename;
    }

    private static String contentDisposition(String filename) {
        var encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8)
            .replace("+", "%20");
        return "attachment; filename*=UTF-8''" + encoded;
    }

    public static Mono<ServerResponse> errorPage(HttpStatus status, String message) {
        return ServerResponse.status(status)
            .contentType(HTML_UTF8)
            .cacheControl(CacheControl.noStore())
            .bodyValue(DownloadPageRenderer.renderErrorPage(status.value(), message));
    }
}
