"""
3 types of SCC captions:
    Roll-Up
    Paint-On
    Pop-On

Commands:
    94ae - [ENM] - Erase Non-displayed(buffer) Memory
    942c - [EDM] - Erase Displayed Memory
    9420 - [RCL] - Resume Caption Loading
    9429 - [RDC] - Resume Direct Captioning

    9425, 9426, 94a7 - [RU2], [RU3], [RU4] (roll up captions 2,3 or 4 rows)
        - these commands set the number of expected lines

    94ad - (in CEA-608-E: 142d) - [CR] carriage return.
        - This actually rolls the captions up as many rows as specified by
        [RU1], [RU2], or [RU3]

    80 - no-op char. Doesn't do anything, but must be used with other
        characters, to make a 2 byte word

    97a1, 97a2, 9723 - [TO] move 1, 2 or 3 columns - Tab Over command
        - this moves the positioning 1, 2, or 3 columns to the right

    942f - [EOC] - display the buffer on the screen - End Of Caption
    ... - [PAC] - Preamble address code (can set positioning and style)
        - All the PACs are specified by the first and second byte combined
        from pycaption.scc.constants.PAC_BYTES_TO_POSITIONING_MAP

    9429 - [RDC] - Resume Direct Captioning
    94a4 - (in CEA-608-E: 1424) - [DER] Delete to End of Row


Pop-On:
    The commands should usually appear in this order. Not strict though, and
    the the commands don't have to necessarily be on the same row.

    1. 94ae [ENM] (erase non displayed memory)
    2. 9420 [RCL] (resume caption loading => this command here means we're using
     Pop-On captions)
    2.1? [ENM] - if step 0 was skipped?
    3. [PAC] Positioning/ styling command
     (can position on columns divisible by 4)
        The control chars is called Preamble Address Code [PAC].
    4. If positioning needs to be on columns not divisible by 4, use a [TO]
     command
    5. text
    6. 942c [EDM] - optionally, erase the currently displayed caption
    7. 942f [EOC] display the caption


Roll-Up:
    1. [RU2], [RU3] or [RU4]    - sets Roll-Up style and depth
        - these set the Roll-Up style: (characteristic command)
    2. [CR] to roll the display up 1 row...lol?
    3. [PAC] - sets the indent of the base row


Paint-On:
    1. [RDC] - sets the Paint-On style (characteristic command)
    2. [PAC]
    3. text
    4. [PAC]
    5. text or [DER]

There are some rules regarding the parity of the commands.

This resource:
http://www.theneitherworld.com/mcpoodle/SCC_TOOLS/DOCS/SCC_FORMAT.HTML
 specifies that there are interpreters which only work if the commands have an
 odd parity. This however is not consistent, and we might not handle well
 these cases. Odd parity of a command means that, converting toe word into
 binary, should result in an odd number of '1's. The PAC commands obey this
 rule, but some do not. Some commands that do not are found in the COMMANDS
 dictionary. This is legacy logic, that I didn't know how to handle, and
 just carried over when implementing positioning.
"""

import re
from collections import deque
from copy import deepcopy

from pycaption.base import BaseReader, CaptionSet
from pycaption.exceptions import (
    CaptionLineLengthError,
    CaptionReadNoCaptions,
    CaptionReadTimingError,
)
from pycaption.geometry import HorizontalAlignmentEnum

from .constants import (
    CHARACTERS,
    COMMANDS,
    CUE_STARTING_COMMAND,
    EXTENDED_CHARS,
    HEADER,
    PAC_BYTES_TO_POSITIONING_MAP,
    PAC_TAB_OFFSET_COMMANDS,
    SPECIAL_CHARS,
)
from .specialized_collections import (
    CaptionCreator,
    InstructionNodeCreator,
    NotifyingDict,
    PopOnCue,
)
from .state_machines import DefaultProvidingPositionTracker

_TIMECODE_RE = re.compile(r"\d{2}:\d{2}:\d{2}[:;](\d{1,2})")


