package ctbrec.sites.flirt4free;

import com.iheartradio.m3u8.*;
import com.iheartradio.m3u8.data.MasterPlaylist;
import com.iheartradio.m3u8.data.Playlist;
import com.iheartradio.m3u8.data.PlaylistData;
import com.iheartradio.m3u8.data.StreamInfo;
import ctbrec.AbstractModel;
import ctbrec.Config;
import ctbrec.Model;
import ctbrec.io.HttpException;
import ctbrec.recorder.download.StreamSource;
import okhttp3.*;
import org.json.JSONArray;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static ctbrec.StringConstants.MODEL_ID;
import static ctbrec.StringConstants.STATUS;
import static ctbrec.io.HttpConstants.*;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Locale.ENGLISH;

public class Flirt4FreeModel extends AbstractModel {

    private static final Logger LOG = LoggerFactory.getLogger(Flirt4FreeModel.class);
    private String id;
    private String chatHost;
    private String chatPort;
    private String chatToken;
    private String streamHost;
    private String streamUrl;
    int[] resolution = new int[2];
    private final transient Object monitor = new Object();
    private final transient List<String> categories = new LinkedList<>();
    private boolean online = false;
    private boolean isInteractiveShow = false;
    private boolean isNew = false;
    private String userIdt = "";
    private String userIp = "0.0.0.0";

    private static final Semaphore requestThrottle = new Semaphore(2, true);
    private static volatile long lastRequest = 0;
    private long lastOnlineRequest = 0;


