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.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Example client for the HarvestWave business-lookup API.
 *
 * <p>One {@link HttpClient} is shared by the process. It uses the JDK
 * HTTP/1.1 keep-alive pool, bounded before the client starts, and an explicit
 * connect timeout. {@code POST /oauth/token} uses
 * {@code grant_type=client_credentials}. The access token is reused until
 * shortly before {@code expires_in}, then a new client-credentials token is
 * requested. Lookup is {@code POST /v1/lookups} with
 * {@code Authorization: Bearer}.
 *
 * <p>Credentials are read from {@code HARVESTWAVE_CLIENT_ID} and
 * {@code HARVESTWAVE_CLIENT_SECRET}.
 */
public final class HarvestWaveClient {

    private static final URI TOKEN_URI = URI.create("https://api.harvestwave.com/oauth/token");
    private static final URI LOOKUP_URI = URI.create("https://api.harvestwave.com/v1/lookups");

    private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
    private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
    private static final int POOL_SIZE = 8;
    private static final int KEEP_ALIVE_SECONDS = 30;
    private static final long DEFAULT_EXPIRES_IN_SECONDS = 28_800L;
    private static final long RENEW_BEFORE_SECONDS = 60L;

    /**
     * Pool settings are read once, when the JDK client implementation loads.
     * A {@code connectionPoolSize} of 0 means unbounded.
     */
    private static final HttpClient HTTP = sharedClient();

    private static volatile HarvestWaveClient shared;

    private final String clientId;
    private final String clientSecret;
    private final Object tokenLock = new Object();
    private String accessToken;
    private Instant renewAt;

    private HarvestWaveClient(String clientId, String clientSecret) {
        this.clientId = clientId;
        this.clientSecret = clientSecret;
    }

    /**
     * Returns the process-wide client. Credentials come from the environment.
     */
    public static HarvestWaveClient fromEnvironment() {
        HarvestWaveClient existing = shared;
        if (existing != null) {
            return existing;
        }
        String clientId = requiredEnv("HARVESTWAVE_CLIENT_ID");
        String clientSecret = requiredEnv("HARVESTWAVE_CLIENT_SECRET");
        synchronized (HarvestWaveClient.class) {
            if (shared == null) {
                shared = new HarvestWaveClient(clientId, clientSecret);
            }
            return shared;
        }
    }

    /**
     * Looks up a phone number.
     *
     * @param phoneNumber value of the {@code phoneNumber} field
     * @return the lookup document
     */
    public LookupResult lookup(String phoneNumber) {
        if (phoneNumber == null || phoneNumber.isBlank()) {
            throw new IllegalArgumentException("phoneNumber is required");
        }
        // Connectivity checks use DEMO test numbers (NPA-555, last four 0001-0199), not a credit purchase.
        String body = lookupJson(phoneNumber);
        String json = post(LOOKUP_URI, body, bearer());
        return readLookup(json);
    }

    public static void main(String[] args) {
        if (args.length != 1) {
            throw new IllegalArgumentException("Usage: java HarvestWaveClient <phoneNumber>");
        }
        LookupResult result = fromEnvironment().lookup(args[0]);
        StringBuilder out = new StringBuilder();
        out.append("isBusiness=").append(result.isBusiness());
        for (Business business : result.businesses()) {
            out.append('\n')
                    .append(business.name())
                    .append(" confidence=")
                    .append(business.confidence());
        }
        System.out.println(out);
    }

    private String bearer() {
        Instant now = Instant.now();
        synchronized (tokenLock) {
            String reusable = reusableToken(now);
            if (reusable != null) {
                return reusable;
            }
            Issued issued = readToken(post(TOKEN_URI, tokenJson(clientId, clientSecret), null), now);
            accessToken = issued.accessToken();
            renewAt = issued.renewAt();
            return accessToken;
        }
    }

    /** Caller holds {@code tokenLock}. */
    private String reusableToken(Instant now) {
        if (accessToken == null || renewAt == null || !now.isBefore(renewAt)) {
            return null;
        }
        return accessToken;
    }

