package ai.testers;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;

/**
 * Testers.AI client. Fault-tolerant by design: analysis is optional extra
 * testing and never throws unless {@code strict=true} is set.
 */
public final class TestersAI {

    private final Config cfg;
    private final HttpClient http;

    public TestersAI() { this(Config.fromEnv()); }

    public TestersAI(Config cfg) {
        this.cfg = cfg;
        this.http = HttpClient.newBuilder()
                .connectTimeout(Duration.ofMillis(cfg.timeoutMs))
                .build();
    }

    public Config config() { return cfg; }

    public AnalysisResult analyzeScreenshot(Path image, Map<String, Object> context) throws Exception {
        return analyzeScreenshot(Files.readAllBytes(image), context, null, null);
    }

    public AnalysisResult analyzeScreenshot(byte[] image, Map<String, Object> context) {
        return analyzeScreenshot(image, context, null, null);
    }

    public AnalysisResult analyzeScreenshot(byte[] image, Map<String, Object> context,
                                            List<Object> checks, List<Object> testers) {
        Map<String, Object> p = new LinkedHashMap<>();
        p.put("image_base64", Base64.getEncoder().encodeToString(image));
        p.put("context", context == null ? Map.of() : context);
        attachSelection(p, checks, testers);
        return call("screenshot", "/v1/analyze/screenshot", Json.obj(p));
    }

    public AnalysisResult analyzeConsole(List<Map<String, Object>> entries, Map<String, Object> context) {
        return analyzeConsole(entries, context, null, null);
    }

    public AnalysisResult analyzeConsole(List<Map<String, Object>> entries, Map<String, Object> context,
                                         List<Object> checks, List<Object> testers) {
        Map<String, Object> p = new LinkedHashMap<>();
        p.put("entries", entries == null ? List.of() : entries);
        p.put("context", context == null ? Map.of() : context);
        attachSelection(p, checks, testers);
        return call("console", "/v1/analyze/console", Json.obj(p));
    }

    public AnalysisResult analyzeNetwork(Object harOrEntries, Map<String, Object> context) {
        return analyzeNetwork(harOrEntries, context, null, null);
    }

    public AnalysisResult analyzeNetwork(Object harOrEntries, Map<String, Object> context,
                                         List<Object> checks, List<Object> testers) {
        Map<String, Object> p = new LinkedHashMap<>();
        if (harOrEntries instanceof Map && ((Map<?, ?>) harOrEntries).containsKey("log")) {
            p.put("har", harOrEntries);
        } else {
            p.put("entries", harOrEntries);
        }
        p.put("context", context == null ? Map.of() : context);
        attachSelection(p, checks, testers);
        return call("network", "/v1/analyze/network", Json.obj(p));
    }

    public AnalysisResult analyzePageText(String text, Map<String, Object> context) {
        return analyzePageText(text, context, null, null);
    }

    public AnalysisResult analyzePageText(String text, Map<String, Object> context,
                                          List<Object> checks, List<Object> testers) {
        Map<String, Object> p = new LinkedHashMap<>();
        p.put("text", text == null ? "" : text);
        p.put("context", context == null ? Map.of() : context);
        attachSelection(p, checks, testers);
        return call("page_text", "/v1/analyze/page-text", Json.obj(p));
    }

    /** Persona-feedback request. Pass a screenshot and/or page-text plus
     *  generation hints; the server returns N personas with star ratings + comments. */
    public AnalysisResult analyzePersonas(byte[] screenshot, String pageText,
                                          Map<String, Object> context, int personas,
                                          List<String> personaTraits, List<String> attributes,
                                          Integer failBelow) {
        if (screenshot == null && (pageText == null || pageText.isEmpty()))
            throw new IllegalArgumentException("analyzePersonas requires screenshot or pageText");
        Map<String, Object> p = new LinkedHashMap<>();
        p.put("context", context == null ? Map.of() : context);
        p.put("personas", personas);
        if (screenshot != null) p.put("image_base64", Base64.getEncoder().encodeToString(screenshot));
        if (pageText != null && !pageText.isEmpty()) p.put("text", pageText);
        if (personaTraits != null && !personaTraits.isEmpty()) p.put("persona_traits", personaTraits);
        if (attributes != null && !attributes.isEmpty()) p.put("attributes", attributes);

        AnalysisResult r = call("personas", "/v1/analyze/personas", Json.obj(p));
        // Auto-promote low ratings to issues (so r.failed() works).
        if (r.ok && r.raw != null && failBelow != null) {
            Object parsed = Json.Parser.parse(r.raw);
            if (parsed instanceof Map<?, ?> root) {
                Object personasObj = ((Map<String, Object>) root).get("personas");
                if (personasObj instanceof List<?>) for (Object pp : (List<?>) personasObj) {
                    if (!(pp instanceof Map<?, ?>)) continue;
                    Map<String, Object> pm = (Map<String, Object>) pp;
                    String name = String.valueOf(pm.getOrDefault("name", "user"));
                    String comments = String.valueOf(pm.getOrDefault("comments", ""));
                    Object ratingsObj = pm.get("ratings");
                    if (!(ratingsObj instanceof Map<?, ?>)) continue;
                    for (Map.Entry<?, ?> e : ((Map<?, ?>) ratingsObj).entrySet()) {
                        try {
                            int s = ((Number) e.getValue()).intValue();
                            if (s < failBelow) {
                                AnalysisResult.Issue iss = new AnalysisResult.Issue();
                                iss.severity = s <= 3 ? "critical" : "high";
                                iss.category = "persona:" + e.getKey();
                                iss.message  = name + " rated " + e.getKey() + " " + s + "/10";
                                iss.evidence = comments.length() > 240 ? comments.substring(0, 240) : comments;
                                r.issues.add(iss);
                            }
                        } catch (Exception ignored) {}
                    }
                }
            }
        }
        return r;
    }

