package com.r35157.service.notification.impl.discord;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.r35157.service.notification.BoundNotificationService;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jetbrains.annotations.NotNull;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;

/**
 * Synchronously delivers bound notification messages through a Discord
 * incoming webhook.
 */
public class DiscordNotificationServiceImpl implements BoundNotificationService {

    /**
     * Creates a service bound to a canonical Discord incoming-webhook endpoint.
     *
     * <p>The supplied user agent must use Discord's canonical format:
     * {@code DiscordBot (<client-url>, <version-number>)}. For example:
     * {@code DiscordBot (https://example.com/my-project, 1.0)}.
     *
     * <p>The client URL and version must identify the application or library
     * configuring this service, rather than this transport implementation.
     * Discord may reject or block HTTP requests that do not provide a valid
     * user agent.
     *
     * @param discordWebhookEndpoint Discord incoming-webhook endpoint
     * @param userAgent complete Discord HTTP user agent in the canonical
     *                  {@code DiscordBot (<client-url>, <version-number>)} format
     * @param timeout connection and individual request timeout
     * @throws IllegalArgumentException if the endpoint is not a structurally
     *                                  valid canonical Discord incoming-webhook
     *                                  endpoint, if the user agent does not use
     *                                  the required Discord format, or if the
     *                                  timeout is null, zero, or negative
     */
    public DiscordNotificationServiceImpl(
            @NotNull ΩRestEndpointΩ discordWebhookEndpoint,
            @NotNull ΩUserAgentΩ userAgent,
            @NotNull Duration timeout
    ) {
        this.discordWebhookEndpoint = validateDiscordWebhookEndpoint(discordWebhookEndpoint);
        this.userAgent = validateDiscordUserAgent(userAgent);
        this.timeout = validateTimeout(timeout);

        this.httpClient = HttpClient.newBuilder()
                .connectTimeout(this.timeout)
                .followRedirects(HttpClient.Redirect.NEVER)
                .build();
    }

    @Override
    public void push(@NotNull ΩNotificationMessageΩ message) throws IOException {
        validateDiscordMessage(message);

        HttpRequest request = createRequest(message);
        HttpResponse<String> response;

        try {
            response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        } catch(InterruptedException ie) {
            Thread.currentThread().interrupt();
            throw new IOException("Discord delivery was interrupted", ie);
        } catch(IOException ioe) {
            throw new IOException("Discord delivery failed", ioe);
        }

        if (response.statusCode() < 200 || response.statusCode() >= 300) {
            throw new IOException(createHttpFailureMessage(response));
        }
    }

    private HttpRequest createRequest(ΩNotificationMessageΩ message) throws IOException {
        try {
            String payload = OBJECT_MAPPER.writeValueAsString(Map.of("content", message));

            return HttpRequest.newBuilder()
                    .uri(createExecutionEndpoint())
                    .timeout(timeout)
                    .header("Content-Type", "application/json; charset=UTF-8")
                    .header("Accept", "application/json")
                    .header("User-Agent", userAgent)
                    .POST(HttpRequest.BodyPublishers.ofString(
                            payload,
                            StandardCharsets.UTF_8
                    ))
                    .build();
        } catch(JsonProcessingException jpe) {
            throw new IOException("Failed to serialize Discord notification message", jpe);
        } catch(RuntimeException re) {
            throw new IOException("Failed to prepare Discord delivery request", re);
        }
    }

    private ΩRestEndpointΩ createExecutionEndpoint() {
        return URI.create(discordWebhookEndpoint + "?wait=true");
    }

    private String createHttpFailureMessage(HttpResponse<String> response) {
        String responseBody = redactWebhookSecret(response.body());
        if (responseBody == null || responseBody.isBlank()) {
            return "Discord delivery failed: HTTP " + response.statusCode();
        }
        return "Discord delivery failed: HTTP " + response.statusCode()
                + ": " + responseBody;
    }

    private String redactWebhookSecret(String value) {
        if (value == null) {
            return null;
        }
        return value
                .replace(discordWebhookEndpoint.toString(), "<redacted>")
                .replace(webhookToken(), "<redacted>");
    }

    private String webhookToken() {
        String path = discordWebhookEndpoint.getPath();
        return path.substring(path.lastIndexOf('/') + 1);
    }