    private static HttpClient sharedClient() {
        setPoolPropertyIfAbsent("jdk.httpclient.connectionPoolSize", Integer.toString(POOL_SIZE));
        setPoolPropertyIfAbsent("jdk.httpclient.keepalive.timeout", Integer.toString(KEEP_ALIVE_SECONDS));
        return HttpClient.newBuilder()
                .version(HttpClient.Version.HTTP_1_1)
                .connectTimeout(CONNECT_TIMEOUT)
                .followRedirects(HttpClient.Redirect.NEVER)
                .build();
    }

    private static void setPoolPropertyIfAbsent(String key, String value) {
        if (System.getProperty(key) == null) {
            System.setProperty(key, value);
        }
    }

    private static String requiredEnv(String name) {
        String value = System.getenv(name);
        if (value == null || value.isBlank()) {
            throw new IllegalStateException(name + " is required");
        }
        return value;
    }

    private String post(URI uri, String body, String bearer) {
        HttpRequest.Builder request = HttpRequest.newBuilder(uri)
                .timeout(REQUEST_TIMEOUT)
                .header("Accept", "application/json")
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
        if (bearer != null) {
            request.header("Authorization", "Bearer " + bearer);
        }
        try {
            HttpResponse<String> response =
                    HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            int status = response.statusCode();
            if (status < 200 || status > 299) {
                throw new Failure("POST " + uri.getPath() + " returned HTTP " + status);
            }
            return response.body();
        } catch (IOException ex) {
            throw new Failure("POST " + uri.getPath() + " failed", ex);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            throw new Failure("POST " + uri.getPath() + " was interrupted", ex);
        }
    }

    private static String tokenJson(String clientId, String clientSecret) {
        return "{\"grant_type\":\"client_credentials\",\"client_id\":"
                + quote(clientId)
                + ",\"client_secret\":"
                + quote(clientSecret)
                + "}";
    }

    private static String lookupJson(String phoneNumber) {
        return "{\"phoneNumber\":" + quote(phoneNumber) + "}";
    }

    private static String quote(String value) {
        StringBuilder out = new StringBuilder(value.length() + 2);
        out.append('"');
        for (int index = 0; index < value.length(); index++) {
            char current = value.charAt(index);
            switch (current) {
                case '"' -> out.append("\\\"");
                case '\\' -> out.append("\\\\");
                case '\b' -> out.append("\\b");
                case '\f' -> out.append("\\f");
                case '\n' -> out.append("\\n");
                case '\r' -> out.append("\\r");
                case '\t' -> out.append("\\t");
                default -> {
                    if (current < 0x20) {
                        out.append(String.format("\\u%04x", (int) current));
                    } else {
                        out.append(current);
                    }
                }
            }
        }
        out.append('"');
        return out.toString();
    }

    private static Issued readToken(String json, Instant now) {
        Map<String, Object> document = asObject(Json.parse(json), "token response");
        String token = asString(document.get("access_token"), "access_token");
        requireUsableToken(token);
        String tokenType = asString(document.get("token_type"), "token_type");
        if (!"Bearer".equals(tokenType)) {
            throw new Failure("token_type must be Bearer");
        }
        long expiresIn = document.containsKey("expires_in")
                ? asLong(document.get("expires_in"), "expires_in")
                : DEFAULT_EXPIRES_IN_SECONDS;
        if (expiresIn < 0) {
            throw new Failure("expires_in must not be negative");
        }
        long skew = renewSkewSeconds(expiresIn);
        Instant renewAt;
        try {
            renewAt = now.plusSeconds(expiresIn - skew);
        } catch (ArithmeticException | java.time.DateTimeException ex) {
            throw new Failure("expires_in is not usable", ex);
        }
        return new Issued(token, renewAt);
    }

    private static long renewSkewSeconds(long expiresInSeconds) {
        return Math.min(RENEW_BEFORE_SECONDS, Math.max(0L, expiresInSeconds - 1L));
    }

    private static void requireUsableToken(String token) {
        if (token.isEmpty()) {
            throw new Failure("access_token is empty");
        }
        for (int index = 0; index < token.length(); index++) {
            char current = token.charAt(index);
            if (current <= ' ' || current == 0x7f) {
                throw new Failure("access_token is not usable");
            }
        }
    }

