/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.netbeans.modules.php.dbgp;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.io.UnsupportedEncodingException;
import java.net.Socket;
import java.net.SocketException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.netbeans.api.debugger.DebuggerEngine;
import org.netbeans.api.debugger.DebuggerManager;
import org.netbeans.api.debugger.Session;
import org.netbeans.modules.php.dbgp.breakpoints.BreakpointModel;
import org.netbeans.modules.php.dbgp.models.AbstractIDEBridge;
import org.netbeans.modules.php.dbgp.models.CallStackModel;
import org.netbeans.modules.php.dbgp.models.ThreadsModel;
import org.netbeans.modules.php.dbgp.models.VariablesModel;
import org.netbeans.modules.php.dbgp.models.WatchesModel;
import org.netbeans.modules.php.dbgp.packets.DbgpCommand;
import org.netbeans.modules.php.dbgp.packets.DbgpMessage;
import org.netbeans.modules.php.dbgp.packets.DbgpResponse;
import org.netbeans.modules.php.dbgp.packets.Error;
import org.netbeans.modules.php.dbgp.packets.InitMessage;
import org.netbeans.modules.php.dbgp.packets.Reason;
import org.netbeans.modules.php.dbgp.packets.StackGetCommand;
import org.netbeans.modules.php.dbgp.packets.Status;
import org.netbeans.modules.php.dbgp.packets.StopCommand;
import org.openide.DialogDisplayer;
import org.openide.NotifyDescriptor;
import org.openide.util.Exceptions;
import org.openide.util.NbBundle;

/**
 * @author Radek Matous
 */
public class DebugSession extends SingleThread {
    private static final Logger LOGGER = Logger.getLogger(DebugSession.class.getName());
    private static final int SLEEP_TIME = 100;
    private static final AtomicInteger TRANSACTION_ID = new AtomicInteger(0);
    private final DebuggerOptions options;
    private final BackendLauncher backendLauncher;
    private final AtomicReference<Status> status;
    private Session session;
    private Socket sessionSocket;
    private final AtomicBoolean detachRequest;
    private final AtomicBoolean stopRequest;
    private Thread sessionThread;
    private final List<DbgpCommand> commands;
    private AtomicReference<SessionId> sessionId;
    private AtomicReference<DebuggerEngine> engine;
    private IDESessionBridge myBridge;
    private AtomicReference<String> myFileName;
    private volatile boolean canceled;

    DebugSession(DebuggerOptions options, BackendLauncher backendLauncher) {
        commands = new LinkedList<>();
        this.detachRequest = new AtomicBoolean(false);
        this.stopRequest = new AtomicBoolean(false);
        this.sessionId = new AtomicReference<>();
        this.backendLauncher = backendLauncher;
        this.status = new AtomicReference<>();
        this.options = options;
    }

    public void startProcessing(Socket socket) {
        synchronized (getSync()) {
            try {
                Status stat = getStatus();
                detachRequest.set(true);
                if (stat != null) {
                    waitFinished();
                }
                this.sessionSocket = socket;
                FutureTask invokeLater = invokeLater();
                invokeLater.get();
            } catch (InterruptedException | ExecutionException ex) {
                Exceptions.printStackTrace(ex);
            }
        }
    }

    @Override
    public void run() {
        preprocess();
        try {
            while (!detachRequest.get()) {
                try {
                    sendCommands();
                    receiveData();
                    sleepTillNewCommand();
                } catch (SocketException exc) {
                    log(exc);
                    detachRequest.set(true);
                    stop();
                } catch (IOException e) {
                    log(e);
                } catch (Throwable e) {
                    log(e, Level.SEVERE);
                }
                if (canceled) {
                    synchronized (commands) {
                        if (commands.isEmpty()) {
                            detachRequest.set(true);
                            stop();
                        }
                    }
                }
            }
        } finally {
            postprocess();
        }
    }

