package com.useinsider.react;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.module.annotations.ReactModule;
import com.facebook.react.uimanager.SimpleViewManager;
import com.facebook.react.uimanager.ThemedReactContext;
import com.facebook.react.uimanager.UIManagerHelper;
import com.facebook.react.uimanager.ViewManagerDelegate;
import com.facebook.react.uimanager.annotations.ReactProp;
import com.facebook.react.uimanager.events.Event;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.viewmanagers.InsiderAppFrameViewManagerDelegate;
import com.facebook.react.viewmanagers.InsiderAppFrameViewManagerInterface;

import com.useinsider.insider.Insider;
import com.useinsider.insider.InsiderAppFramesError;
import com.useinsider.insider.InsiderAppFramesView;
import com.useinsider.insider.InsiderAppFramesViewListener;
import com.useinsider.insider.InsiderAppFramesViewStatus;

import org.json.JSONObject;

import java.util.HashMap;
import java.util.Map;

/**
 * Fabric ViewManager for the {@code InsiderAppFrameView} component, backed by the native
 * {@link InsiderAppFramesView}.
 *
 * <p>Status and error codes are forwarded as enum {@code name()}s, which must match the values in
 * {@code src/InsiderAppFrameStatus.js} and {@code src/InsiderAppFramesError.js}. There is no
 * compile-time link: a rename on one side shows up only as an unrecognized value at runtime.</p>
 */
