package io.sqsmap.sdk;

import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.json.JSONArray;
import org.json.JSONObject;

/** Android native-map script host. UI and callbacks belong to the application. */
public final class NativeMapHost implements AutoCloseable {
    /** Encoding of owned JNI readback frames. The matching native readback targets use sRGB. */
    public enum FrameFormat {
        /** Reserved for externally supplied non-sRGB frames; NativeMapHost does not emit this format. */
        UNORM_PREMULTIPLIED_RGBA,
        /** Actual output from the matching native headless readback targets. */
        SRGB_PREMULTIPLIED_RGBA
    }
    public interface Listener {
        default void onFrame(int width, int height, byte[] rgba) {}
        /** New listeners can consume format metadata; existing three-argument listeners remain compatible. */
        default void onFrame(int width,int height,byte[] rgba,FrameFormat format){onFrame(width,height,rgba);}
        default void onEvents(String json) {}
        default void onScriptState(String json) {}
        default void onError(Throwable error) {}
    }
    public interface Task { void cancel(); }
    public interface Responder {
        CompletableFuture<Void> success(byte[] body);
        /** One WebSocket message; await completion before submitting another. */
        default CompletableFuture<Void> frame(byte[] body) {
            CompletableFuture<Void> failed=new CompletableFuture<>();
            failed.completeExceptionally(new IllegalStateException("CAPABILITY_UNAVAILABLE: streaming responder is unavailable"));
            return failed;
        }
        CompletableFuture<Void> failure(String code, String message);
    }
    public interface Transport extends AutoCloseable {
        Task start(String requestJson, Responder response) throws Exception;
        @Override default void close() {}
    }
    public enum Command {
        CAMERA(0), POINTER(1), GESTURE_INPUT(2), RESOURCE_POLICY(3), SOURCE(4), LAYER(5), IMAGE(6), SCENE(7), GESTURE(8);
        final int channel;
        Command(int channel) { this.channel = channel; }
    }
    private final ScheduledExecutorService owner = Executors.newSingleThreadScheduledExecutor(r -> new Thread(r,"SqsMap-owner"));
    private final Listener listener;
    private final Transport transport;
    private final java.util.concurrent.Semaphore responseBytes=new java.util.concurrent.Semaphore(64*1024*1024,true);
    private final Map<String,Task> jobs = new HashMap<>();
    private final AtomicBoolean closing = new AtomicBoolean();
    private final CompletableFuture<Void> closed = new CompletableFuture<>();
    private long map, session, frameSequence;
    private int width, height;
    private String lastScriptState;
    private long nextTick;
    private boolean sessionTerminal;