    private void preprocess() {
        detachRequest.set(false);
        stopRequest.set(false);
        synchronized (commands) {
            commands.clear();
        }
        sessionId.set(null);
        myBridge = new IDESessionBridge();
        myFileName = new AtomicReference<>();
        engine = new AtomicReference<>();
        setSessionThread(Thread.currentThread());
    }

    private void postprocess() {
        try {
            getSocket().close();
        } catch (IOException e) {
            log(e);
        } finally {
            setSessionThread(null);
            IDESessionBridge bridge = getBridge();
            if (bridge != null) {
                bridge.destroy();
            }
        }
    }

    public void initConnection(InitMessage message) {
        setSessionFile(message.getFileUri());
        DebuggerEngine[] engines = DebuggerManager.getDebuggerManager().getDebuggerEngines();
        for (DebuggerEngine nextEngine : engines) {
            SessionId id = (SessionId) nextEngine.lookupFirst(null, SessionId.class);
            // [NETBEANS-5905] don't check about what the idekey is
            // see also https://bugs.xdebug.org/view.php?id=2005
            if (id != null && message.getSessionId() != null) {
                sessionId.set(id);
                id.initialize(message.getFileUri(), options.getPathMapping());
                engine.set(nextEngine);
            }
        }
        IDESessionBridge bridge = getBridge();
        if (bridge != null) {
            bridge.init();
        }
    }

    private void sendCommands() throws IOException {
        List<DbgpCommand> list;
        synchronized (commands) {
            list = new ArrayList<>(commands);
            commands.clear();
        }
        for (DbgpCommand command : list) {
            if (!detachRequest.get()) {
                command.send(getSocket().getOutputStream());
                if (command.wantAcknowledgment()) {
                    receiveData(command);
                }
            }
        }
    }

    public void sendCommandLater(DbgpCommand command) {
        synchronized (this) {
            /*
             *  Do not collect command before session is not initialized.
             *  So any command before Init message will not be sent.
             *  ( F.e. commands for getting watch values will be just ignored
             *  if they was requested before Init message ).
             */
            if (getSessionId() == null) {
                return;
            }
            if (getSessionThread() == null) {
                return;
            }
            addCommand(command);
        }
    }

    public DbgpResponse sendSynchronCommand(DbgpCommand command) {
        DbgpResponse retval = null;
        if (canSendSynchronCommand()) {
            try {
                command.send(getSocket().getOutputStream());
                if (command.wantAcknowledgment()) {
                    DbgpMessage message = receiveData(command);
                    if (message instanceof DbgpResponse) {
                        retval = (DbgpResponse) message;
                    }
                }
            } catch (SocketException e) {
                log(e);
            } catch (IOException e) {
                log(e);
            }
        }
        return retval;
    }

    private void receiveData() throws IOException {
        receiveData(null);
    }

    private DbgpMessage receiveData(DbgpCommand command) throws IOException {
        if (command != null && command.getCommand().equals(StopCommand.COMMAND)) {
            detachRequest.set(true);
        }
        if (command != null || getSocket().getInputStream().available() > 0) {
            DbgpMessage message;
            try {
                message = DbgpMessage.create(getSocket().getInputStream(), options.getProjectEncoding());
            } catch (SocketException ex) {
                if (command != null) {
                    LOGGER.log(Level.INFO, "COMMAND: " + command.toString() + "; TRANS_ID: " + command.getTransactionId() + "; WANT_ACK: " + command.wantAcknowledgment(), ex);
                } else {
                    LOGGER.log(Level.INFO, "COMMAND: null", ex);
                }
                throw ex;
            }
            handleMessage(command, message);
            return message;
        }
        return null;
    }

