package ctbrec.sites.fc2live;

import static ctbrec.io.HttpConstants.*;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiConsumer;

import org.json.JSONArray;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.iheartradio.m3u8.Encoding;
import com.iheartradio.m3u8.Format;
import com.iheartradio.m3u8.ParseException;
import com.iheartradio.m3u8.PlaylistException;
import com.iheartradio.m3u8.PlaylistParser;
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 com.squareup.moshi.JsonReader;
import com.squareup.moshi.JsonWriter;

import ctbrec.AbstractModel;
import ctbrec.Config;
import ctbrec.io.HttpException;
import ctbrec.recorder.download.Download;
import ctbrec.recorder.download.StreamSource;
import okhttp3.FormBody;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
import okio.ByteString;

public class Fc2Model extends AbstractModel {
    private static final Logger LOG = LoggerFactory.getLogger(Fc2Model.class);
    private String id;
    private int viewerCount;
    private boolean online;
    private String version;
    private WebSocket ws;
    private String playlistUrl;
    private AtomicInteger websocketUsage = new AtomicInteger(0);
    private long lastHeartBeat = System.currentTimeMillis();
    private int messageId = 1;

    @Override
    public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
        if(ignoreCache) {
            loadModelInfo();
        }
        return online;
    }

    void loadModelInfo() throws IOException {
        String url = Fc2Live.BASE_URL + "/api/memberApi.php";
        RequestBody body = new FormBody.Builder()
                .add("channel", "1")
                .add("profile", "1")
                .add("streamid", id)
                .build();
        Request req = new Request.Builder()
                .url(url)
                .method("POST", body)
                .header(ACCEPT, "*/*")
                .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage())
                .header(REFERER, Fc2Live.BASE_URL)
                .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                .header(X_REQUESTED_WITH, XML_HTTP_REQUEST)
                .build();
        try(Response resp = getSite().getHttpClient().execute(req)) {
            if(resp.isSuccessful()) {
                String msg = resp.body().string();
                JSONObject json = new JSONObject(msg);
                // LOG.debug(json.toString(2));
                JSONObject data = json.getJSONObject("data");
                JSONObject channelData = data.getJSONObject("channel_data");
                online = channelData.optInt("is_publish") == 1;
                onlineState = online ? State.ONLINE : State.OFFLINE;
                if(channelData.optInt("fee") == 1) {
                    onlineState = State.PRIVATE;
                    online = false;
                }
                version = channelData.optString("version");
                if (data.has("profile_data")) {
                    JSONObject profileData = data.getJSONObject("profile_data");
                    setName(profileData.getString("name").replace('/', '_'));
                }
            } else {
                throw new IOException("HTTP status " + resp.code() + " " + resp.message());
            }
        }
    }

    @Override
    public State getOnlineState(boolean failFast) throws IOException, ExecutionException {
        if(failFast) {
            return onlineState;
        } else if(Objects.equals(onlineState, State.UNKNOWN)){
            loadModelInfo();
        }
        return onlineState;
    }

    @Override
    public List<StreamSource> getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException {
        try {
            openWebsocket();
            List<StreamSource> sources = new ArrayList<>();
            LOG.debug("Paylist url {}", playlistUrl);
            sources.addAll(parseMasterPlaylist(playlistUrl));
            return sources;
        } catch (InterruptedException e1) {
            Thread.currentThread().interrupt();
            throw new ExecutionException(e1);
        } finally {
            closeWebsocket();
        }
    }

    private List<StreamSource> parseMasterPlaylist(String playlistUrl) throws IOException, ParseException, PlaylistException {
        List<StreamSource> sources = new ArrayList<>();
        Request req = new Request.Builder()
                .url(playlistUrl)
                .header(ACCEPT, "*/*")
                .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage())
                .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                .header(ORIGIN, Fc2Live.BASE_URL)
                .header(REFERER, getUrl())
                .build();
        try(Response response = site.getHttpClient().execute(req)) {
            if(response.isSuccessful()) {
                InputStream inputStream = response.body().byteStream();
                PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8);
                Playlist playlist = parser.parse();
                MasterPlaylist master = playlist.getMasterPlaylist();
                sources.clear();
                for (PlaylistData playlistData : master.getPlaylists()) {
                    StreamSource streamsource = new StreamSource();
                    streamsource.mediaPlaylistUrl = playlistData.getUri();
                    if (playlistData.hasStreamInfo()) {
                        StreamInfo info = playlistData.getStreamInfo();
                        streamsource.bandwidth = info.getBandwidth();
                        streamsource.width = info.hasResolution() ? info.getResolution().width : 0;
                        streamsource.height = info.hasResolution() ? info.getResolution().height : 0;
                    } else {
                        streamsource.bandwidth = 0;
                        streamsource.width = 0;
                        streamsource.height = 0;
                    }
                    sources.add(streamsource);
                }
                LOG.debug(sources.toString());
                return sources;
            } else {
                throw new HttpException(response.code(), response.message());
            }
        }
    }

    private void getControlToken(BiConsumer<String, String> callback) throws IOException {
        String url = Fc2Live.BASE_URL + "/api/getControlServer.php";
        RequestBody body = new FormBody.Builder()
                .add("channel_id", id)
                .add("channel_version", version)
                .add("client_app", "browser_hls")
                .add("client_type", "pc")
                .add("client_version", "1.6.0 [1]")
                .add("mode", "play")
                .build();
        Request req = new Request.Builder()
                .url(url)
                .method("POST", body)
                .header(ACCEPT, "*/*")
                .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage())
                .header(REFERER, Fc2Live.BASE_URL)
                .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                .header(X_REQUESTED_WITH, XML_HTTP_REQUEST)
                .build();
        LOG.debug("Fetching page {}", url);
        try(Response resp = getSite().getHttpClient().execute(req)) {
            if(resp.isSuccessful()) {
                String msg = resp.body().string();
                JSONObject json = new JSONObject(msg);
                if(json.has("url")) {
                    String wssurl = json.getString("url");
                    String token = json.getString("control_token");
                    callback.accept(token, wssurl);
                } else {
                    throw new IOException("Couldn't determine websocket url");
                }
            } else {
                throw new HttpException(resp.code(), resp.message());
            }
        }
    }

    @Override
    public void invalidateCacheEntries() {
        // not needed
    }

    @Override
    public void receiveTip(Double tokens) throws IOException {
        // tipping is not implemented for FC2
    }

    @Override
    public int[] getStreamResolution(boolean failFast) throws ExecutionException {
        return new int[2];
    }

    @Override
    public boolean follow() throws IOException {
        return followUnfollow("add");
    }

    @Override
    public boolean unfollow() throws IOException {
        return followUnfollow("remove");
    }

    private boolean followUnfollow(String mode) throws IOException {
        if(!getSite().getHttpClient().login()) {
            throw new IOException("Login didn't work");
        }

        RequestBody body = new FormBody.Builder()
                .add("id", getId())
                .add("mode", mode)
                .build();
        Request req = new Request.Builder()
                .url(getSite().getBaseUrl() + "/api/favoriteManager.php")
                .header(REFERER, getUrl())
                .header("Content-Type", "application/x-www-form-urlencoded")
                .post(body)
                .build();
        try(Response resp = getSite().getHttpClient().execute(req)) {
            if(resp.isSuccessful()) {
                String content = resp.body().string();
                JSONObject json = new JSONObject(content);
                return json.optInt("status") == 1;
            } else {
                LOG.error("Login failed {} {}", resp.code(), resp.message());
                return false;
            }
        }
    }

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

    public String getId() {
        return id;
    }

    public int getViewerCount() {
        return viewerCount;
    }

    public void setViewerCount(int viewerCount) {
        this.viewerCount = viewerCount;
    }

    /**
     * Opens a chat websocket connection. This connection is used to retrieve the HLS playlist url. It also has to be kept open as long as the HLS stream is
     * "played". Fc2Model keeps track of the number of objects, which tried to open or close the websocket. As long as at least one object is using the
     * websocket, it is kept open. If the last object, which is using it, calls closeWebsocket, the websocket is closed.
     *
     * @throws IOException
     */
    public void openWebsocket() throws InterruptedException, IOException {
        messageId = 1;
        int usage = websocketUsage.incrementAndGet();
        LOG.debug("{} objects using the websocket for {}", usage, this);
        if(ws != null) {
            return;
        } else {
            Object monitor = new Object();
            loadModelInfo();
            getControlToken((token, url) -> {
                url = url + "?control_token=" + token;
                LOG.debug("Session token: {}", token);
                LOG.debug("Getting playlist token over websocket {}", url);

                Request request = new Request.Builder()
                        .url(url)
                        .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                        .header(ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
                        .header(ACCEPT_LANGUAGE, "de,en-US;q=0.7,en;q=0.3")
                        .build();
                ws = getSite().getHttpClient().newWebSocket(request, new WebSocketListener() {
                    @Override
                    public void onOpen(WebSocket webSocket, Response response) {
                        response.close();
                        webSocket.send("{\"name\":\"get_hls_information\",\"arguments\":{},\"id\":" + (messageId++) + "}");
                    }

                    @Override
                    public void onMessage(WebSocket webSocket, String text) {
                        JSONObject json = new JSONObject(text);
                        if(json.optString("name").equals("_response_")) {
                            if(json.has("arguments")) {
                                JSONObject args = json.getJSONObject("arguments");
                                if(args.has("playlists_high_latency")) {
                                    JSONArray playlists = args.getJSONArray("playlists_high_latency");
                                    JSONObject playlist = playlists.getJSONObject(0);
                                    playlistUrl = playlist.getString("url");
                                    LOG.debug("Master Playlist: {}", playlistUrl);
                                    synchronized (monitor) {
                                        monitor.notifyAll();
                                    }
                                } else {
                                    LOG.trace(json.toString());
                                }
                            }
                        } else if(json.optString("name").equals("user_count") || json.optString("name").equals("comment")) {
                            // ignore
                        } else {
                            LOG.trace("WS <-- {}: {}", getName(), text);
                        }

                        // send heartbeat every now and again
                        long now = System.currentTimeMillis();
                        if( (now - lastHeartBeat) > TimeUnit.SECONDS.toMillis(30)) {
                            webSocket.send("{\"name\":\"heartbeat\",\"arguments\":{},\"id\":" + messageId + "}");
                            lastHeartBeat = now;
                            LOG.trace("Sending heartbeat for {} (messageId: {})", getName(), messageId);
                            messageId++;
                        }
                    }

                    @Override
                    public void onMessage(WebSocket webSocket, ByteString bytes) {
                        LOG.debug("ws btxt {}", bytes);
                    }

                    @Override
                    public void onClosed(WebSocket webSocket, int code, String reason) {
                        LOG.debug("ws closed {} - {}", code, reason);
                    }

                    @Override
                    public void onFailure(WebSocket webSocket, Throwable t, Response response) {
                        LOG.debug("ws failure", t);
                        response.close();
                    }
                });
            });
            synchronized (monitor) {
                // wait at max 10 seconds, otherwise we can assume, that the stream is not available
                monitor.wait(TimeUnit.SECONDS.toMillis(20));
            }
            if(playlistUrl == null) {
                throw new IOException("No playlist response for 20 seconds");
            }
        }
    }

    public void closeWebsocket() {
        int websocketUsers = websocketUsage.decrementAndGet();
        LOG.debug("{} objects using the websocket for {}", websocketUsers, this);
        if(websocketUsers == 0) {
            LOG.debug("Closing the websocket for {}", this);
            ws.close(1000, "");
            ws = null;
        }
    }

    @Override
    public Download createDownload() {
        if (Config.getInstance().getSettings().useHlsdl) {
            return new Fc2HlsdlDownload();
        } else {
            if (Config.isServerMode() && !Config.getInstance().getSettings().recordSingleFile) {
                return new Fc2HlsDownload(getSite().getHttpClient());
            } else {
                return new Fc2MergedHlsDownload(getSite().getHttpClient());
            }
        }
    }

    @Override
    public void readSiteSpecificData(JsonReader reader) throws IOException {
        reader.nextName();
        id = reader.nextString();
    }

    @Override
    public void writeSiteSpecificData(JsonWriter writer) throws IOException {
        writer.name("id").value(id);
    }

    @Override
    public String getSanitizedNamed() {
        return getId();
    }
}