    private NativeMapHost(int width, int height, Listener listener, Transport transport) {
        this.width=width;this.height=height;this.listener=listener;
        this.transport=transport==null?new DefaultTransport():transport;
    }
    /** Create and use the map on a dedicated owner thread. Android API 24 or later. */
    public static CompletableFuture<NativeMapHost> open(int width,int height,Listener listener,Transport transport) {
        return open(width,height,1.0,listener,transport);
    }
    /** Logical coordinates and dense physical output; use display density for pixelRatio. */
    public static CompletableFuture<NativeMapHost> open(int width,int height,double pixelRatio,Listener listener,Transport transport) {
        if(listener==null)throw new IllegalArgumentException("Listener is required");
        NativeMapHost host=new NativeMapHost(width,height,listener,transport);
        CompletableFuture<NativeMapHost> ready=new CompletableFuture<>();
        host.owner.execute(()->{
            try {
                long[] versions=NativeBindings.versions();
                if(versions[0]!=1||versions[1]!=1||(versions[2]&7L)!=7L)throw new IllegalStateException("Map ABI/session v1 with unwind, script and session features is required; use release-capi");
                host.map=NativeBindings.create(width,height);
                if(pixelRatio!=1.0){
                    if((versions[2]&0x80000000L)==0)throw new IllegalStateException("Matching display-density map library is required");
                    NativeBindings.setViewport(host.map,width,height,pixelRatio);
                    host.refreshDimensions();
                }
                host.owner.scheduleWithFixedDelay(host::tick,0,16,TimeUnit.MILLISECONDS);
                ready.complete(host);
            } catch(Throwable error) {host.closeAsync().whenComplete((ignored,cleanup)->{if(cleanup!=null)error.addSuppressed(cleanup);ready.completeExceptionally(error);});}
        });
        return ready;
    }
    private static byte[] utf8(String value) {return value.getBytes(StandardCharsets.UTF_8);}
    private static String text(byte[] value) {return new String(value,StandardCharsets.UTF_8);}
    private interface Operation<T> {T run() throws Exception;}
    private final java.util.concurrent.Semaphore operationSlots=new java.util.concurrent.Semaphore(32);
    private <T> CompletableFuture<T> submit(Operation<T> operation) {
        CompletableFuture<T> result=new CompletableFuture<>();
        if(closing.get()){result.completeExceptionally(new IllegalStateException("Map host is closed"));return result;}
        if(!operationSlots.tryAcquire()){result.completeExceptionally(new IllegalStateException("RESOURCE_LIMIT: Android owner operation queue exceeded 32"));return result;}
        try {owner.execute(()->{
            try {
            if(closing.get()||map==0){result.completeExceptionally(new IllegalStateException("Map host is closed"));return;}
            try {result.complete(operation.run());}catch(Throwable error){result.completeExceptionally(error);}
            } finally {operationSlots.release();}
        });}catch(RejectedExecutionException error){operationSlots.release();result.completeExceptionally(error);}
        return result;
    }
    /** Replace the previous retained session; import {map} from 'sqsmap' is unchanged. */
    public CompletableFuture<String> runScript(String module) {
        if(module==null)throw new IllegalArgumentException("Module is required");
        return submit(()->{
            stopSession();session=NativeBindings.start(map,utf8(module));lastScriptState=null;sessionTerminal=false;
            return text(NativeBindings.sessionState(session));
        });
    }
    public CompletableFuture<Void> stopScript() {return submit(()->{stopSession();return null;});}
    private void stopSession() {
        if(session==0)return;
        long previous=session;session=0;
        try {NativeBindings.stop(previous);}finally{NativeBindings.release(previous);}
        lastScriptState=null;
    }
    public CompletableFuture<String> command(Command command,String json) {
        if(command==null||json==null)throw new IllegalArgumentException("Command and JSON are required");
        return submit(()->text(NativeBindings.command(map,command.channel,utf8(json))));
    }
    public CompletableFuture<String> getState() {return submit(()->text(NativeBindings.read(map,0)));}
    public CompletableFuture<String> getAttributions() {return submit(()->text(NativeBindings.read(map,5)));}
    public CompletableFuture<String> getStyle() {return submit(()->text(NativeBindings.read(map,6)));}
    public CompletableFuture<String> getCapabilities() {return submit(()->new JSONObject(text(NativeBindings.read(map,3))).put("touchNavigation",true).toString());}
    public CompletableFuture<Void> resize(int width,int height) {
        return submit(()->{NativeBindings.resize(map,width,height);refreshDimensions();return null;});
    }
    /** Resize in logical units; frame callback dimensions remain physical pixels. */
    public CompletableFuture<Void> resize(int logicalWidth,int logicalHeight,double pixelRatio){
        return submit(()->{NativeBindings.setViewport(map,logicalWidth,logicalHeight,pixelRatio);refreshDimensions();return null;});
    }
    private void refreshDimensions() throws Exception {
        JSONObject state=new JSONObject(text(NativeBindings.read(map,0)));
        width=state.getInt("width");height=state.getInt("height");
    }
    private TouchInput.Point[] touchPrevious=new TouchInput.Point[0];
    private boolean touchPan,touchZoom,touchPitch,touchRotation,panActive,zoomActive,pitchActive;
    private String touchMode;
    private TouchInput.Point touchPointer;private boolean touchTap;
    private final java.util.concurrent.atomic.AtomicInteger touchQueued=new java.util.concurrent.atomic.AtomicInteger();
    private void touchPolicy(JSONObject policy) throws Exception {
        if(!policy.getBoolean("dragPan")){touchPan=false;touchActivity("dragPan",false);}
        if(!policy.getBoolean("touchZoomRotate")){touchZoom=false;touchActivity("touchZoomRotate",false);}
        if(!policy.getBoolean("touchPitch")){touchPitch=false;touchActivity("touchPitch",false);}
        if(!policy.getBoolean("touchRotation"))touchRotation=false;
    }
    private JSONObject nativeCall(int channel,String method,Object... args)throws Exception{
        return new JSONObject().put("method",method).put("args",new JSONArray(args));
    }
    private Object invoke(int channel,String method,Object... args)throws Exception{
        String result=text(NativeBindings.command(map,channel,utf8(nativeCall(channel,method,args).toString())));
        return new org.json.JSONTokener(result).nextValue();
    }
    private void touchActivity(String name,boolean active)throws Exception{
        boolean before=name.equals("dragPan")?panActive:name.equals("touchZoomRotate")?zoomActive:pitchActive;
        if(before==active)return;
        NativeBindings.command(map,2,utf8(new JSONObject().put("handler",name).put("active",active).toString()));
        if(name.equals("dragPan"))panActive=active;else if(name.equals("touchZoomRotate"))zoomActive=active;else pitchActive=active;
    }
    private void endTouch()throws Exception{touchActivity("dragPan",false);touchActivity("touchZoomRotate",false);touchActivity("touchPitch",false);touchPrevious=new TouchInput.Point[0];touchMode=null;touchTap=false;touchPointer=null;}
    private void touchPointer(String type,TouchInput.Point point,JSONObject state)throws Exception{
        if(point==null)return;double ratio=state.getDouble("pixelRatio");
        if(point.x<0||point.y<0||point.x>state.getDouble("width")/ratio||point.y>state.getDouble("height")/ratio)return;
        NativeBindings.command(map,1,utf8(new JSONObject().put("type",type).put("point",new JSONObject().put("x",point.x).put("y",point.y)).put("pointerType","touch").toString()));
    }
    void reportInputError(Throwable error){if(!closing.get())report(error);}
    /** Recognized touch navigation; input is copied, converted from View to logical coordinates. */
    public CompletableFuture<Void> touch(TouchInput input){
        if(input==null)throw new IllegalArgumentException("Touch frame is required");
        final TouchInput frame=new TouchInput(input.phase,input.viewWidth,input.viewHeight,input.points);
        if(touchQueued.incrementAndGet()>32&&frame.phase==TouchInput.Phase.MOVE){touchQueued.decrementAndGet();CompletableFuture<Void> failed=new CompletableFuture<>();failed.completeExceptionally(new IllegalStateException("RESOURCE_LIMIT: touch input queue is full"));return failed;}
        CompletableFuture<Void> result=submit(()->{drainEvents();if(frame.phase==TouchInput.Phase.CANCEL){endTouch();return null;}
            JSONObject state=new JSONObject(text(NativeBindings.read(map,0)));
            if(frame.points.length==0){touchPointer("mouseup",touchPointer,state);if(touchTap)touchPointer("click",touchPointer,state);endTouch();return null;}
            double ratio=state.getDouble("pixelRatio"),sx=state.getDouble("width")/frame.viewWidth/ratio,sy=state.getDouble("height")/frame.viewHeight/ratio;
            TouchInput.Point[] points=new TouchInput.Point[frame.points.length];for(int i=0;i<points.length;i++){TouchInput.Point p=frame.points[i];points[i]=new TouchInput.Point(p.id,p.x*sx,p.y*sy);}
            JSONObject policy=(JSONObject)invoke(8,"sqs.gesturesState");if(!policy.has("touchZoomRotate"))throw new IllegalStateException("CAPABILITY_UNAVAILABLE: matching touch-navigation library required");touchPolicy(policy);
            if(frame.phase!=TouchInput.Phase.MOVE||touchPrevious.length!=points.length){boolean tap=frame.phase==TouchInput.Phase.DOWN&&points.length==1&&touchPrevious.length==0;endTouch();touchPointer=points[0];touchTap=tap;touchPointer(frame.phase==TouchInput.Phase.DOWN?"mousedown":"mouseup",touchPointer,state);touchPrevious=points;touchPan=policy.getBoolean("dragPan");touchZoom=policy.getBoolean("touchZoomRotate");touchRotation=policy.getBoolean("touchRotation");String projection=state.getJSONObject("cameraState").getString("projection");touchPitch=policy.getBoolean("touchPitch")&&!projection.equals("geographic");return null;}
            TouchInput.Delta delta=TouchInput.delta(touchPrevious,points);touchPrevious=points;touchPointer=points[0];touchPointer("mousemove",touchPointer,state);if(delta==null)return null;
            if(Math.hypot(delta.dx,delta.dy)<.2&&Math.abs(delta.factor-1)<.002&&Math.abs(delta.rotation)<.2)return null;
            touchTap=false;
            if(touchMode==null)touchMode=points.length==1?"single":delta.pitchCandidate&&touchPitch?"pitch":"zoom";
            if(touchMode.equals("pitch")){if(touchPitch){touchActivity("touchPitch",true);double pitch=((Number)invoke(0,"getPitch")).doubleValue();invoke(0,"setPitch",Math.max(0,Math.min(65,pitch+delta.pitch)));}return null;}
            if(touchPan&&(delta.dx!=0||delta.dy!=0)){touchActivity("dragPan",true);invoke(0,"panBy",new JSONArray(new double[]{-delta.dx,-delta.dy}),new JSONObject().put("duration",0));}
            if(touchMode.equals("zoom")&&touchZoom&&(delta.factor!=1||touchRotation&&delta.rotation!=0)){
                touchActivity("touchZoomRotate",true);String projection=state.getJSONObject("cameraState").getString("projection");JSONObject options=new JSONObject().put("duration",0);
                if(!projection.equals("globe")){JSONObject anchor=(JSONObject)invoke(0,"unproject",new JSONObject().put("x",delta.x).put("y",delta.y));options.put("around",new JSONArray(new double[]{anchor.getDouble("lng"),anchor.getDouble("lat")}));}
                if(delta.factor!=1){double zoom=((Number)invoke(0,"getZoom")).doubleValue();invoke(0,"zoomTo",Math.max(0,Math.min(24,zoom+Math.log(delta.factor)/Math.log(2))),options);}
                if(touchRotation&&delta.rotation!=0){double bearing=((Number)invoke(0,"getBearing")).doubleValue();invoke(0,"rotateTo",bearing+delta.rotation,options);}
            }
            return null;
        });
        result.whenComplete((value,error)->touchQueued.decrementAndGet());return result;
    }
    private void drainEvents()throws Exception{
        String events=text(NativeBindings.read(map,2));JSONArray values=new JSONArray(events);
        for(int i=0;i<values.length();i++){JSONObject event=values.getJSONObject(i);JSONObject gesture=event.optJSONObject("gestureEvent");if(gesture!=null&&gesture.optJSONObject("gestures")!=null)touchPolicy(gesture.getJSONObject("gestures"));}
        if(values.length()!=0)safe(()->listener.onEvents(events));
    }
    private void safe(Runnable callback) {
        try {callback.run();}catch(Throwable error){report(error);}
    }
    private void report(Throwable error) {
        try{listener.onError(error);}catch(Throwable ignored){/* Application callback must not kill the owner thread. */}
    }
    private void tick() {
        if(closing.get()||map==0||System.nanoTime()<nextTick)return;
        try {
            if(session!=0&&!sessionTerminal){
                String state;
                try {state=text(NativeBindings.pump(session));}
                catch(IllegalStateException error){state=text(NativeBindings.sessionState(session));report(error);}
                String phase=new JSONObject(state).getString("state");sessionTerminal="succeeded".equals(phase)||"failed".equals(phase)||"cancelled".equals(phase);
                if(!state.equals(lastScriptState)){lastScriptState=state;final String snapshot=state;safe(()->listener.onScriptState(snapshot));}
            }
            drainResources();
            drainEvents();
            JSONObject state=new JSONObject(text(NativeBindings.read(map,0)));
            JSONObject lifecycle=state.getJSONObject("lifecycle"),work=lifecycle.getJSONObject("work");
            if(work.optBoolean("pendingRender")||work.optBoolean("animating")||lifecycle.optLong("frameSequence")!=frameSequence){
                byte[] rgba=NativeBindings.read(map,4);
                frameSequence=new JSONObject(text(NativeBindings.read(map,0))).getJSONObject("lifecycle").getLong("frameSequence");
                FrameFormat format=FrameFormat.SRGB_PREMULTIPLIED_RGBA;
                safe(()->listener.onFrame(width,height,rgba,format));
            }
        }catch(Throwable error){nextTick=System.nanoTime()+TimeUnit.SECONDS.toNanos(1);report(error);}
    }
    private void drainResources() throws Exception {
        JSONArray requests=new JSONArray(text(NativeBindings.read(map,1)));
        for(int i=0;i<requests.length();i++){
            JSONObject request=requests.getJSONObject(i);String generation=request.getString("generation");
            if(request.optBoolean("cancel")){Task job=jobs.remove(generation);if(job!=null)job.cancel();continue;}
            final boolean streaming="websocket".equals(request.getJSONObject("resource").optString("transport"));
            final long responseLimit=request.getJSONObject("resource").getLong("maxBytes");
            if(responseLimit<1||responseLimit>64*1024*1024)throw new IllegalStateException("Invalid native response budget");
            final Responder response=new Responder(){
                private final AtomicBoolean finished=new AtomicBoolean(),pending=new AtomicBoolean();
                private CompletableFuture<Void> reject(String message){
                    CompletableFuture<Void> failed=new CompletableFuture<>();failed.completeExceptionally(new IllegalStateException(message));return failed;
                }
                private CompletableFuture<Void> deliver(byte[] body,String code,String message,boolean frame){
                    if(closing.get())return reject("Map host is closed");
                    if(frame&&!streaming)return reject("CAPABILITY_UNAVAILABLE: only WebSocket requests accept frames");
                    if(streaming&&!frame&&code==null)return reject("INVALID_ARGUMENT: WebSocket messages require frame");
                    if(body==null)return reject("INVALID_ARGUMENT: response body is required");
                    if(body.length>responseLimit)return reject("RESOURCE_LIMIT: response exceeds request budget");
                    if(finished.get())return reject("Response already delivered");
                    final boolean terminalError=code!=null;
                    if(!terminalError&&!pending.compareAndSet(false,true))return reject("RESOURCE_LIMIT: await previous response before submitting another");
                    int size=body.length;
                    if(!responseBytes.tryAcquire(size)){if(!terminalError)pending.set(false);return reject("RESOURCE_LIMIT: response memory budget is busy");}
                    final byte[] copy;
                    try{copy=body.clone();}catch(Throwable error){responseBytes.release(size);if(!terminalError)pending.set(false);return reject("RESOURCE_LIMIT: response copy failed");}
                    if(!frame&&!finished.compareAndSet(false,true)){responseBytes.release(size);if(!terminalError)pending.set(false);return reject("Response already delivered");}
                    CompletableFuture<Void> result=submit(()->{
                        if(!jobs.containsKey(generation)||(frame&&finished.get()))return null;
                        Task terminal=frame?null:jobs.remove(generation);
                        JSONObject metadata=new JSONObject().put("generation",generation).put("id",request.getLong("id"));
                        if(code!=null){metadata.put("errorCode",code);metadata.put("error",message);}
                        try{NativeBindings.respond(map,utf8(metadata.toString()),copy);}finally{if(terminal!=null)terminal.cancel();}return null;
                    });
                    return result.whenComplete((value,error)->{responseBytes.release(size);if(!terminalError)pending.set(false);});
                }
                public CompletableFuture<Void> success(byte[] body){return deliver(body,null,null,false);}
                public CompletableFuture<Void> frame(byte[] body){return deliver(body,null,null,true);}
                public CompletableFuture<Void> failure(String code,String message){
                    if(code==null||!java.util.Arrays.asList("IO_ERROR","CANCELLED","RESOURCE_LIMIT","INVALID_ARGUMENT","ENGINE_ERROR","CAPABILITY_UNAVAILABLE").contains(code)||message==null||message.isEmpty()||message.length()>1024||utf8(message).length>1024)return reject("INVALID_ARGUMENT: typed response error required");
                    return deliver(new byte[0],code,message,false);
                }
            };
            if(jobs.size()>=32){
                JSONObject metadata=new JSONObject().put("generation",generation).put("id",request.getLong("id")).put("errorCode","RESOURCE_LIMIT").put("error","Mobile transport supports at most32 pending jobs");
                NativeBindings.respond(map,utf8(metadata.toString()),new byte[0]);continue;
            }
            // Insert before a synchronous custom transport callback can complete.
            jobs.put(generation,()->{});
            try {Task task=transport.start(request.toString(),response);if(task==null)throw new IllegalStateException("Transport returned no cancellation handle");jobs.put(generation,task);}
            catch(Exception error){response.failure("IO_ERROR","Mobile resource transport failed");report(error);}
        }
    }
    /** Completes after owner-thread destruction, final remove callbacks and all host cleanup. */
    public CompletableFuture<Void> closeAsync() {
        if(!closing.compareAndSet(false,true))return closed;
        try {owner.execute(()->{
            Throwable failure=null;
            for(Task job:jobs.values()){try{job.cancel();}catch(Throwable error){if(failure==null)failure=error;}}jobs.clear();
            try{transport.close();}catch(Throwable error){failure=error;}
            try{if(map!=0){endTouch();NativeBindings.destroy(map);}}catch(Throwable error){failure=error;}finally{map=0;}
            if(session!=0){try{String finalState=text(NativeBindings.sessionState(session));safe(()->listener.onScriptState(finalState));}catch(Throwable error){failure=error;}
                try{NativeBindings.release(session);}catch(Throwable error){failure=error;}finally{session=0;}}
            owner.shutdown();
            if(failure==null)closed.complete(null);else closed.completeExceptionally(failure);
        });}catch(RejectedExecutionException error){closed.completeExceptionally(error);}
        return closed;
    }
    /** Nonblocking; use closeAsync() when waiting for destruction is necessary. */
    @Override public void close(){closeAsync();}
}