    @NbBundle.Messages({
        "# {0} - Error code",
        "# {1} - Error message",
        "XdebugError=Response from XDebug contains errors:\n\n Code: {0}\n Message: {1}"
    })
    private void handleMessage(DbgpCommand command, DbgpMessage message)
            throws IOException {
        if (message == null) {
            return;
        }

        if (command == null) {
            // this is case when we don't need achnowl-t
            message.process(this, null);
            return;
        }

        boolean awaited = false;
        if (message instanceof DbgpResponse) {
            DbgpResponse response = (DbgpResponse) message;
            Error error = response.getError();
            if (error != null) {
                String errorMessage = Bundle.XdebugError(error.getErrorCode(), error.getMessage());
                DialogDisplayer.getDefault().notify(new NotifyDescriptor.Message(errorMessage, NotifyDescriptor.WARNING_MESSAGE));
                LOGGER.log(Level.INFO, "PHP_XDEBUG_ERROR - code: {0}, message: {1}", new Object[]{error.getErrorCode(), error.getMessage()});
            }
            String id = response.getTransactionId();
            if (id.equals(command.getTransactionId())) {
                awaited = true;
                message.process(this, command);
            }
        }
        if (!awaited) {
            message.process(this, null);
            receiveData(command);
        }
    }

    private boolean canSendSynchronCommand() {
        Thread currentSessionThread = getSessionThread();
        if (currentSessionThread == null) {
            return false;
        }
        if (currentSessionThread != Thread.currentThread()) {
            printing146558(Thread.currentThread());
        }
        return true;
    }

    private void printing146558(Thread currentThread) {
        IllegalStateException illegalStateException = new IllegalStateException(
                "Method incorrect usage. It should be called in handler thread only. " + //NOI18N
                "Called from thread: " + currentThread.getName() // NOI18N
                );
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        try {
            illegalStateException.printStackTrace(new PrintStream(bos, false, Charset.defaultCharset().name()));
            LOGGER.log(Level.WARNING, bos.toString(Charset.defaultCharset().name()));
        } catch (UnsupportedEncodingException ex) {
            Exceptions.printStackTrace(ex);
        } finally {
            try {
                bos.close();
            } catch (IOException ex) {
                Exceptions.printStackTrace(ex);
            }
        }
    }

    @Override
    public boolean cancel() {
        // NETBEANS-5080 request cancellation
        // startProcessing() may be called via other ways
        // e.g. via command line: nc -vz localhost 9003(debugger port)
        // First of all, get the socket after the above command is run
        // See: Socket sessionSocket = myServer.accept(); in ServerThread.run()
        // Then, invokeLater.get() is called in startProcessing()
        // Finally, infinite loop occurs in run() becuase do not still receive anything
        canceled = true;
        return true;
    }

    public void processStatus(Status status, Reason reason, DbgpCommand command) {
        setStatus(status);
        if (status.isBreak() && reason.isOK()) {
            processBreakStatus();
        } else if (status.isStopping()) {
            processStoppingStatus();
        } else if (status.isStopped()) {
            processStoppedStatus();
        }
    }

    public void stopSession() {
        sendStopCommand();
        stop();
    }

    private void stop() {
        if (!stopRequest.get()) {
            stopRequest.set(true);
            stopEngines();
            stopBackend();
        }
    }

    private void processBreakStatus() {
        sendCommandLater(new StackGetCommand(getTransactionId()));
        IDESessionBridge bridge = getBridge();
        if (bridge != null) {
            bridge.setSuspended(true);
            ThreadsModel threadsModel = bridge.getThreadsModel();
            if (threadsModel != null) {
                threadsModel.updateSession(this);
            }
        }
    }

    private void processStoppingStatus() {
        detachRequest.set(true);
        processStoppedStatus();
    }

    private void processStoppedStatus() {
        if (getOptions().isDebugForFirstPageOnly()) {
            stop();
        }
    }

    private void sendStopCommand() {
        final boolean isDetached = detachRequest.get();
        if (!isDetached) {
            Thread currentThread = Thread.currentThread();
            final StopCommand stopCommand = new StopCommand(getTransactionId());
            if (currentThread == getSessionThread()) {
                sendSynchronCommand(stopCommand);
            } else {
                sendCommandLater(stopCommand);
            }
        }
    }

    private void stopEngines() {
        SessionManager.stopEngines(session);
    }

    public String getTransactionId() {
        return TRANSACTION_ID.getAndIncrement() + "";
    }