    private static LookupResult readLookup(String json) {
        Map<String, Object> document = asObject(Json.parse(json), "lookup response");
        Object businessFlag = document.get("isBusiness");
        if (!(businessFlag instanceof Boolean isBusiness)) {
            throw new Failure("isBusiness must be a boolean");
        }
        Object rawBusinesses = document.get("businesses");
        if (!(rawBusinesses instanceof List<?> businesses)) {
            throw new Failure("businesses must be an array");
        }
        List<Business> parsed = new ArrayList<>();
        for (Object item : businesses) {
            Map<String, Object> business = asObject(item, "business");
            String name = asString(business.get("name"), "name");
            double confidence = asDouble(business.get("confidence"), "confidence");
            parsed.add(new Business(name, confidence));
        }
        return new LookupResult(isBusiness, parsed);
    }

    private static Map<String, Object> asObject(Object value, String field) {
        if (!(value instanceof Map<?, ?> map)) {
            throw new Failure(field + " must be a JSON object");
        }
        Map<String, Object> typed = new LinkedHashMap<>();
        for (Map.Entry<?, ?> entry : map.entrySet()) {
            if (!(entry.getKey() instanceof String key)) {
                throw new Failure(field + " has a non-text field");
            }
            typed.put(key, entry.getValue());
        }
        return typed;
    }

    private static String asString(Object value, String field) {
        if (!(value instanceof String text)) {
            throw new Failure(field + " must be a string");
        }
        return text;
    }

    private static long asLong(Object value, String field) {
        if (value instanceof Long number) {
            return number;
        }
        if (value instanceof Double number
                && !number.isNaN()
                && !number.isInfinite()
                && number == Math.rint(number)
                && number <= Long.MAX_VALUE
                && number >= Long.MIN_VALUE) {
            return number.longValue();
        }
        throw new Failure(field + " must be an integer");
    }

    private static double asDouble(Object value, String field) {
        if (!(value instanceof Number number)) {
            throw new Failure(field + " must be a number");
        }
        double parsed = number.doubleValue();
        if (Double.isNaN(parsed) || Double.isInfinite(parsed)) {
            throw new Failure(field + " must be a finite number");
        }
        return parsed;
    }

    /** Lookup document from {@code POST /v1/lookups}. */
    public record LookupResult(boolean isBusiness, List<Business> businesses) {
        public LookupResult {
            businesses = List.copyOf(businesses);
        }
    }

    /** One element of {@code businesses}. */
    public record Business(String name, double confidence) {
        public Business {
            if (name == null) {
                throw new IllegalArgumentException("name is required");
            }
            if (Double.isNaN(confidence) || Double.isInfinite(confidence)) {
                throw new IllegalArgumentException("confidence must be finite");
            }
        }
    }

    /** Call failed. The message does not include credentials. */
    public static final class Failure extends RuntimeException {
        private static final long serialVersionUID = 1L;

        Failure(String message) {
            super(message);
        }

        Failure(String message, Throwable cause) {
            super(message, cause);
        }
    }

    private record Issued(String accessToken, Instant renewAt) {}

    /** Small JSON reader for the token and lookup documents. Unknown fields are kept, then ignored. */
    private static final class Json {
        private final String text;
        private int index;

        private Json(String text) {
            this.text = text == null ? "" : text;
        }

        static Object parse(String text) {
            Json reader = new Json(text);
            Object value = reader.readValue();
            reader.skipWs();
            if (reader.index != reader.text.length()) {
                throw new Failure("Invalid JSON: trailing data");
            }
            return value;
        }

        private Object readValue() {
            skipWs();
            if (index >= text.length()) {
                throw fail("unexpected end");
            }
            char current = text.charAt(index);
            return switch (current) {
                case '{' -> readObject();
                case '[' -> readArray();
                case '"' -> readString();
                case 't' -> readLiteral("true", Boolean.TRUE);
                case 'f' -> readLiteral("false", Boolean.FALSE);
                case 'n' -> readLiteral("null", null);
                case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> readNumber();
                default -> throw fail("unexpected character");
            };
        }

        private Map<String, Object> readObject() {
            expect('{');
            Map<String, Object> map = new LinkedHashMap<>();
            if (consume('}')) {
                return map;
            }
            while (true) {
                skipWs();
                String key = readString();
                expect(':');
                Object value = readValue();
                if (map.containsKey(key)) {
                    throw fail("duplicate field");
                }
                map.put(key, value);
                if (consume('}')) {
                    return map;
                }
                expect(',');
            }
        }

