/*
 * Copyright (c) 2010-2026 Contributors to the openHAB project
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */
package org.openhab.binding.oppo.internal.handler;

import static org.openhab.binding.oppo.internal.OppoBindingConstants.*;
import static org.openhab.core.thing.Thing.PROPERTY_FIRMWARE_VERSION;

import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.oppo.internal.OppoException;
import org.openhab.binding.oppo.internal.OppoStateDescriptionOptionProvider;
import org.openhab.binding.oppo.internal.communication.OppoCommand;
import org.openhab.binding.oppo.internal.communication.OppoConnector;
import org.openhab.binding.oppo.internal.communication.OppoDefaultConnector;
import org.openhab.binding.oppo.internal.communication.OppoIpConnector;
import org.openhab.binding.oppo.internal.communication.OppoMessageEvent;
import org.openhab.binding.oppo.internal.communication.OppoMessageEventListener;
import org.openhab.binding.oppo.internal.communication.OppoSerialConnector;
import org.openhab.binding.oppo.internal.communication.OppoStatusCodes;
import org.openhab.binding.oppo.internal.configuration.OppoThingConfiguration;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.io.transport.serial.SerialPortManager;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.NextPreviousType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.PlayPauseType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.RewindFastforwardType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.StateOption;
import org.openhab.core.types.UnDefType;
import org.osgi.framework.Bundle;
import org.osgi.framework.FrameworkUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * The {@link OppoHandler} is responsible for handling commands, which are sent to one of the channels.
 *
 * Based on the Rotel binding by Laurent Garnier
 *
 * @author Michael Lobstein - Initial contribution
 */
@NonNullByDefault
public class OppoHandler extends BaseThingHandler implements OppoMessageEventListener {
    private static final long RECON_POLLING_INTERVAL_SEC = 15;
    private static final long POLLING_INTERVAL_SEC = 10;
    private static final long INITIAL_POLLING_DELAY_SEC = 5;
    private static final long SLEEP_BETWEEN_CMD_MS = 100;

    private static final Pattern TIME_CODE_PATTERN = Pattern
            .compile("^(\\d{3}) (\\d{3}) ([A-Z]{1}) (\\d{2}:\\d{2}:\\d{2})$");

    private final Logger logger = LoggerFactory.getLogger(OppoHandler.class);

    private @Nullable ScheduledFuture<?> reconnectJob;
    private @Nullable ScheduledFuture<?> pollingJob;

    private OppoStateDescriptionOptionProvider stateDescriptionProvider;
    private SerialPortManager serialPortManager;
    private OppoConnector connector = new OppoDefaultConnector();

    private final TranslationProvider translationProvider;
    private final LocaleProvider localeProvider;
    private final @Nullable Bundle bundle;

    private List<StateOption> inputSourceOptions = new ArrayList<>();
    private List<StateOption> hdmiModeOptions = new ArrayList<>();

    private long lastEventReceived = System.currentTimeMillis();
    private String verboseMode = VERBOSE_2;
    private String currentTitle = BLANK;
    private String currentChapter = BLANK;
    private String currentTimeMode = T;
    private String currentPlayMode = BLANK;
    private String currentDiscType = BLANK;
    private int titleElapsed = 0;
    private int titleLength = -1;
    private volatile boolean isPowerOn = false;
    private volatile boolean powerCmdDebounce = false;
    private volatile boolean isStopped = true;
    private boolean isUDP20X = false;
    private boolean isBdpIP = false;
    private volatile boolean isVbModeSet = false;
    private volatile boolean isInitialQuery = false;
    private volatile boolean isFirmwareSet = false;
    private Object sequenceLock = new Object();

    /**
     * Constructor
     */
    public OppoHandler(Thing thing, OppoStateDescriptionOptionProvider stateDescriptionProvider,
            SerialPortManager serialPortManager, TranslationProvider translationProvider,
            LocaleProvider localeProvider) {
        super(thing);
        this.stateDescriptionProvider = stateDescriptionProvider;
        this.serialPortManager = serialPortManager;
        this.translationProvider = translationProvider;
        this.localeProvider = localeProvider;
        this.bundle = FrameworkUtil.getBundle(OppoHandler.class);
    }

    @Override
    public void initialize() {
        final OppoThingConfiguration config = getConfigAs(OppoThingConfiguration.class);

        final int model = config.model;
        this.isUDP20X = (model == MODEL203 || model == MODEL205);

        // Check configuration settings
        if (model == 0) {
            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.player-model");
            return;
        }

        final String serialPort = config.serialPort;
        final String host = config.host;
        Integer port = config.port;

        String configError = null;
        if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) {
            configError = "@text/error.port-select";
        } else if (serialPort != null && (host == null || host.isEmpty())) {
            if (serialPort.toLowerCase().startsWith("rfc2217")) {
                configError = "@text/error.rfc2217";
            }
        } else {
            isBdpIP = false;
            if (port == null) {
                port = switch (model) {
                    case MODEL83 -> BDP83_PORT;
                    case MODEL103, MODEL105 -> BDP10X_PORT;
                    default -> UDP20X_PORT;
                };
                isBdpIP = port != UDP20X_PORT;
            } else if (port <= 0) {
                configError = "@text/error.invalid-port";
            }
        }