@ReactModule(name = RNInsiderAppFrameViewManager.NAME)
public class RNInsiderAppFrameViewManager
        extends SimpleViewManager<InsiderAppFramesView>
        implements InsiderAppFrameViewManagerInterface<InsiderAppFramesView> {

    public static final String NAME = "InsiderAppFrameView";

    private final ViewManagerDelegate<InsiderAppFramesView> delegate =
            new InsiderAppFrameViewManagerDelegate<>(this);

    // Event names and payload keys must match src/specs/InsiderAppFrameNativeComponent.ts;
    // a mismatch fails silently at runtime.
    private static final String EVENT_ON_STATUS_CHANGE = "onStatusChange";
    private static final String EVENT_ON_LOAD_ERROR = "onLoadError";
    private static final String EVENT_ON_HEIGHT_CHANGE = "onHeightChange";
    private static final String EVENT_ON_DISMISS_REQUEST = "onDismissRequest";
    private static final String EVENT_ON_ACTION = "onAction";

    private static final String KEY_PLACEMENT_ID = "placementId";
    private static final String KEY_STATUS = "status";
    private static final String KEY_PREVIOUS_STATUS = "previousStatus";
    private static final String KEY_CODE = "code";
    private static final String KEY_MESSAGE = "message";
    private static final String KEY_CAUSE = "cause";
    private static final String KEY_DISMISS_CODE = "dismissCode";
    private static final String KEY_HEIGHT = "height";
    private static final String KEY_DATA = "data";

    private static final String ERROR_CODE_UNKNOWN = "UNKNOWN";
    private static final String EMPTY_ACTION_DATA = "{}";

    @NonNull
    @Override
    public String getName() {
        return NAME;
    }

    @Nullable
    @Override
    protected ViewManagerDelegate<InsiderAppFramesView> getDelegate() {
        return delegate;
    }

    @NonNull
    @Override
    protected InsiderAppFramesView createViewInstance(@NonNull ThemedReactContext context) {
        InsiderAppFramesView view = new InsiderAppFramesView(context);
        view.setAppFramesListener(new FrameListener());
        return view;
    }

    @Override
    public void onDropViewInstance(@NonNull InsiderAppFramesView view) {
        view.setAppFramesListener(null);
        super.onDropViewInstance(view);
    }

    @Override
    @ReactProp(name = "placementId")
    public void setPlacementId(InsiderAppFramesView view, @Nullable String placementId) {
        view.setPlacementId(placementId);
    }

    @Override
    public Map<String, Object> getExportedCustomDirectEventTypeConstants() {
        // Extend super's map: it registers onAccessibilityAction, which NativeProps inherits
        // from ViewProps. ViewManager's base implementation returns null.
        Map<String, Object> events = super.getExportedCustomDirectEventTypeConstants();
        if (events == null) {
            events = new HashMap<>();
        }
        for (String name : new String[]{
                EVENT_ON_STATUS_CHANGE, EVENT_ON_LOAD_ERROR,
                EVENT_ON_HEIGHT_CHANGE, EVENT_ON_DISMISS_REQUEST, EVENT_ON_ACTION}) {
            events.put(name, mapBuilderOf("registrationName", name));
        }
        return events;
    }

    private static Map<String, String> mapBuilderOf(String key, String value) {
        Map<String, String> map = new HashMap<>();
        map.put(key, value);
        return map;
    }

    private void emit(InsiderAppFramesView view, String eventName, WritableMap payload) {
        ReactContext reactContext = UIManagerHelper.getReactContext(view);
        EventDispatcher dispatcher =
                UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.getId());
        if (dispatcher == null) {
            // Not expected: Fabric records the event emitter before creating the view. Reported
            // because JS mirrors status and error from these events, so a drop would desync it.
            reportDroppedEvent(eventName, view.getId());
            return;
        }
        dispatcher.dispatchEvent(new FrameEvent(
                UIManagerHelper.getSurfaceId(view), view.getId(), eventName, payload));
    }

    /**
     * Reports through the SDK's error channel, like the rest of this bridge, rather than logcat.
     * Package-private as a test seam: {@code Insider.Instance} cannot be stubbed.
     */
    void reportDroppedEvent(String eventName, int viewId) {
        Insider.Instance.putException(new IllegalStateException(
                "Dropped " + eventName + ": no EventDispatcher for view " + viewId + " yet."));
    }

    /**
     * Reports an action payload that could not be serialized and reaches JS as {@code {}}.
     * Package-private as a test seam, like {@link #reportDroppedEvent}.
     */
    void reportUnserializableAction(int viewId) {
        Insider.Instance.putException(new IllegalStateException(
                "Action data for view " + viewId + " could not be serialized; forwarding {}."));
    }

    private static WritableMap basePayload(InsiderAppFramesView view) {
        WritableMap payload = Arguments.createMap();
        String placementId = view.getPlacementId();
        payload.putString(KEY_PLACEMENT_ID, placementId == null ? "" : placementId);
        return payload;
    }

    private static class FrameEvent extends Event<FrameEvent> {
        private final String eventName;
        private final WritableMap payload;

        FrameEvent(int surfaceId, int viewTag, String eventName, WritableMap payload) {
            super(surfaceId, viewTag);
            this.eventName = eventName;
            this.payload = payload;
        }

        @Override
        public String getEventName() {
            return eventName;
        }

        @Override
        protected WritableMap getEventData() {
            return payload;
        }

        @Override
        public boolean canCoalesce() {
            // Event coalesces same-name events by default, which would drop status transitions
            // and heights; both native SDKs deliver every event, so none of the five coalesce.
            return false;
        }
    }

    private class FrameListener implements InsiderAppFramesViewListener {
        @Override
        public void onStatusChanged(@NonNull InsiderAppFramesView view,
                                    @NonNull InsiderAppFramesViewStatus status,
                                    @NonNull InsiderAppFramesViewStatus previousStatus) {
            WritableMap payload = basePayload(view);
            payload.putString(KEY_STATUS, status.name());
            payload.putString(KEY_PREVIOUS_STATUS, previousStatus.name());
            emit(view, EVENT_ON_STATUS_CHANGE, payload);
        }

        @Override
        public void onLoadFailed(@NonNull InsiderAppFramesView view,
                                 @NonNull InsiderAppFramesError error) {
            WritableMap payload = basePayload(view);
            payload.putString(KEY_CODE, error.getCode() == null
                    ? ERROR_CODE_UNKNOWN : error.getCode().name());
            // The canonical per-code text, the same text iOS sends as localizedDescription.
            payload.putString(KEY_MESSAGE, error.getMessage() == null ? "" : error.getMessage());
            // Only the message crosses the bridge; empty rather than null because the codegen
            // payload is non-nullable.
            Throwable cause = error.getCause();
            payload.putString(KEY_CAUSE,
                    cause == null || cause.getMessage() == null ? "" : cause.getMessage());
            // NO_DISMISS_CODE (-1) unless a template reported a dismissal.
            payload.putInt(KEY_DISMISS_CODE, error.getDismissCode());
            emit(view, EVENT_ON_LOAD_ERROR, payload);
        }

        @Override
        public void onHeightChangeRequested(@NonNull InsiderAppFramesView view, int optimalHeight) {
            // The SDK reports px; React Native layout uses dp, as iOS already reports.
            double heightDp = optimalHeight / view.getResources().getDisplayMetrics().density;

            WritableMap payload = basePayload(view);
            payload.putDouble(KEY_HEIGHT, heightDp);
            // JS applies the height: Fabric always passes an EXACTLY measure spec, so the native
            // view cannot size itself.
            emit(view, EVENT_ON_HEIGHT_CHANGE, payload);
        }

        @Override
        public void onDismissRequested(@NonNull InsiderAppFramesView view) {
            // Event only: collapsing the slot is the host app's decision, as on native.
            emit(view, EVENT_ON_DISMISS_REQUEST, basePayload(view));
        }

        @Override
        public void onActionTriggered(@NonNull InsiderAppFramesView view,
                                      @NonNull JSONObject actionData) {
            WritableMap payload = basePayload(view);
            // toString() returns null when serialization fails (e.g. NaN), but the codegen
            // payload is non-nullable.
            String json = actionData.toString();
            if (json == null) {
                reportUnserializableAction(view.getId());
                json = EMPTY_ACTION_DATA;
            }
            payload.putString(KEY_DATA, json);
            emit(view, EVENT_ON_ACTION, payload);
        }
    }
}