    /** Convenience: persona feedback with sensible defaults. */
    public AnalysisResult analyzePersonas(byte[] screenshot, Map<String, Object> context) {
        return analyzePersonas(screenshot, null, context, 3, null, null, null);
    }

    private void attachSelection(Map<String, Object> payload, List<Object> checks, List<Object> testers) {
        List<Object> combined = new ArrayList<>();
        if (checks  != null) combined.addAll(checks);
        if (testers != null) combined.addAll(testers);
        if (combined.isEmpty()) return;
        Checks.Resolved r = Checks.resolve(combined);
        if (!r.checks.isEmpty())         payload.put("checks", r.checks);
        if (!r.testers.isEmpty())        payload.put("testers", r.testers);
        if (!r.customTesters.isEmpty())  payload.put("custom_testers", r.customTesters);
        if (!r.unknown.isEmpty()) {
            log("Unknown check/tester tokens ignored: " + r.unknown
                    + ". Valid names: https://testers.ai/sdk#checks");
        }
    }

    // ------------------------------------------------------------ internal
    /** SDK-level log. Silenced by {@code config.quiet}. Writes to stderr with a
     *  prefix so CI grep filters are easy. */
    private void log(String msg) {
        if (cfg.quiet) return;
        try { System.err.println("[testersai] " + msg); } catch (Throwable ignored) {}
    }

    private static boolean looksInvalid(String key) {
        if (key == null || key.length() < 16) return true;
        return !(key.startsWith("sk_") || key.startsWith("sk-"));
    }

    private AnalysisResult call(String kind, String path, String body) {
        long started = System.currentTimeMillis();
        if (!cfg.enabled) return AnalysisResult.skipped(kind, "disabled", null, 0);
        if (cfg.apiKey == null || cfg.apiKey.isBlank()) {
            log("TESTERSAI_API_KEY is not set. Get one at https://testers.ai/sdk "
                    + "(click 'Get API key' on the docs site). "
                    + "Set TESTERSAI_QUIET=true to silence this.");
            return fail(kind, "missing_api_key", started);
        }
        if (looksInvalid(cfg.apiKey)) {
            log("TESTERSAI_API_KEY looks malformed (expected a string starting with "
                    + "'sk_'). Double-check the value from https://testers.ai/sdk.");
        }

        long deadline = started + cfg.maxRetryWaitMs;
        int attempt = 0;
        String lastErr = null;

        while (true) {
            attempt++;
            try {
                HttpRequest req = HttpRequest.newBuilder()
                        .uri(URI.create(cfg.baseUrl.replaceAll("/+$", "") + path))
                        .timeout(Duration.ofMillis(cfg.timeoutMs))
                        .header("Authorization", "Bearer " + cfg.apiKey)
                        .header("Content-Type", "application/json")
                        .header("User-Agent", "testersai-java/0.1.0")
                        .POST(HttpRequest.BodyPublishers.ofString(body))
                        .build();
                HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
                int s = resp.statusCode();
                if (s == 401 || s == 403) {
                    log("Testers.AI API rejected the key (HTTP " + s
                            + "). The key may be invalid, expired, or revoked. "
                            + "Get a fresh one at https://testers.ai/sdk.");
                    return fail(kind, "http_" + s, started);
                }
                if (s == 429 || s >= 500) {
                    lastErr = "http_" + s;
                    if (!shouldRetry(attempt, deadline)) {
                        return AnalysisResult.skipped(kind,
                                s == 429 ? "rate_limited" : "server_error",
                                lastErr, elapsed(started));
                    }
                    sleepBackoff(attempt, deadline, resp.headers().firstValue("Retry-After").orElse(null));
                    continue;
                }
                if (s >= 400) return fail(kind, "http_" + s, started);
                return AnalysisResult.parse(kind, resp.body(), elapsed(started));
            } catch (Exception e) {
                lastErr = "network:" + e.getClass().getSimpleName();
                if (!shouldRetry(attempt, deadline)) return fail(kind, lastErr, started);
                sleepBackoff(attempt, deadline, null);
            }
        }
    }

    private boolean shouldRetry(int attempt, long deadline) {
        return attempt <= cfg.maxRetries && System.currentTimeMillis() < deadline;
    }

    private void sleepBackoff(int attempt, long deadline, String retryAfter) {
        long wait = Math.min(250L * (1L << (attempt - 1)), 2000L);
        wait += ThreadLocalRandom.current().nextLong(wait / 2 + 1);
        if (retryAfter != null) {
            try {
                long ra = Long.parseLong(retryAfter) * 1000L;
                wait = Math.min(ra, wait * 2);
            } catch (NumberFormatException ignored) {}
        }
        long remaining = deadline - System.currentTimeMillis();
        if (remaining <= 0) return;
        try { Thread.sleep(Math.min(wait, remaining)); } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
        }
    }

    private AnalysisResult fail(String kind, String error, long started) {
        if (cfg.strict) throw new TestersAIException(error);
        return AnalysisResult.skipped(kind, "error", error, elapsed(started));
    }

    private static int elapsed(long started) {
        return (int) (System.currentTimeMillis() - started);
    }
}