        this.verboseMode = config.verboseMode && !isBdpIP ? VERBOSE_3 : VERBOSE_2;

        if (configError != null) {
            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError);
            return;
        }

        if (serialPort != null) {
            connector = new OppoSerialConnector(serialPortManager, serialPort, getThing().getUID().getAsString());
        } else if (port != null) {
            connector = new OppoIpConnector(host, port, isBdpIP, getThing().getUID().getAsString());
        } else {
            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.port-select");
            return;
        }

        this.buildOptionDropdowns(model);
        stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_SOURCE),
                inputSourceOptions);
        stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_HDMI_MODE),
                hdmiModeOptions);

        // remove channels not needed for this model
        List<Channel> channels = new ArrayList<>(this.getThing().getChannels());

        if (model == MODEL83) {
            channels.removeIf(c -> (c.getUID().getId().equals(CHANNEL_SUB_SHIFT)
                    || c.getUID().getId().equals(CHANNEL_OSD_POSITION)));
        }

        if (model == MODEL83 || model == MODEL103 || model == MODEL105) {
            channels.removeIf(c -> (c.getUID().getId().equals(CHANNEL_ASPECT_RATIO)
                    || c.getUID().getId().equals(CHANNEL_HDR_MODE)));
        }
        updateThing(editThing().withChannels(channels).build());

        scheduleReconnectJob();
        schedulePollingJob();

        // no query to determine this, so set the default value at startup
        updateChannelState(CHANNEL_TIME_MODE, currentTimeMode);

        updateStatus(ThingStatus.UNKNOWN);
    }

    @Override
    public void dispose() {
        cancelReconnectJob();
        cancelPollingJob();
        closeConnection();
        super.dispose();
    }

    /**
     * Handle a command the UI
     *
     * @param channelUID the channel sending the command
     * @param command the command received
     *
     */
    @Override
    public void handleCommand(ChannelUID channelUID, Command command) {
        String channel = channelUID.getId();

        if (getThing().getStatus() != ThingStatus.ONLINE || !connector.isConnected()) {
            logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
            return;
        }

        synchronized (sequenceLock) {
            try {
                final String commandStr = command.toString();
                switch (channel) {
                    case CHANNEL_POWER:
                        if (command instanceof OnOffType) {
                            connector.sendCommand(
                                    command == OnOffType.ON ? OppoCommand.POWER_ON : OppoCommand.POWER_OFF);

                            powerCmdDebounce = true;

                            if (command == OnOffType.OFF) {
                                clearStateFlags();
                            } else {
                                isPowerOn = true;
                            }
                        }
                        break;
                    case CHANNEL_VOLUME:
                        if (command instanceof PercentType) {
                            connector.sendCommand(OppoCommand.SET_VOLUME_LEVEL, commandStr);
                        }
                        break;
                    case CHANNEL_MUTE:
                        if (command instanceof OnOffType) {
                            if (command == OnOffType.ON) {
                                connector.sendCommand(OppoCommand.SET_VOLUME_LEVEL, MUTE);
                            } else {
                                connector.sendCommand(OppoCommand.MUTE);
                            }
                        }
                        break;
                    case CHANNEL_SOURCE:
                        if (command instanceof DecimalType) {
                            connector.sendCommand(OppoCommand.SET_INPUT_SOURCE, commandStr);
                        }
                        break;
                    case CHANNEL_CONTROL:
                        this.handleControlCommand(command);
                        break;
                    case CHANNEL_TIME_MODE:
                        if (command instanceof StringType) {
                            connector.sendCommand(OppoCommand.SET_TIME_DISPLAY, commandStr);
                            currentTimeMode = commandStr;
                        }
                        break;
                    case CHANNEL_REPEAT_MODE:
                        if (command instanceof StringType) {
                            // this one is lame, the response code when querying repeat mode is two digits,
                            // but setting it is a 2-3 letter code.
                            connector.sendCommand(OppoCommand.SET_REPEAT, OppoStatusCodes.REPEAT_MODE.get(commandStr));
                        }
                        break;
                    case CHANNEL_ZOOM_MODE:
                        if (command instanceof StringType) {
                            // again why could't they make the query code and set code the same?
                            connector.sendCommand(OppoCommand.SET_ZOOM_RATIO,
                                    OppoStatusCodes.ZOOM_MODE.get(commandStr));
                        }
                        break;
                    case CHANNEL_SUB_SHIFT:
                        if (command instanceof DecimalType) {
                            connector.sendCommand(OppoCommand.SET_SUBTITLE_SHIFT, commandStr);
                        }
                        break;
                    case CHANNEL_OSD_POSITION:
                        if (command instanceof DecimalType) {
                            connector.sendCommand(OppoCommand.SET_OSD_POSITION, commandStr);
                        }
                        break;
                    case CHANNEL_HDMI_MODE:
                        if (command instanceof StringType) {
                            connector.sendCommand(OppoCommand.SET_HDMI_MODE, commandStr);
                        }
                        break;
                    case CHANNEL_HDR_MODE:
                        if (command instanceof StringType) {
                            connector.sendCommand(OppoCommand.SET_HDR_MODE, commandStr);
                        }
                        break;
                    case CHANNEL_REMOTE_BUTTON:
                        if (command instanceof StringType) {
                            connector.sendCommand(commandStr);
                            if ("STP".equals(commandStr) || "EJT".equals(commandStr)) {
                                currentTitle = BLANK;
                                currentChapter = BLANK;
                                titleLength = -1;
                                isStopped = true;
                            }
                        }
                        break;
                    default:
                        logger.debug("Unknown command {} from channel {}", command, channel);
                        break;
                }
            } catch (OppoException e) {
                logger.debug("Command {} from channel {} failed: {}", command, channel, e.getMessage());
                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
                closeConnection();
                scheduleReconnectJob();
            }
        }
    }

    /**
     * Open the connection with the Oppo player
     *
     * @return true if the connection is opened successfully or false if not
     */
    private synchronized boolean openConnection() {
        connector.addEventListener(this);
        try {
            connector.open();
        } catch (OppoException e) {
            logger.debug("openConnection() failed: {}", e.getMessage());
        }
        logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
        return connector.isConnected();
    }

    /**
     * Close the connection with the Oppo player
     */
    private synchronized void closeConnection() {
        clearStateFlags();
        isFirmwareSet = false;

        if (connector.isConnected()) {
            connector.close();
            logger.debug("closeConnection(): disconnected");
        }
        connector.removeEventListener(this);
    }

    /**
     * Handle an event received from the Oppo player
     *
     * @param evt the event to process
     */
    @Override
    public void onNewMessageEvent(OppoMessageEvent evt) {
        lastEventReceived = System.currentTimeMillis();

        String key = evt.getKey();
        String updateData = evt.getValue().trim();
        if (this.getThing().getStatus() == ThingStatus.OFFLINE) {
            updateStatus(ThingStatus.ONLINE);
        }

        synchronized (sequenceLock) {
            try {
                switch (key) {
                    case NOP: // ignore
                        break;
                    case UTC:
                        if (!isStopped) {
                            // Player sent a time code update ie: 000 000 T 00:00:01
                            // g1 = title(movie only; cd always 000), g2 = chapter(movie)/track(cd), g3 = time display
                            // code, g4 = time
                            final Matcher matcher = TIME_CODE_PATTERN.matcher(updateData);
                            if (matcher.find()) {
                                // only update these when title + chapter/track (a pseudo hash code) changes
                                if (!currentChapter.equals(matcher.group(1) + matcher.group(2))) {
                                    currentChapter = matcher.group(1) + matcher.group(2);
                                    // for CDs this will get track 1/x also
                                    connector.sendCommand(OppoCommand.QUERY_TITLE_TRACK);
                                    // for movies shows chapter 1/x; skip for CDs
                                    if (!CDDA.equals(currentDiscType) && !SACD.equals(currentDiscType)) {
                                        connector.sendCommand(OppoCommand.QUERY_CHAPTER);
                                    }
                                }

                                if (!currentTimeMode.equals(matcher.group(3))) {
                                    currentTimeMode = matcher.group(3);
                                    updateChannelState(CHANNEL_TIME_MODE, currentTimeMode);
                                }

                                if (isValidTimecode(matcher.group(4))) {
                                    updateState(CHANNEL_TIME_DISPLAY, new QuantityType<>(
                                            getSecondsFromTimecode(matcher.group(4)), API_SECONDS_UNIT));
                                } else {
                                    logger.debug("Invalid timecode in {} message: {}", key, updateData);
                                }
                            } else {
                                logger.debug("no match on message: {}", updateData);
                            }
                        }
                        break;
                    case QTE:
                    case QTR:
                    case QCE:
                    case QCR:
                        if (!isStopped) {
                            int timecode = 0;

                            if (!isValidTimecode(updateData)) {
                                logger.debug("Invalid timecode in {} message: {}", key, updateData);
                                break;
                            } else {
                                timecode = getSecondsFromTimecode(updateData);
                            }

                            // these are used with verbose mode 2 and BDP direct IP
                            if (VERBOSE_2.equals(this.verboseMode) && ((key.equals(QTE) && T.equals(currentTimeMode))
                                    || (key.equals(QTR) && X.equals(currentTimeMode))
                                    || (key.equals(QCE) && C.equals(currentTimeMode))
                                    || (key.equals(QCR) && K.equals(currentTimeMode)))) {
                                updateState(CHANNEL_TIME_DISPLAY, new QuantityType<>(timecode, API_SECONDS_UNIT));
                            }

                            if (key.equals(QTE)) {
                                titleElapsed = timecode;
                                updateState(CHANNEL_TITLE_ELAPSED, new QuantityType<>(timecode, API_SECONDS_UNIT));
                            }

                            if (key.equals(QTR)) {
                                final int remain = timecode;

                                updateState(CHANNEL_TITLE_END_TIME, remain > 0
                                        ? new DateTimeType(
                                                Instant.now().plusSeconds(remain).truncatedTo(ChronoUnit.MINUTES))
                                        : UnDefType.UNDEF);

                                if (remain > 0
                                        && (isLinked(CHANNEL_TITLE_LENGTH) || isLinked(CHANNEL_TITLE_PROGRESS))) {
                                    final int elapsed = this.titleElapsed;
                                    int titleLength = this.titleLength;

                                    // If no titleLength set, calculate only once for the current title/track
                                    if (titleLength == -1) {
                                        titleLength = elapsed + remain;
                                        this.titleLength = titleLength;

                                        updateState(CHANNEL_TITLE_LENGTH,
                                                new QuantityType<>(titleLength, API_SECONDS_UNIT));
                                    }

                                    if (titleLength > 0 && elapsed >= 0 && elapsed <= titleLength) {
                                        updateState(CHANNEL_TITLE_PROGRESS, new PercentType(BigDecimal
                                                .valueOf(Math.round(elapsed / (double) titleLength * 100.0))));
                                    } else {
                                        updateState(CHANNEL_TITLE_PROGRESS, UnDefType.UNDEF);
                                    }
                                }
                            }
                        }
                        break;
                    case QVR:
                        thing.setProperty(PROPERTY_FIRMWARE_VERSION, updateData);
                        isFirmwareSet = true;
                        break;
                    case QPW:
                        // After power is commanded, ignore the first polling update to prevent bouncing
                        if (!powerCmdDebounce) {
                            updateChannelState(CHANNEL_POWER, updateData);
                        }
                        powerCmdDebounce = false;

                        if (OFF.equals(updateData)) {
                            clearStateFlags();
                        } else {
                            isPowerOn = true;
                        }
                        break;
                    case UPW:
                        updateChannelState(CHANNEL_POWER, ONE.equals(updateData) ? ON : OFF);
                        if (ZERO.equals(updateData)) {
                            clearStateFlags();
                        } else {
                            isPowerOn = true;
                        }
                        break;
                    case QVL:
                    case UVL:
                    case VUP:
                    case VDN:
                        if (MUTE.equals(updateData) || MUT.equals(updateData)) { // query sends MUTE, update sends MUT
                            updateChannelState(CHANNEL_MUTE, ON);
                        } else if (UMT.equals(updateData)) {
                            updateChannelState(CHANNEL_MUTE, OFF);
                        } else {
                            updateChannelState(CHANNEL_VOLUME, updateData);
                            updateChannelState(CHANNEL_MUTE, OFF);
                        }
                        break;
                    case QIS:
                    case UIS:
                        // example: 0 BD-PLAYER, split off just the number
                        updateChannelState(CHANNEL_SOURCE, updateData.split(SPACE)[0]);
                        break;
                    case QTK:
                        if (!isStopped) {
                            // example: 02/10, split off both numbers
                            final String[] titleTrack = updateData.split(SLASH);
                            if (titleTrack.length == 2 && !"00".equals(titleTrack[1])) {
                                if (!currentTitle.equals(titleTrack[0])) {
                                    currentTitle = titleTrack[0];
                                    // reset title length when title/track changes
                                    titleLength = -1;
                                }
                                updateChannelState(CHANNEL_CURRENT_TITLE, titleTrack[0]);
                                updateChannelState(CHANNEL_TOTAL_TITLE, titleTrack[1]);
                            }
                        }
                        break;
                    case QCH:
                        if (!isStopped && !CDDA.equals(currentDiscType) && !SACD.equals(currentDiscType)) {
                            // example: 03/03, split off the both numbers
                            final String[] chapter = updateData.split(SLASH);
                            if (chapter.length == 2 && !"00".equals(chapter[1])) {
                                updateChannelState(CHANNEL_CURRENT_CHAPTER, chapter[0]);
                                updateChannelState(CHANNEL_TOTAL_CHAPTER, chapter[1]);
                            }
                        }
                        break;
                    case UPL:
                    case QPL:
                        // try to normalize the slightly different responses between UPL and QPL
                        currentPlayMode = OppoStatusCodes.PLAYBACK_STATUS.getOrDefault(updateData, updateData);

                        // if playback has stopped, we have to zero out Time, Title and Track info and so on manually
                        if (STOPPED_STATES.contains(currentPlayMode)) {
                            clearStatusChannels(false);
                            currentTitle = BLANK;
                            currentChapter = BLANK;
                            titleLength = -1;
                            isStopped = true;
                        } else {
                            isStopped = false;
                        }
                        updateChannelState(CHANNEL_PLAY_MODE, currentPlayMode);
                        updateState(CHANNEL_CONTROL,
                                PLAY.equals(currentPlayMode) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);

                        // ejecting the disc does not produce a UDT message, so clear disc type manually
                        if (OPEN.equals(currentPlayMode) || NO_DISC.equals(currentPlayMode)) {
                            updateChannelState(CHANNEL_DISC_TYPE, null);
                            updateChannelState(CHANNEL_ASPECT_RATIO, null);
                            updateChannelState(CHANNEL_SOURCE_RESOLUTION, null);
                            updateChannelState(CHANNEL_OUTPUT_RESOLUTION, null);
                            updateChannelState(CHANNEL_3D_INDICATOR, null);
                            currentDiscType = BLANK;
                        }

                        // if switching to play mode and not a CD then query the subtitle type...
                        // because if subtitles were on when playback stopped, they got nulled out above
                        // and the subtitle update message ("UST") is not sent when play starts like it is for audio
                        if (PLAY.equals(currentPlayMode) && !CDDA.equals(currentDiscType)
                                && !SACD.equals(currentDiscType)) {
                            connector.sendCommand(OppoCommand.QUERY_SUBTITLE_TYPE);
                        }
                        break;
                    case QRP:
                        updateChannelState(CHANNEL_REPEAT_MODE, updateData);
                        break;
                    case QZM:
                        updateChannelState(CHANNEL_ZOOM_MODE, updateData);
                        break;
                    case UDT:
                    case QDT:
                        if (!QDT.equals(key) || (!isStopped && !LOADING.equals(currentPlayMode))) {
                            // try to normalize the slightly different responses between UDT and QDT
                            currentDiscType = OppoStatusCodes.DISC_TYPE.getOrDefault(updateData, updateData);
                            updateChannelState(CHANNEL_DISC_TYPE, currentDiscType);
                        }
                        break;
                    case UAT:
                        // we got the audio type status update, throw it away
                        // and call the query because the text output is better
                        // wait before sending the command to give the player time to catch up
                        Thread.sleep(SLEEP_BETWEEN_CMD_MS);
                        connector.sendCommand(OppoCommand.QUERY_AUDIO_TYPE);
                        break;
                    case QAT:
                        updateChannelState(CHANNEL_AUDIO_TYPE, updateData);
                        break;
                    case UST:
                        // we got the subtitle type status update, throw it away
                        // and call the query because the text output is better
                        // wait before sending the command to give the player time to catch up
                        Thread.sleep(SLEEP_BETWEEN_CMD_MS);
                        connector.sendCommand(OppoCommand.QUERY_SUBTITLE_TYPE);
                        break;
                    case QST:
                        updateChannelState(CHANNEL_SUBTITLE_TYPE, updateData);
                        break;
                    case UAR: // 203 & 205 only
                        if (!isStopped) {
                            updateChannelState(CHANNEL_ASPECT_RATIO, updateData);
                        }
                        break;
                    case UVO:
                        if (!isStopped) {
                            // example: _480I60 1080P60 - 1st source res, 2nd output res
                            final String[] resolution = updateData.replace(UNDERSCORE, BLANK).split(SPACE);
                            if (resolution.length == 2) {
                                updateChannelState(CHANNEL_SOURCE_RESOLUTION,
                                        !"OTHER".equals(resolution[0]) ? resolution[0] : null);
                                updateChannelState(CHANNEL_OUTPUT_RESOLUTION,
                                        !"OTHER".equals(resolution[1]) ? resolution[1] : null);
                            }
                        }
                        break;
                    case U3D:
                    case Q3D:
                        if (!isStopped) {
                            updateChannelState(CHANNEL_3D_INDICATOR, updateData);
                        }
                        break;
                    case QSH:
                        updateChannelState(CHANNEL_SUB_SHIFT, updateData);
                        break;
                    case QOP:
                        updateChannelState(CHANNEL_OSD_POSITION, updateData);
                        break;
                    case QHD:
                        if (this.isUDP20X) {
                            updateChannelState(CHANNEL_HDMI_MODE, updateData);
                        } else {
                            handleHdmiModeUpdate(updateData);
                        }
                        break;
                    case QHR: // 203 & 205 only
                        updateChannelState(CHANNEL_HDR_MODE, updateData);
                        break;
                    case SVM: // Verbose mode set successful message
                        isVbModeSet = updateData.equals(verboseMode);
                        if (isVbModeSet) {
                            logger.debug("Verbose mode {} set successful", updateData);
                        }
                        break;
                    default:
                        logger.debug("onNewMessageEvent: unhandled key {}, value: {}", key, updateData);
                        break;
                }
            } catch (OppoException | InterruptedException e) {
                logger.debug("Exception processing event from player: {}", e.getMessage());
            }
        }
    }

    /**
     * Validate if timecode is the proper hh:mm:ss format
     *
     * @param timecode the string containing a timecode
     * @return true if timecode is valid
     *
     */
    private boolean isValidTimecode(String timecode) {
        return timecode.length() == 8 && timecode.replaceAll(COLON, BLANK).chars().allMatch(Character::isDigit);
    }

    /**
     * Convert a timecode from hh:mm:ss format into the number of seconds
     *
     * @param timecode the string containing a timecode
     * @return the number of seconds as int
     *
     */
    private int getSecondsFromTimecode(String timecode) {
        return (Integer.parseInt(timecode.substring(0, 2)) * 3600) + (Integer.parseInt(timecode.substring(3, 5)) * 60)
                + Integer.parseInt(timecode.substring(6, 8));
    }

    /**
     * Clears the status channels
     */
    private void clearStatusChannels(boolean clearPlayMode) {
        if (clearPlayMode) {
            updateChannelState(CHANNEL_PLAY_MODE, null);
            updateChannelState(CHANNEL_DISC_TYPE, null);
            updateChannelState(CHANNEL_ASPECT_RATIO, null);
            updateChannelState(CHANNEL_SOURCE_RESOLUTION, null);
            updateChannelState(CHANNEL_OUTPUT_RESOLUTION, null);
            updateChannelState(CHANNEL_3D_INDICATOR, null);
        }
        updateChannelState(CHANNEL_CURRENT_TITLE, null);
        updateChannelState(CHANNEL_TOTAL_TITLE, null);
        updateChannelState(CHANNEL_CURRENT_CHAPTER, null);
        updateChannelState(CHANNEL_TOTAL_CHAPTER, null);
        updateChannelState(CHANNEL_TIME_DISPLAY, null);
        updateChannelState(CHANNEL_TITLE_ELAPSED, null);
        updateChannelState(CHANNEL_TITLE_LENGTH, null);
        updateChannelState(CHANNEL_TITLE_END_TIME, null);
        updateChannelState(CHANNEL_TITLE_PROGRESS, null);
        updateChannelState(CHANNEL_AUDIO_TYPE, null);
        updateChannelState(CHANNEL_SUBTITLE_TYPE, null);
    }

    private void clearStateFlags() {
        if (!BLANK.equals(currentPlayMode)) {
            currentPlayMode = BLANK;
            currentTitle = BLANK;
            currentChapter = BLANK;
            titleLength = -1;
            clearStatusChannels(true);
        }
        isPowerOn = false;
        isStopped = true;
        isInitialQuery = false;
        isVbModeSet = false;
    }

    /**
     * Schedule the reconnection job
     */
    private void scheduleReconnectJob() {
        logger.debug("Schedule reconnect job");
        cancelReconnectJob();

        reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
            if (!connector.isConnected()) {
                logger.debug("Trying to reconnect...");
                closeConnection();
                synchronized (sequenceLock) {
                    boolean connected = openConnection();

                    if (connected) {
                        try {
                            connector.sendCommand(OppoCommand.QUERY_POWER_STATUS);
                            Thread.sleep(SLEEP_BETWEEN_CMD_MS);
                            connector.sendCommand(OppoCommand.QUERY_PLAYBACK_STATUS);

                            updateStatus(ThingStatus.ONLINE);
                            isInitialQuery = false;
                            isVbModeSet = false;
                        } catch (OppoException | InterruptedException e) {
                            logger.debug("Exception sending initial commands: {}", e.getMessage());
                            connected = false;
                        }
                    }

                    if (!connected) {
                        updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
                        closeConnection();
                    }
                }
            }
        }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
    }

    /**
     * Cancel the reconnection job
     */
    private void cancelReconnectJob() {
        ScheduledFuture<?> reconnectJob = this.reconnectJob;
        if (reconnectJob != null) {
            reconnectJob.cancel(true);
            this.reconnectJob = null;
        }
    }

    /**
     * Schedule the polling job
     */
    private void schedulePollingJob() {
        logger.debug("Schedule polling job");
        cancelPollingJob();

        pollingJob = scheduler.scheduleWithFixedDelay(() -> {
            if (connector.isConnected()) {
                logger.debug("Polling the player for updated status...");

                synchronized (sequenceLock) {
                    try {
                        // Verbose mode 2 & 3 only do once until power comes on OR always for BDP direct IP
                        if ((!isPowerOn && !isInitialQuery) || isBdpIP) {
                            connector.sendCommand(OppoCommand.QUERY_POWER_STATUS);
                        }

                        if (isPowerOn) {
                            // the verbose mode must be set while the player is on
                            if (!isVbModeSet && !isBdpIP) {
                                connector.sendCommand(OppoCommand.SET_VERBOSE_MODE, this.verboseMode);
                                Thread.sleep(SLEEP_BETWEEN_CMD_MS);
                            }

                            // Verbose mode 2 & 3 only do once OR always for BDP direct IP
                            if (!isInitialQuery || isBdpIP) {
                                // check firmware until successful
                                if (!isFirmwareSet) {
                                    connector.sendCommand(OppoCommand.QUERY_FIRMWARE_VERSION);
                                    Thread.sleep(SLEEP_BETWEEN_CMD_MS);
                                }

                                isInitialQuery = true;
                                OppoCommand.QUERY_COMMANDS.forEach(cmd -> {
                                    try {
                                        connector.sendCommand(cmd);
                                        Thread.sleep(SLEEP_BETWEEN_CMD_MS);
                                    } catch (OppoException | InterruptedException e) {
                                        logger.debug("Exception sending polling commands: {}", e.getMessage());
                                    }
                                });
                            }

                            // for Verbose mode 2 get the current play back time if we are playing
                            if (VERBOSE_2.equals(this.verboseMode) && PLAY.equals(currentPlayMode)) {
                                switch (currentTimeMode) {
                                    case T:
                                        connector.sendCommand(OppoCommand.QUERY_TITLE_ELAPSED);
                                        break;
                                    case X:
                                        connector.sendCommand(OppoCommand.QUERY_TITLE_REMAIN);
                                        break;
                                    case C:
                                        connector.sendCommand(OppoCommand.QUERY_CHAPTER_ELAPSED);
                                        break;
                                    case K:
                                        connector.sendCommand(OppoCommand.QUERY_CHAPTER_REMAIN);
                                        break;
                                }
                                Thread.sleep(SLEEP_BETWEEN_CMD_MS);

                                // make queries to refresh total number of titles/tracks & chapters
                                connector.sendCommand(OppoCommand.QUERY_TITLE_TRACK);
                                Thread.sleep(SLEEP_BETWEEN_CMD_MS);
                                connector.sendCommand(OppoCommand.QUERY_CHAPTER);

                                // If QUERY_TITLE_ELAPSED not run above, do now if necessary
                                if (!T.equals(currentTimeMode) && (isLinked(CHANNEL_TITLE_ELAPSED)
                                        || isLinked(CHANNEL_TITLE_LENGTH) || isLinked(CHANNEL_TITLE_END_TIME)
                                        || isLinked(CHANNEL_TITLE_PROGRESS))) {
                                    Thread.sleep(SLEEP_BETWEEN_CMD_MS);
                                    connector.sendCommand(OppoCommand.QUERY_TITLE_ELAPSED);
                                }

                                // If QUERY_TITLE_REMAIN not run above, do now if necessary
                                if (!X.equals(currentTimeMode) && (isLinked(CHANNEL_TITLE_LENGTH)
                                        || isLinked(CHANNEL_TITLE_END_TIME) || isLinked(CHANNEL_TITLE_PROGRESS))) {
                                    Thread.sleep(SLEEP_BETWEEN_CMD_MS);
                                    connector.sendCommand(OppoCommand.QUERY_TITLE_REMAIN);
                                }
                            } else if (!isBdpIP) {
                                // Verbose mode 3 - QUERY_TITLE_ELAPSED and QUERY_TITLE_REMAIN polling if necessary
                                if (PLAY.equals(currentPlayMode) && (isLinked(CHANNEL_TITLE_ELAPSED)
                                        || isLinked(CHANNEL_TITLE_LENGTH) || isLinked(CHANNEL_TITLE_END_TIME)
                                        || isLinked(CHANNEL_TITLE_PROGRESS))) {
                                    connector.sendCommand(OppoCommand.QUERY_TITLE_ELAPSED);
                                    Thread.sleep(SLEEP_BETWEEN_CMD_MS);
                                    connector.sendCommand(OppoCommand.QUERY_TITLE_REMAIN);
                                } else {
                                    // Verbose mode 3 and no polling above or 2 not playing, keep the connection alive
                                    connector.sendCommand(OppoCommand.NO_OP);
                                }
                            }
                        }

                    } catch (OppoException | InterruptedException e) {
                        logger.debug("Polling error: {}", e.getMessage());
                    }

                    // if the last event received was more than 1.25 intervals ago,
                    // the player is not responding even though the connection is still good
                    if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
                        logger.debug("Player not responding to status requests");
                        updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
                                "Player not responding to status requests");
                        closeConnection();
                        scheduleReconnectJob();
                    }
                }
            }
        }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
    }

    /**
     * Cancel the polling job
     */
    private void cancelPollingJob() {
        ScheduledFuture<?> pollingJob = this.pollingJob;
        if (pollingJob != null) {
            pollingJob.cancel(true);
            this.pollingJob = null;
        }
    }

    /**
     * Update the state of a channel
     *
     * @param channel the channel
     * @param value the value to be updated
     */
    private void updateChannelState(String channel, @Nullable String value) {
        if (!isLinked(channel)) {
            return;
        }

        if (value == null) {
            updateState(channel, UnDefType.UNDEF);
            return;
        }

        State state = UnDefType.UNDEF;

        switch (channel) {
            case CHANNEL_POWER:
            case CHANNEL_MUTE:
                state = OnOffType.from(ON.equals(value));
                break;
            case CHANNEL_SOURCE:
            case CHANNEL_SUB_SHIFT:
            case CHANNEL_OSD_POSITION:
            case CHANNEL_CURRENT_TITLE:
            case CHANNEL_TOTAL_TITLE:
            case CHANNEL_CURRENT_CHAPTER:
            case CHANNEL_TOTAL_CHAPTER:
                state = new DecimalType(value);
                break;
            case CHANNEL_VOLUME:
                state = new PercentType(BigDecimal.valueOf(Integer.parseInt(value)));
                break;
            case CHANNEL_PLAY_MODE:
            case CHANNEL_TIME_MODE:
            case CHANNEL_REPEAT_MODE:
            case CHANNEL_ZOOM_MODE:
            case CHANNEL_DISC_TYPE:
            case CHANNEL_AUDIO_TYPE:
            case CHANNEL_SUBTITLE_TYPE:
            case CHANNEL_ASPECT_RATIO:
            case CHANNEL_SOURCE_RESOLUTION:
            case CHANNEL_OUTPUT_RESOLUTION:
            case CHANNEL_3D_INDICATOR:
            case CHANNEL_HDMI_MODE:
            case CHANNEL_HDR_MODE:
                state = new StringType(value);
                break;
            default:
                break;
        }
        updateState(channel, state);
    }

    /**
     * Handle a button press from a UI Player item
     *
     * @param command the control button press command received
     */
    private void handleControlCommand(Command command) throws OppoException {
        if (command instanceof PlayPauseType) {
            if (command == PlayPauseType.PLAY) {
                connector.sendCommand(OppoCommand.PLAY);
            } else if (command == PlayPauseType.PAUSE) {
                connector.sendCommand(OppoCommand.PAUSE);
            }
        } else if (command instanceof NextPreviousType) {
            if (command == NextPreviousType.NEXT) {
                connector.sendCommand(OppoCommand.NEXT);
            } else if (command == NextPreviousType.PREVIOUS) {
                connector.sendCommand(OppoCommand.PREV);
            }
        } else if (command instanceof RewindFastforwardType) {
            if (command == RewindFastforwardType.FASTFORWARD) {
                connector.sendCommand(OppoCommand.FFORWARD);
            } else if (command == RewindFastforwardType.REWIND) {
                connector.sendCommand(OppoCommand.REWIND);
            }
        } else {
            logger.debug("Unknown control command: {}", command);
        }
    }

    private void buildOptionDropdowns(int model) {
        hdmiModeOptions.clear();
        inputSourceOptions.clear();

        if (model == MODEL83 || model == MODEL103 || model == MODEL105) {
            hdmiModeOptions.add(new StateOption("AUTO", getString("auto", "Auto")));
            hdmiModeOptions.add(new StateOption("SRC", getString("direct", "Source Direct")));
            if (model != MODEL83) {
                hdmiModeOptions.add(new StateOption("4K2K", "4K*2K"));
            }
            hdmiModeOptions.add(new StateOption("1080P", "1080P"));
            hdmiModeOptions.add(new StateOption("1080I", "1080I"));
            hdmiModeOptions.add(new StateOption("720P", "720P"));
            hdmiModeOptions.add(new StateOption("SDP", "480P"));
            hdmiModeOptions.add(new StateOption("SDI", "480I"));
        }

        if (model == MODEL103 || model == MODEL105) {
            inputSourceOptions.add(new StateOption("0", getString("blu_ray", "Blu-ray Player")));
            inputSourceOptions.add(new StateOption("1", getString("hdmi_in_front", "HDMI/MHL In-Front")));
            inputSourceOptions.add(new StateOption("2", getString("hdmi_in_back", "HDMI In-Back")));
            inputSourceOptions.add(new StateOption("3", getString("arc1", "ARC 1")));
            inputSourceOptions.add(new StateOption("4", getString("arc2", "ARC 2")));

            if (model == MODEL105) {
                inputSourceOptions.add(new StateOption("5", getString("optical", "Optical In")));
                inputSourceOptions.add(new StateOption("6", getString("coaxial", "Coaxial In")));
                inputSourceOptions.add(new StateOption("7", getString("usb", "USB Audio In")));
            }
        }

        if (model == MODEL203 || model == MODEL205) {
            hdmiModeOptions.add(new StateOption("AUTO", getString("auto", "Auto")));
            hdmiModeOptions.add(new StateOption("SRC", getString("direct", "Source Direct")));
            hdmiModeOptions.add(new StateOption("UHD_AUTO", getString("auto_uhd", "UHD Auto")));
            hdmiModeOptions.add(new StateOption("UHD24", "UHD24"));
            hdmiModeOptions.add(new StateOption("UHD50", "UHD50"));
            hdmiModeOptions.add(new StateOption("UHD60", "UHD60"));
            hdmiModeOptions.add(new StateOption("1080P_AUTO", getString("auto_1080p", "1080P Auto")));
            hdmiModeOptions.add(new StateOption("1080P24", "1080P24"));
            hdmiModeOptions.add(new StateOption("1080P50", "1080P50"));
            hdmiModeOptions.add(new StateOption("1080P60", "1080P60"));
            hdmiModeOptions.add(new StateOption("1080I50", "1080I50"));
            hdmiModeOptions.add(new StateOption("1080I60", "1080I60"));
            hdmiModeOptions.add(new StateOption("720P50", "720P50"));
            hdmiModeOptions.add(new StateOption("720P60", "720P60"));
            hdmiModeOptions.add(new StateOption("576P", "567P"));
            hdmiModeOptions.add(new StateOption("576I", "567I"));
            hdmiModeOptions.add(new StateOption("480P", "480P"));
            hdmiModeOptions.add(new StateOption("480I", "480I"));

            inputSourceOptions.add(new StateOption("0", getString("blu_ray", "Blu-ray Player")));
            inputSourceOptions.add(new StateOption("1", getString("hdmi_in", "HDMI In")));
            inputSourceOptions.add(new StateOption("2", getString("arc", "ARC")));

            if (model == MODEL205) {
                inputSourceOptions.add(new StateOption("3", getString("optical", "Optical In")));
                inputSourceOptions.add(new StateOption("4", getString("coaxial", "Coaxial In")));
                inputSourceOptions.add(new StateOption("5", getString("usb", "USB Audio In")));
            }
        }
    }

    private @Nullable String getString(String i18nKey, String defaultStr) {
        return translationProvider.getText(bundle, "option." + i18nKey, defaultStr, localeProvider.getLocale());
    }

    private void handleHdmiModeUpdate(String updateData) {
        // ugly... a couple of the query hdmi mode response codes on the earlier models don't match the code to set it
        // some of this protocol is weird like that...
        if ("480I".equals(updateData)) {
            updateChannelState(CHANNEL_HDMI_MODE, "SDI");
        } else if ("480P".equals(updateData)) {
            updateChannelState(CHANNEL_HDMI_MODE, "SDP");
        } else if ("4K*2K".equals(updateData)) {
            updateChannelState(CHANNEL_HDMI_MODE, "4K2K");
        } else {
            updateChannelState(CHANNEL_HDMI_MODE, updateData);
        }
    }
}