        private List<Object> readArray() {
            expect('[');
            List<Object> list = new ArrayList<>();
            if (consume(']')) {
                return list;
            }
            while (true) {
                list.add(readValue());
                if (consume(']')) {
                    return list;
                }
                expect(',');
            }
        }

        private String readString() {
            if (index >= text.length() || text.charAt(index) != '"') {
                throw fail("expected string");
            }
            index++;
            StringBuilder out = new StringBuilder();
            while (index < text.length()) {
                char current = text.charAt(index++);
                if (current == '"') {
                    return out.toString();
                }
                if (current == '\\') {
                    if (index >= text.length()) {
                        throw fail("truncated escape");
                    }
                    char escaped = text.charAt(index++);
                    switch (escaped) {
                        case '"', '\\', '/' -> out.append(escaped);
                        case 'b' -> out.append('\b');
                        case 'f' -> out.append('\f');
                        case 'n' -> out.append('\n');
                        case 'r' -> out.append('\r');
                        case 't' -> out.append('\t');
                        case 'u' -> out.append((char) readHex4());
                        default -> throw fail("bad escape");
                    }
                } else if (current < 0x20) {
                    throw fail("raw control character");
                } else {
                    out.append(current);
                }
            }
            throw fail("unterminated string");
        }

        private int readHex4() {
            if (index + 4 > text.length()) {
                throw fail("truncated unicode escape");
            }
            int codePoint = 0;
            for (int step = 0; step < 4; step++) {
                int digit = Character.digit(text.charAt(index++), 16);
                if (digit < 0) {
                    throw fail("bad unicode escape");
                }
                codePoint = (codePoint << 4) + digit;
            }
            return codePoint;
        }

        private Object readLiteral(String word, Object value) {
            if (!text.startsWith(word, index)) {
                throw fail("expected literal");
            }
            index += word.length();
            if (index < text.length() && Character.isLetterOrDigit(text.charAt(index))) {
                throw fail("bad literal");
            }
            return value;
        }

        private Number readNumber() {
            int start = index;
            if (index < text.length() && text.charAt(index) == '-') {
                index++;
            }
            if (index >= text.length() || !isDigit(text.charAt(index))) {
                throw fail("bad number");
            }
            if (text.charAt(index) == '0') {
                index++;
            } else {
                while (index < text.length() && isDigit(text.charAt(index))) {
                    index++;
                }
            }
            boolean real = false;
            if (index < text.length() && text.charAt(index) == '.') {
                real = true;
                index++;
                int fraction = index;
                while (index < text.length() && isDigit(text.charAt(index))) {
                    index++;
                }
                if (index == fraction) {
                    throw fail("bad number");
                }
            }
            if (index < text.length() && (text.charAt(index) == 'e' || text.charAt(index) == 'E')) {
                real = true;
                index++;
                if (index < text.length() && (text.charAt(index) == '+' || text.charAt(index) == '-')) {
                    index++;
                }
                int exponent = index;
                while (index < text.length() && isDigit(text.charAt(index))) {
                    index++;
                }
                if (index == exponent) {
                    throw fail("bad number");
                }
            }
            String literal = text.substring(start, index);
            try {
                if (!real) {
                    return Long.parseLong(literal);
                }
                return Double.valueOf(literal);
            } catch (NumberFormatException ex) {
                throw fail("bad number");
            }
        }

        private void expect(char wanted) {
            skipWs();
            if (index >= text.length() || text.charAt(index) != wanted) {
                throw fail("expected delimiter");
            }
            index++;
        }

        private boolean consume(char wanted) {
            skipWs();
            if (index < text.length() && text.charAt(index) == wanted) {
                index++;
                return true;
            }
            return false;
        }

        private void skipWs() {
            while (index < text.length()) {
                char current = text.charAt(index);
                if (current != ' ' && current != '\t' && current != '\n' && current != '\r') {
                    return;
                }
                index++;
            }
        }

        private static boolean isDigit(char current) {
            return current >= '0' && current <= '9';
        }

        private Failure fail(String reason) {
            return new Failure("Invalid JSON: " + reason);
        }
    }
}