class SCCReader(BaseReader):
    """Converts a given unicode string to a CaptionSet.

    This can be then later used for converting into any other supported formats
    """

    def __init__(self, *args, **kw):
        super().__init__(*args, **kw)
        self.caption_stash = CaptionCreator()
        self.time_translator = _SccTimeTranslator()

        self.position_tracker = DefaultProvidingPositionTracker()

        self.last_command = ""
        self.double_starter = False

        self.buffer_dict = NotifyingDict()

        self.buffer_dict["pop"] = self._new_buffer()
        self.buffer_dict["paint"] = self._new_buffer()
        self.buffer_dict["roll"] = self._new_buffer()

        # Call this method when the active key changes
        self.buffer_dict.add_change_observer(self._flush_implicit_buffers)
        self.buffer_dict.set_active("pop")

        self.pop_ons_queue = deque()

        self.roll_rows = []
        self.roll_rows_expected = 0
        self.simulate_roll_up = False

        self._raw_lines = []
        self.time = 0

    def detect(self, content):
        """Checks whether the given content is a proper SCC file

        :type content: str or bytes

        :rtype: bool
        """
        content = self._decode_content(content)
        lines = content.splitlines()
        if lines[0] == HEADER:
            return True
        else:
            return False

    def read(self, content, lang="en-US", simulate_roll_up=False, offset=0):
        """Converts the unicode string into a CaptionSet

        :type content: str
        :param content: The SCC content to be converted to a CaptionSet

        :type lang: str
        :param lang: The language of the caption

        :type simulate_roll_up: bool
        :param simulate_roll_up: If True, when converting to other formats,
            the resulting captions will contain all the rows that were visible
            on the screen when the captions were rolling up.

        :type offset: int
        :param offset:

        :rtype: CaptionSet
        """
        content = self._decode_content(content)

        self.simulate_roll_up = simulate_roll_up
        self.time_translator.offset = offset * 1000000
        lines = content.splitlines()
        self._raw_lines = lines

        # loop through each line except the first
        for line in lines[1:]:
            self._translate_line(line)

        self._flush_implicit_buffers(self.buffer_dict.active_key)

        captions = CaptionSet(
            {lang: self.caption_stash.get_all()},
            visual_alignment_default=HorizontalAlignmentEnum.CENTER,
        )
        self._validate_captions(captions, lang)
        self._fix_last_captions_without_ending(captions.get_captions(lang))

        return captions

    def _validate_captions(self, captions, lang):
        """Validate parsed captions for SCC-specific constraints.

        Raises CaptionReadNoCaptions if no captions were parsed,
        CaptionLineLengthError if any line exceeds 32 characters,
        CaptionReadTimingError if any timecode has frame >= 30,
        and CaptionReadTimingError if any cue is shorter than 0.05s.
        """
        if captions.is_empty():
            raise CaptionReadNoCaptions("empty caption file")

        self._validate_frame_numbers()
        self._validate_line_lengths()

        for cap in captions.get_captions(lang):
            if 0 < cap.end - cap.start < 50000:
                raise CaptionReadTimingError(
                    f"Unsupported cue duration around {cap.format_start()} "
                    f'for line beginning with "{cap.get_text()}". Duration '
                    f"must be at least 0.05 seconds."
                )

    def _validate_frame_numbers(self):
        """Raise CaptionReadTimingError if any timecode has frames >= 30."""
        violations = []
        for line in self._raw_lines:
            match = _TIMECODE_RE.match(line.strip())
            if match:
                frames = int(match.group(1))
                if frames >= 30:
                    timecode = match.group(0)
                    violations.append(f"{timecode} (frame {frames})")
        if violations:
            raise CaptionReadTimingError(
                "Frame number must be 0-29 in SCC timecodes.\n"
                "Invalid timecodes:\n" + "\n".join(violations)
            )

    def _validate_line_lengths(self):
        """Raise CaptionLineLengthError if any line exceeds 32 characters."""
        violations = [
            f"around {cap.format_start()} - {line} - Length {len(line)}"
            for cap in (c.to_real_caption() for c in self.caption_stash._collection)
            for line in cap.get_text().split("\n")
            if len(line) > 32
        ]
        if violations:
            raise CaptionLineLengthError(
                "32 character limit for caption cue in scc file.\n"
                "Lines longer than 32:\n" + "\n".join(violations)
            )

    @staticmethod
    def _fix_last_captions_without_ending(caption_list):
        """Set end = start + 4s for trailing captions that were never ended."""
        for caption in reversed(caption_list):
            if caption.end:
                return
            caption.end = caption.start + 4 * 1000 * 1000

    def _flush_implicit_buffers(self, old_key=None, *args):
        """Convert to Captions those buffers whose behavior is implicit.

        The Pop-On buffer is explicit. New captions are created from it
        with the command 'End Of Caption' [EOC], '942f'

        The other 2 buffers, Roll-Up and Paint-On we treat as "more" implicit,
        meaning that they can be displayed by a command on the next row.
        If they're on the last row however, or if the caption type is changing,
        we make sure to convert the buffers to text, so we don't lose any info.
        """
        if old_key == "pop":
            if self.pop_ons_queue:
                self._pop_on()

        elif old_key == "roll":
            if not self.buffer.is_empty():
                self._roll_up()

        elif old_key == "paint" and not self.buffer.is_empty():
            self.caption_stash.create_and_store(
                self.buffer, self.time, caption_mode="paint_on"
            )
            self._reset_buffer()

    def _translate_line(self, line):
        """Parse a single SCC file line into timestamp and word pairs."""
        # ignore blank lines
        if line.strip() == "":
            return

        # split line in timestamp and words
        r = re.compile(r"([0-9:;]*)([\s\t]*)((.)*)")
        parts = r.findall(line.lower())

        self.time_translator.start_at(parts[0][0])
        word_list = parts[0][2].split(" ")

        for idx, word in enumerate(word_list):
            word = word.strip()
            if len(word) == 4:
                # Look ahead for the next command, skipping the duplicate
                # that SCC uses for error-correction (same word repeated).
                next_idx = idx + 1
                if next_idx < len(word_list) and word_list[next_idx].strip() == word:
                    next_idx += 1
                next_command = (
                    word_list[next_idx] if next_idx < len(word_list) else None
                )
                self._translate_word(word=word, next_command=next_command)

    def _translate_word(self, word, next_command=None):
        """Dispatch a single 4-char hex word as command, special char, or text."""
        if self._handle_double_command(word):
            # count frames for timing
            self.time_translator._frames += 1
            return
        if word in COMMANDS or self._is_pac_command(word):
            self._translate_command(word=word, next_command=next_command)

        # second, check if word is a special character
        elif word in SPECIAL_CHARS:
            self._translate_special_char(word)

        elif word in EXTENDED_CHARS:
            self._translate_extended_char(word)

        # third, try to convert word into 2 characters
        else:
            self._translate_characters(word)

        # count frames for timing
        self.time_translator._frames += 1

    def _is_doubled_type(self, word):
        """Check if this word type is subject to CEA-608 doubling."""
        is_doubled = (
            (word != "94a1" and word in COMMANDS)
            or self._is_pac_command(word)
            or word in SPECIAL_CHARS
        )
        if self.double_starter:
            is_doubled = is_doubled or word in EXTENDED_CHARS or word == "94a1"
        return is_doubled

    def _handle_double_command(self, word):
        """Detect and skip redundant doubled commands used for error correction.

        Returns True if this word is the second copy and should be skipped.

        :rtype: bool
        """
        if word in CUE_STARTING_COMMAND and word != self.last_command:
            self.double_starter = False

        if self._is_doubled_type(word) and word == self.last_command:
            if word in CUE_STARTING_COMMAND:
                self.double_starter = True
            self.last_command = ""
            return True

        if self._is_pac_command(word) and word in self.last_command:
            self.last_command = ""
            return True

        if word in PAC_TAB_OFFSET_COMMANDS:
            if self._is_pac_command(self.last_command):
                self.last_command += f" {word}"
                return False
            return True

        self.last_command = word
        return False

    def _translate_special_char(self, word):
        """Decode a special character code and add it to the active buffer."""
        self.buffer.add_chars(SPECIAL_CHARS[word])

    def _translate_extended_char(self, word):
        """
        Each of the 64 Extended Characters incorporates an automatic BS.
        When an Extended Character is received, the cursor moves to the
        left one column position (unless the Extended Character is the first
        character on a row), erasing any character which may be in that location,
        then displays the Extended Character.
        """
        self.buffer.handle_backspace(word)
        # add to buffer
        self.buffer.add_chars(EXTENDED_CHARS[word])

    _ROLL_UP_DEPTH = {"9425": 2, "9426": 3, "94a7": 4}

    def _translate_command(self, word, next_command=None):
        """Route a CEA-608 control command to the appropriate handler."""
        if word == "9420":
            self._cmd_pop_on()
        elif word == "9429":
            self._cmd_paint_on()
        elif word in self._ROLL_UP_DEPTH:
            self._cmd_roll_up(word)
        elif word == "94ae":
            self._cmd_erase_non_displayed()
        elif word == "942f":
            self._cmd_end_of_caption()
        elif word == "94ad":
            self._cmd_carriage_return()
        elif word == "942c":
            self._cmd_erase_displayed()
        else:
            self.buffer.interpret_command(command=word, next_command=next_command)

    def _cmd_pop_on(self):
        """Handle Resume Caption Loading [RCL] - switch to pop-on mode."""
        self.buffer_dict.set_active("pop")

    def _cmd_paint_on(self):
        """Handle Resume Direct Captioning [RDC] - switch to paint-on mode."""
        self.buffer_dict.set_active("paint")
        self.roll_rows_expected = 1
        if not self.buffer.is_empty():
            self.caption_stash.create_and_store(
                self.buffer, self.time, caption_mode="paint_on"
            )
            self._reset_buffer()
        self.time = self.time_translator.get_time()

    def _cmd_roll_up(self, word):
        """Handle Roll-Up command [RU2/RU3/RU4] - switch to roll-up mode."""
        self.buffer_dict.set_active("roll")
        prev_rows = self.roll_rows_expected
        self.roll_rows_expected = self._ROLL_UP_DEPTH[word]

        if not self.buffer.is_empty():
            self.caption_stash.create_and_store(
                self.buffer,
                self.time,
                caption_mode="roll_up",
                roll_up_rows=prev_rows or self.roll_rows_expected,
            )
            self._reset_buffer()

        self.roll_rows = []
        self.time = self.time_translator.get_time()

    def _cmd_erase_non_displayed(self):
        """Handle Erase Non-Displayed Memory [ENM] - clear the write buffer."""
        self._reset_buffer()

    def _cmd_end_of_caption(self):
        """Handle End Of Caption [EOC] - queue buffer for display."""
        self.time = self.time_translator.get_time()
        if self.pop_ons_queue:
            self._pop_on(end=self.time)
        if self.buffer.is_empty():
            return
        cue = PopOnCue(buffer=deepcopy(self.buffer), start=self.time, end=0)
        self.pop_ons_queue.appendleft(cue)
        self._reset_buffer()

    def _cmd_carriage_return(self):
        """Handle Carriage Return [CR] - roll up the current buffer."""
        if not self.buffer.is_empty():
            self._roll_up()

    def _cmd_erase_displayed(self):
        """Handle Erase Displayed Memory [EDM] - finalize visible captions."""
        edm_time = self.time_translator.get_time()
        if self.pop_ons_queue:
            self._pop_on(end=edm_time)
        if (
            self.buffer_dict.active_key in ("paint", "roll")
            and not self.buffer.is_empty()
        ):
            mode = "paint_on" if self.buffer_dict.active_key == "paint" else "roll_up"
            roll_rows = (
                self.roll_rows_expected
                if self.buffer_dict.active_key == "roll"
                else None
            )
            self.caption_stash.create_and_store(
                self.buffer,
                self.time,
                edm_time,
                caption_mode=mode,
                roll_up_rows=roll_rows,
            )
            self._reset_buffer()
            self.time = edm_time

    def _new_buffer(self):
        """Create a fresh InstructionNodeCreator bound to the shared
        position tracker."""
        return InstructionNodeCreator(position_tracker=self.position_tracker)

    def _reset_buffer(self):
        """Replace the active buffer with a fresh creator and reset position state."""
        self.buffer = self._new_buffer()
        self.position_tracker.reset_for_new_caption()

    def _translate_characters(self, word):
        """Decode a 4-char hex word as two printable characters."""
        # split word into the 2 bytes
        byte1 = word[:2]
        byte2 = word[2:]

        # check to see if the the bytes are recognized characters
        if byte1 not in CHARACTERS or byte2 not in CHARACTERS:
            return

        self.buffer.add_chars(CHARACTERS[byte1], CHARACTERS[byte2])

    @property
    def buffer(self):
        """Returns the currently active buffer"""
        return self.buffer_dict.get_active()

    @buffer.setter
    def buffer(self, value):
        """Sets a new value to the active key

        :param value: any object
        """
        try:
            key = self.buffer_dict.active_key
            self.buffer_dict[key] = value
        except TypeError:
            pass

    def _roll_up(self):
        """Flush the roll-up buffer as a caption and prepare for the next row."""
        # We expect the active buffer to be the roll buffer
        if self.simulate_roll_up:
            if self.roll_rows_expected > 1:
                if len(self.roll_rows) >= self.roll_rows_expected:
                    self.roll_rows.pop(0)

                self.roll_rows.append(self.buffer)
                self.buffer = InstructionNodeCreator.from_list(
                    self.roll_rows, position_tracker=self.position_tracker
                )

        # convert buffer and empty
        self.caption_stash.create_and_store(
            self.buffer,
            self.time,
            caption_mode="roll_up",
            roll_up_rows=self.roll_rows_expected,
        )
        self._reset_buffer()

        # configure time
        self.time = self.time_translator.get_time()

        # try to insert the proper ending time for the previous caption
        self.caption_stash.correct_last_timing(self.time, force=True)

    def _pop_on(self, end=0):
        """Dequeue and store the oldest pop-on cue as a finalized caption."""
        pop_on_cue = self.pop_ons_queue.pop()
        self.caption_stash.create_and_store(
            pop_on_cue.buffer, pop_on_cue.start, end, caption_mode="pop_on"
        )

    @staticmethod
    def _is_pac_command(word):
        """Check whether the given word is a Preamble Address Code [PAC] command.

        :type word: str
        :param word: 4 letter unicode command
        :rtype: bool
        """
        byte1, byte2 = word[:2], word[2:]
        try:
            PAC_BYTES_TO_POSITIONING_MAP[byte1][byte2]
        except KeyError:
            return False
        else:
            return True