    @Override
    public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
        long now = System.currentTimeMillis();
        long timeSinceLastCheck = now - lastOnlineRequest;
        if (ignoreCache && timeSinceLastCheck > TimeUnit.MINUTES.toMillis(1)) {
            String url = "https://ws.vs3.com/rooms/check-model-status.php?model_name=" + getName();
            acquireSlot();
            try {
                Request request = new Request.Builder()
                        .url(url)
                        .header(ACCEPT, "*/*")
                        .header(ACCEPT_LANGUAGE, ENGLISH.getLanguage())
                        .header(REFERER, getUrl())
                        .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                        .header(X_REQUESTED_WITH, XML_HTTP_REQUEST)
                        .build();
                try (Response response = getSite().getHttpClient().execute(request)) {
                    if (response.isSuccessful()) {
                        parseOnlineState(response.body().string());
                    } else {
                        throw new HttpException(response.code(), response.message());
                    }
                }
            } finally {
                lastOnlineRequest = System.currentTimeMillis();
                releaseSlot();
            }
        }
        return online;
    }

    private void parseOnlineState(String body) {
        if (body.trim().isEmpty()) {
            return;
        }
        JSONObject json = new JSONObject(body);
        if (Objects.equals(json.optString("status"), "failed")) {
            if (Objects.equals(json.optString("message"), "Model is inactive")) {
                LOG.debug("Model inactive or deleted: {}", getName());
                setMarkedForLaterRecording(true);
            }
            online = false;
            onlineState = Model.State.OFFLINE;
            return;
        }
        online = Objects.equals(json.optString(STATUS), "online"); // online is true, even if the model is in private or away
        updateModelId(json);
        if (online) {
            try {
                loadModelInfo();
            } catch (Exception e) {
                online = false;
                onlineState = Model.State.OFFLINE;
            }
        }
    }

    private void updateModelId(JSONObject json) {
        if (json.has(MODEL_ID)) {
            Object modelId = json.get(MODEL_ID);
            if (modelId instanceof Number n) {
                if (n.intValue() > 0) {
                    id = String.valueOf(json.get(MODEL_ID));
                }
            }
        }
    }

    private void loadModelInfo() throws IOException {
        String url = getSite().getBaseUrl() + "/webservices/chat-room-interface.php?a=login_room&model_id=" + id;
        LOG.trace("Loading url {}", url);
        Request request = new Request.Builder()
                .url(url)
                .header(ACCEPT, "*/*")
                .header(ACCEPT_LANGUAGE, ENGLISH.getLanguage())
                .header(REFERER, getUrl())
                .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                .header(X_REQUESTED_WITH, XML_HTTP_REQUEST)
                .build();
        try (Response response = getSite().getHttpClient().execute(request)) {
            if (response.isSuccessful()) {
                JSONObject json = new JSONObject(response.body().string());
                if (json.optString(STATUS).equals("success")) {
                    JSONObject config = json.getJSONObject("config");
                    JSONObject performer = config.getJSONObject("performer");
                    setUrl(getSite().getBaseUrl() + "/rooms/" + getName() + '/');
                    setDisplayName(performer.optString("name", getName()));
                    JSONObject room = config.getJSONObject("room");
                    chatHost = room.getString("host");
                    chatPort = room.getString("port_to_be");
                    chatToken = json.getString("token_enc");
                    String status = room.optString(STATUS);
                    setOnlineState(mapStatus(status));
                    online = onlineState == State.ONLINE;
                    JSONObject user = config.getJSONObject("user");
                    userIp = user.getString("ip");
                } else {
                    LOG.trace("Loading model info failed. Assuming model {} is offline", getName());
                    online = false;
                    onlineState = Model.State.OFFLINE;
                }
            } else {
                throw new HttpException(response.code(), response.message());
            }
        }
    }

    private State mapStatus(String status) {
        return switch (status) {
            case "P", "F" -> State.PRIVATE;
            case "A" -> State.AWAY;
            case "O" -> State.ONLINE;
            default -> State.UNKNOWN;
        };
    }

    @Override
    public List<StreamSource> getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException {
        return getStreamSources(true);
    }

    private List<StreamSource> getStreamSources(boolean withWebsocket) throws IOException, ExecutionException, ParseException, PlaylistException {
        MasterPlaylist masterPlaylist;
        try {
            if (withWebsocket) {
                acquireSlot();
                try {
                    loadStreamUrl();
                } finally {
                    releaseSlot();
                }
            }
            masterPlaylist = getMasterPlaylist();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ExecutionException(e);
        }
        List<StreamSource> sources = new ArrayList<>();
        for (PlaylistData playlist : masterPlaylist.getPlaylists()) {
            if (playlist.hasStreamInfo()) {
                StreamSource src = new StreamSource();
                StreamInfo info = playlist.getStreamInfo();
                src.bandwidth = info.getBandwidth();
                src.height = (info.hasResolution()) ? info.getResolution().height : 0;
                src.width = (info.hasResolution()) ? info.getResolution().width : 0;
                HttpUrl masterPlaylistUrl = HttpUrl.parse(streamUrl);
                src.mediaPlaylistUrl = "https://" + masterPlaylistUrl.host() + '/' + playlist.getUri();
                LOG.trace("Media playlist {}", src.mediaPlaylistUrl);
                sources.add(src);
            }
        }
        return sources;
    }

    public MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException, InterruptedException {
        LOG.trace("Loading master playlist {}", streamUrl);
        Request req = new Request.Builder()
                .url(streamUrl)
                .header(ACCEPT, "*/*")
                .header(ACCEPT_LANGUAGE, ENGLISH.getLanguage())
                .header(REFERER, getUrl())
                .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                .header(X_REQUESTED_WITH, XML_HTTP_REQUEST)
                .build();
        acquireSlot();
        try (Response response = getSite().getHttpClient().execute(req)) {
            if (response.isSuccessful()) {
                InputStream inputStream = response.body().byteStream();
                PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT);
                Playlist playlist = parser.parse();
                MasterPlaylist master = playlist.getMasterPlaylist();
                return master;
            } else {
                throw new HttpException(response.code(), response.message());
            }
        } finally {
            releaseSlot();
        }
    }

    private void loadStreamUrl() throws IOException, InterruptedException {
        loadModelInfo();
        Objects.requireNonNull(chatHost, "chatHost is null");
        String h = chatHost.replace("chat", "chat-vip");
        String url = "https://" + h + "/chat?token=" + URLEncoder.encode(chatToken, UTF_8) + "&port_to_be=" + chatPort;
        LOG.trace("Opening chat websocket {}", url);
        Request req = new Request.Builder()
                .url(url)
                .header(ACCEPT, "*/*")
                .header(ACCEPT_LANGUAGE, ENGLISH.getLanguage())
                .header(REFERER, getUrl())
                .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                .header(X_REQUESTED_WITH, XML_HTTP_REQUEST)
                .build();

        getSite().getHttpClient().newWebSocket(req, new WebSocketListener() {
            @Override
            public void onOpen(WebSocket webSocket, Response response) {
                LOG.trace("Chat websocket for {} opened", getName());
            }

            @Override
            public void onMessage(WebSocket webSocket, String text) {
                LOG.trace("Chat wbesocket for {}: {}", getName(), text);
                JSONObject json = new JSONObject(text);
                if (json.optString("command").equals("8011")) {
                    JSONObject data = json.getJSONObject("data");
                    LOG.trace("stream info:\n{}", data.toString(2));
                    streamHost = data.getString("stream_host");
                    online = true;
                    isInteractiveShow = data.optString("devices").equals("1");
                    String roomState = data.optString("room_state");
                    onlineState = mapStatus(roomState);
                    online = onlineState == State.ONLINE;
                    if (data.optString("room_state").equals("0") && data.optString("login_group_id").equals("14")) {
                        onlineState = Model.State.GROUP;
                        online = false;
                    }
                    try {
                        resolution[0] = Integer.parseInt(data.getString("stream_width"));
                        resolution[1] = Integer.parseInt(data.getString("stream_height"));
                    } catch (Exception e) {
                        LOG.warn("Couldn't determine stream resolution", e);
                    }
                    webSocket.close(1000, "");
                }
            }

            @Override
            public void onFailure(WebSocket webSocket, Throwable t, Response response) {
                LOG.error("Chat websocket for {} failed", getName(), t);
                synchronized (monitor) {
                    monitor.notifyAll();
                }
                if (response != null) {
                    response.close();
                }
            }

            @Override
            public void onClosed(WebSocket webSocket, int code, String reason) {
                LOG.trace("Chat websocket for {} closed {} {}", getName(), code, reason);
                synchronized (monitor) {
                    monitor.notifyAll();
                }
            }
        });

        synchronized (monitor) {
            monitor.wait(10_000);
            if (streamHost == null) {
                throw new RuntimeException("Couldn't determine streaming server for model " + getName());
            } else {
                url = getSite().getBaseUrl() + "/ws/chat/get-stream-urls.php?"
                        + "model_id=" + id
                        + "&video_host=" + streamHost
                        + "&t=" + System.currentTimeMillis();
                LOG.debug("Loading master playlist information: {}", url);
                req = new Request.Builder()
                        .url(url)
                        .header(ACCEPT, "*/*")
                        .header(ACCEPT_LANGUAGE, ENGLISH.getLanguage())
                        .header(REFERER, getUrl())
                        .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                        .header(REFERER, getUrl())
                        .build();
                try (Response response = getSite().getHttpClient().execute(req)) {
                    JSONObject json = new JSONObject(Objects.requireNonNull(response.body(), "HTTP response body is null").string());
                    JSONArray hls = json.getJSONObject("data").getJSONArray("hls");
                    streamUrl = "https:" + hls.getJSONObject(0).getString("url");
                    LOG.debug("Stream URL is {}", streamUrl);
                }
            }
        }
    }

    @Override
    public void invalidateCacheEntries() {
        // nothing to do here
    }

    @Override
    public void receiveTip(Double tokens) throws IOException {
        try {
            // make sure we are logged in and all necessary model data is available
            getSite().login();
            fetchStreamUrl();

            // send the tip
            int giftId = isInteractiveShow ? 775 : 171;
            int amount = tokens.intValue();
            LOG.debug("Sending tip of {} to {}", amount, getName());
            String url = "https://ws.vs3.com/rooms/send-tip.php?" +
                    "gift_id=" + giftId +
                    "&num_credits=" + amount +
                    "&userId=" + getUserIdt() +
                    "&username=" + Config.getInstance().getSettings().flirt4freeUsername +
                    "&userIP=" + userIp +
                    "&anonymous=N&response_type=json" +
                    "&t=" + System.currentTimeMillis();
            LOG.debug("Trying to send tip: {}", url);
            Request req = new Request.Builder()
                    .url(url)
                    .header(ACCEPT, "*/*")
                    .header(ACCEPT_LANGUAGE, ENGLISH.getLanguage())
                    .header(REFERER, getUrl())
                    .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                    .header(REFERER, getUrl())
                    .header(X_REQUESTED_WITH, XML_HTTP_REQUEST)
                    .build();
            try (Response response = getSite().getHttpClient().execute(req)) {
                if (response.isSuccessful()) {
                    JSONObject json = new JSONObject(response.body().string());
                    if (json.optInt("success") != 1) {
                        String msg = json.optString("message");
                        if (json.has("error_message")) {
                            msg = json.getString("error_message");
                        }
                        LOG.error("Sending tip failed: {}", msg);
                        LOG.debug("Response: {}", json.toString(2));
                        throw new IOException(msg);
                    }
                } else {
                    throw new HttpException(response.code(), response.message());
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IOException("Couldn't acquire request slot", e);
        }
    }

    private void fetchStreamUrl() throws InterruptedException, IOException {
        acquireSlot();
        try {
            loadStreamUrl();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IOException("Couldn't send tip", e);
        } finally {
            releaseSlot();
        }
    }

    private String getUserIdt() throws IOException, InterruptedException {
        if (userIdt.isEmpty()) {
            acquireSlot();
            try {
                Request req = new Request.Builder()
                        .url(getUrl())
                        .header(ACCEPT, "*/*")
                        .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
                        .header(REFERER, getUrl())
                        .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                        .build();
                try (Response response = getSite().getHttpClient().execute(req)) {
                    if (response.isSuccessful()) {
                        String body = response.body().string();
                        Matcher m = Pattern.compile("idt\\s*:\\s*'(.*?)',").matcher(body);
                        if (m.find()) {
                            userIdt = m.group(1);
                        } else {
                            throw new IOException("userIdt not found on HTML page");
                        }
                    } else {
                        throw new HttpException(response.code(), response.message());
                    }
                }
            } finally {
                releaseSlot();
            }
        }
        return userIdt;
    }

    @Override
    public int[] getStreamResolution(boolean failFast) throws ExecutionException {
        if (!failFast && streamUrl != null && resolution[0] == 0) {
            try {
                List<StreamSource> streamSources = getStreamSources(true);
                Collections.sort(streamSources);
                StreamSource best = streamSources.get(streamSources.size() - 1);
                resolution = new int[]{best.width, best.height};
            } catch (IOException | ParseException | PlaylistException e) {
                throw new ExecutionException("Couldn't determine stream resolution", e);
            }
        }
        return resolution;
    }

    public void setStreamResolution(int[] res) {
        this.resolution = res;
    }

    @Override
    public boolean follow() throws IOException {
        try {
            return changeFavoriteStatus(true);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IOException("Couldn't change follow status for model " + getName(), e);
        }
    }

    @Override
    public boolean unfollow() throws IOException {
        try {
            isOnline(true);
            return changeFavoriteStatus(false);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IOException("Couldn't change follow status for model " + getName(), e);
        } catch (ExecutionException e) {
            throw new IOException("Couldn't change follow status for model " + getName(), e);
        }
    }

    private boolean changeFavoriteStatus(boolean add) throws IOException, InterruptedException {
        getSite().login();
        acquireSlot();
        try {
            loadModelInfo();
        } finally {
            releaseSlot();
        }
        String url = getSite().getBaseUrl() + "/external.php?a=" +
                (add ? "add_favorite" : "delete_favorite") +
                "&id=" + id +
                "&name=" + getName() +
                "&t=" + System.currentTimeMillis();
        LOG.debug("Sending follow/unfollow request: {}", url);
        Request req = new Request.Builder()
                .url(url)
                .header(ACCEPT, "*/*")
                .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
                .header(REFERER, getUrl())
                .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                .build();
        try (Response response = getSite().getHttpClient().execute(req)) {
            if (response.isSuccessful()) {
                String body = response.body().string();
                LOG.debug("Follow/Unfollow response: {}", body);
                return Objects.equals(body, "1");
            } else {
                throw new HttpException(response.code(), response.message());
            }
        }
    }

    public void setId(String id) {
        this.id = id;
    }

    @Override
    public void readSiteSpecificData(Map<String, String> data) {
        id = data.get("id");
    }

    @Override
    public void writeSiteSpecificData(Map<String, String> data) {
        data.put("id", id);
    }

    public void setStreamUrl(String streamUrl) {
        this.streamUrl = streamUrl;
    }

    public void setOnline(boolean b) {
        online = b;
    }

    public boolean isNew() {
        return isNew;
    }

    public void setNew(boolean isNew) {
        this.isNew = isNew;
    }

    @Override
    public String getName() {
        String original = super.getName();
        String fixed = original.toLowerCase().replace(" ", "-").replace("_", "-");
        if (!fixed.equals(original)) {
            setName(fixed);
        }
        return fixed;
    }

    private void acquireSlot() throws InterruptedException {
        requestThrottle.acquire();
        long now = System.currentTimeMillis();
        long millisSinceLastRequest = now - lastRequest;
        if (millisSinceLastRequest < 500) {
            Thread.sleep(500 - millisSinceLastRequest);
        }
    }

    private void releaseSlot() {
        lastRequest = System.currentTimeMillis();
        requestThrottle.release();
    }

    public List<String> getCategories() {
        return categories;
    }
}