    private static ΩRestEndpointΩ validateDiscordWebhookEndpoint(final ΩRestEndpointΩ endpoint) {
        if (endpoint == null
                || !endpoint.isAbsolute()
                || !"https".equalsIgnoreCase(endpoint.getScheme())
                || endpoint.getHost() == null
                || !"discordapp.com".equalsIgnoreCase(endpoint.getHost())
                || endpoint.getUserInfo() != null
                || endpoint.getFragment() != null
                || endpoint.getQuery() != null
                || (endpoint.getPort() != -1 && endpoint.getPort() != 443)
                || !isCanonicalWebhookPath(endpoint.getPath()))
        {
            throw new IllegalArgumentException("Invalid Discord webhook endpoint");
        }

        return endpoint;
    }

    private static boolean isCanonicalWebhookPath(String path) {
        if (path == null) {
            return false;
        }

        String[] segments = path.split("/", -1);
        if (segments.length == 5) {
            return isUnversionedWebhookPath(segments);
        }
        if (segments.length == 6) {
            return isVersionedWebhookPath(segments);
        }
        return false;
    }

    private static boolean isUnversionedWebhookPath(String[] segments) {
        return segments[0].isEmpty()
                && "api".equals(segments[1])
                && "webhooks".equals(segments[2])
                && !segments[3].isBlank()
                && !segments[4].isBlank();
    }

    private static boolean isVersionedWebhookPath(String[] segments) {
        return segments[0].isEmpty()
                && "api".equals(segments[1])
                && isSupportedDiscordApiVersion(segments[2])
                && "webhooks".equals(segments[3])
                && !segments[4].isBlank()
                && !segments[5].isBlank();
    }

    private static boolean isSupportedDiscordApiVersion(String version) {
        return "v6".equals(version)
                || "v7".equals(version)
                || "v8".equals(version)
                || "v9".equals(version)
                || "v10".equals(version);
    }

    private static Duration validateTimeout(final Duration timeout) {
        if (timeout == null || timeout.isZero() || timeout.isNegative()) {
            throw new IllegalArgumentException("Discord timeout must be positive");
        }
        return timeout;
    }

    private static void validateDiscordMessage(final ΩNotificationMessageΩ message) {
        if (message == null || message.isBlank()) {
            throw new IllegalArgumentException("Notification message cannot be blank");
        }
        if (message.length() > DISCORD_CONTENT_LIMIT) {
            throw new IllegalArgumentException(
                    "Notification message exceeds Discord's 2,000-character limit"
            );
        }
    }

    private static ΩUserAgentΩ validateDiscordUserAgent(final ΩUserAgentΩ userAgent) {
        if (userAgent == null) {
            throw new IllegalArgumentException("Discord user agent cannot be null");
        }

        Matcher matcher = DISCORD_USER_AGENT_PATTERN.matcher(userAgent);
        if (!matcher.matches()) {
            throw new IllegalArgumentException(
                    "Discord user agent must use the format "
                            + "'DiscordBot (<client-url>, <version-number>)'"
            );
        }

        URI clientUri;
        try {
            clientUri = URI.create(matcher.group("clientUrl"));
        } catch(IllegalArgumentException iae) {
            throw new IllegalArgumentException(
                    "Discord user agent contains an invalid client URL",
                    iae
            );
        }

        if (!clientUri.isAbsolute()
                || clientUri.getHost() == null
                || (!"https".equalsIgnoreCase(clientUri.getScheme())
                && !"http".equalsIgnoreCase(clientUri.getScheme()))
                || clientUri.getUserInfo() != null
                || clientUri.getFragment() != null)
        {
            throw new IllegalArgumentException(
                    "Discord user agent must contain an absolute HTTP(S) client URL"
            );
        }

        return userAgent;
    }

    private static final int DISCORD_CONTENT_LIMIT = 2_000;
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    private static final Pattern DISCORD_USER_AGENT_PATTERN = Pattern.compile(
            "\\ADiscordBot \\((?<clientUrl>[^,\\r\\n]+), "
                    + "[0-9]+(?:\\.[0-9]+)*\\)\\z"
    );

    private final ΩUserAgentΩ userAgent;    // Example: "DiscordBot (https://git.r35157.com/r35157/com_r35157_nenjim-hubd-impl_ref, 0.1)";
    private final ΩRestEndpointΩ discordWebhookEndpoint;
    private final Duration timeout;
    private final HttpClient httpClient;
}