    public SessionId getSessionId() {
        return sessionId.get();
    }

    public IDESessionBridge getBridge() {
        return myBridge;
    }

    public String getFileName() {
        return myFileName.get();
    }

    private void setSessionFile(String fileName) {
        myFileName.set(fileName);
    }

    private void sleepTillNewCommand() {
        try {
            // Wake up every 100 milliseconds and see if the debuggee has something to say.
            // The IDE side can interrupt the sleep to send new packets to the
            // debugger.
            Thread.sleep(SLEEP_TIME);
        } catch (InterruptedException ie) {
            // OK, run the look again.
        }
    }

    private synchronized void setSessionThread(Thread thread) {
        sessionThread = thread;
    }

    private void warnUserInCaseOfSocketException() {
        NotifyDescriptor descriptor = new NotifyDescriptor(
                NbBundle.getMessage(DebugSession.class, "MSG_SocketError"),
                NbBundle.getMessage(DebugSession.class, "MSG_SocketErrorTitle"),
                NotifyDescriptor.OK_CANCEL_OPTION,
                NotifyDescriptor.ERROR_MESSAGE,
                new Object[]{NotifyDescriptor.OK_OPTION},
                NotifyDescriptor.OK_OPTION);
        DialogDisplayer.getDefault().notifyLater(descriptor);
    }

    private void addCommand(DbgpCommand command) {
        synchronized (commands) {
            commands.add(command);
        }
    }

    private synchronized Thread getSessionThread() {
        return sessionThread;
    }

    private Socket getSocket() {
        return sessionSocket;
    }

    private void log(IOException e) {
        log(e, Level.SEVERE);
    }

    private void log(Throwable e, Level level) {
        LOGGER.log(level, null, e);
    }

    private void log(SocketException e) {
        log(e, Level.INFO);
        warnUserInCaseOfSocketException();
    }

    public DebuggerOptions getOptions() {
        return options;
    }

    void startBackend() {
        if (backendLauncher != null) {
            backendLauncher.launch();
        }
    }

    void stopBackend() {
        if (backendLauncher != null) {
            backendLauncher.stop();
        }
    }

    /**
     * @return the status
     */
    public Status getStatus() {
        return status.get();
    }

    /**
     * @param status the status to set
     */
    public void setStatus(Status status) {
        assert status != null;
        if (status == Status.BREAK) {
            assert getSession() != null;
            DebuggerManager.getDebuggerManager().setCurrentSession(getSession());
        }

        this.status.set(status);
    }

    /**
     * @return the session
     */
    public Session getSession() {
        return session;
    }

    /**
     * @param session the session to set
     */
    public void setSession(Session session) {
        this.session = session;
    }

    /*
     * This class is associated  with DebugSession but is intended for
     * cooperation with IDE UI.
     */
    public class IDESessionBridge extends AbstractIDEBridge {

        @Override
        protected DebuggerEngine getEngine() {
            return engine.get();
        }

        @Override
        protected DebugSession getDebugSession() {
            return DebugSession.this;
        }

        private void init() {
            hideAnnotations();
            setSuspended(false);
            ThreadsModel threadsModel = getThreadsModel();
            if (threadsModel != null) {
                threadsModel.update();
            }
        }

        private void destroy() {
            setSuspended(false);
            hideAnnotations();
            BreakpointModel breakpointModel = getBreakpointModel();
            if (breakpointModel != null) {
                breakpointModel.setCurrentStack(null, DebugSession.this);
            }
            CallStackModel callStackModel = getCallStackModel();
            if (callStackModel != null) {
                callStackModel.clearModel();
            }
            ThreadsModel threadsModel = getThreadsModel();
            if (threadsModel != null) {
                threadsModel.update();
            }
            VariablesModel variablesModel = getVariablesModel();
            if (variablesModel != null) {
                variablesModel.clearModel();
            }
            WatchesModel watchesModel = getWatchesModel();
            if (watchesModel != null) {
                watchesModel.clearModel();
            }
        }

    }

}