class _SccTimeTranslator:
    """Converts SCC time to microseconds, keeping track of frames passed"""

    def __init__(self):
        self._time = "00:00:00;00"

        # microseconds. The offset from which we begin the time calculation
        self.offset = 0
        self._frames = 0

    def get_time(self):
        """Returns the time, in microseconds. Takes into account the number of
        frames passed, and the offset

        :rtype: int
        """
        base_frames = int(self._time[-2:])
        total_frames = base_frames + self._frames
        stamp = self._time[:-2] + str(total_frames)
        return self._translate_time(stamp, self.offset)

    @staticmethod
    def _translate_time(stamp, offset):
        """
        :param stamp:
        :type offset: int
        :param offset: Subtract this many microseconds from the calculated time
            Helpful for when the captions are off by some time interval.
        :rtype: int
        """
        if not re.match(r"\d{2}:\d{2}:\d{2}[:;]\d{1,2}", stamp):
            raise CaptionReadTimingError(
                "Timestamps should follow the hour:minute:seconds"
                ";frames or hour:minute:seconds:frames format. Please correct "
                f"the following time: {stamp}."
            )

        if ";" in stamp:
            # Drop-frame timebase runs at the same rate as wall clock
            seconds_per_timestamp_second = 1.0
        else:
            # Non-drop-frame timebase runs "slow"
            # 1 second of timecode is longer than an actual second (1.001s)
            seconds_per_timestamp_second = 1001.0 / 1000.0

        time_split = stamp.replace(";", ":").split(":")

        timestamp_seconds = (
            int(time_split[0]) * 3600
            + int(time_split[1]) * 60
            + int(time_split[2])
            + int(time_split[3]) / 30.0
        )

        seconds = timestamp_seconds * seconds_per_timestamp_second
        microseconds = seconds * 1000 * 1000 - offset

        if microseconds < 0:
            microseconds = 0

        return microseconds

    def start_at(self, timespec):
        """Reset the counter to the given time

        :type timespec: str
        """
        self._time = timespec
        self._frames = 0
