import argparse
import struct
import sys
from typing import Optional, Union

from Crypto.Cipher import AES
from collections import namedtuple
import array

CENCSampleAuxiliaryDataFormat = namedtuple("CENCSampleAuxiliaryDataFormat", ["is_encrypted", "iv", "sub_samples"])


class MP4Atom:
    """
    Represents an MP4 atom, which is a basic unit of data in an MP4 file.
    Each atom contains a header (size and type) and data.
    """

    __slots__ = ("atom_type", "size", "data")

    def __init__(self, atom_type: bytes, size: int, data: Union[memoryview, bytearray]):
        """
        Initializes an MP4Atom instance.

        Args:
            atom_type (bytes): The type of the atom.
            size (int): The size of the atom.
            data (Union[memoryview, bytearray]): The data contained in the atom.
        """
        self.atom_type = atom_type
        self.size = size
        self.data = data

    def __repr__(self):
        return f"<MP4Atom type={self.atom_type}, size={self.size}>"

    def pack(self):
        """
        Packs the atom into binary data.

        Returns:
            bytes: Packed binary data with size, type, and data.
        """
        return struct.pack(">I", self.size) + self.atom_type + self.data


class MP4Parser:
    """
    Parses MP4 data to extract atoms and their structure.
    """

    def __init__(self, data: memoryview):
        """
        Initializes an MP4Parser instance.

        Args:
            data (memoryview): The binary data of the MP4 file.
        """
        self.data = data
        self.position = 0

    def read_atom(self) -> Optional[MP4Atom]:
        """
        Reads the next atom from the data.

        Returns:
            Optional[MP4Atom]: MP4Atom object or None if no more atoms are available.
        """
        pos = self.position
        if pos + 8 > len(self.data):
            return None

        size, atom_type = struct.unpack_from(">I4s", self.data, pos)
        pos += 8

        if size == 1:
            if pos + 8 > len(self.data):
                return None
            size = struct.unpack_from(">Q", self.data, pos)[0]
            pos += 8

        if size < 8 or pos + size - 8 > len(self.data):
            return None

        atom_data = self.data[pos : pos + size - 8]
        self.position = pos + size - 8
        return MP4Atom(atom_type, size, atom_data)

    def list_atoms(self) -> list[MP4Atom]:
        """
        Lists all atoms in the data.

        Returns:
            list[MP4Atom]: List of MP4Atom objects.
        """
        atoms = []
        original_position = self.position
        self.position = 0
        while self.position + 8 <= len(self.data):
            atom = self.read_atom()
            if not atom:
                break
            atoms.append(atom)
        self.position = original_position
        return atoms

    def _read_atom_at(self, pos: int, end: int) -> Optional[MP4Atom]:
        if pos + 8 > end:
            return None

        size, atom_type = struct.unpack_from(">I4s", self.data, pos)
        pos += 8

        if size == 1:
            if pos + 8 > end:
                return None
            size = struct.unpack_from(">Q", self.data, pos)[0]
            pos += 8

        if size < 8 or pos + size - 8 > end:
            return None

        atom_data = self.data[pos : pos + size - 8]
        return MP4Atom(atom_type, size, atom_data)

    def print_atoms_structure(self, indent: int = 0):
        """
        Prints the structure of all atoms in the data.

        Args:
            indent (int): The indentation level for printing.
        """
        pos = 0
        end = len(self.data)
        while pos + 8 <= end:
            atom = self._read_atom_at(pos, end)
            if not atom:
                break
            self.print_single_atom_structure(atom, pos, indent)
            pos += atom.size

    def print_single_atom_structure(self, atom: MP4Atom, parent_position: int, indent: int):
        """
        Prints the structure of a single atom.

        Args:
            atom (MP4Atom): The atom to print.
            parent_position (int): The position of the parent atom.
            indent (int): The indentation level for printing.
        """
        try:
            atom_type = atom.atom_type.decode("utf-8")
        except UnicodeDecodeError:
            atom_type = repr(atom.atom_type)
        print(" " * indent + f"Type: {atom_type}, Size: {atom.size}")

        child_pos = 0
        child_end = len(atom.data)
        while child_pos + 8 <= child_end:
            child_atom = self._read_atom_at(parent_position + 8 + child_pos, parent_position + 8 + child_end)
            if not child_atom:
                break
            self.print_single_atom_structure(child_atom, parent_position, indent + 2)
            child_pos += child_atom.size


class MP4Decrypter:
    """
    Class to handle the decryption of CENC encrypted MP4 segments.

    Supports multi-track segments (e.g., video + audio) by properly handling
    data offsets and encryption info for each track.

    Attributes:
        key_map (dict[bytes, bytes]): Mapping of KIDs to decryption keys.
        current_key (Optional[bytes]): Current decryption key (for single-track compatibility).
        trun_sample_sizes (array.array): Array of sample sizes from the 'trun' box.
        current_sample_info (list): List of sample information from the 'senc' box.
        total_encryption_overhead (int): Total size of encryption-related boxes (senc, saiz, saio) across all trafs.
        default_sample_size (int): Default sample size from tfhd, used when trun doesn't specify sizes.
        track_infos (list): List of track info dicts for multi-track mdat decryption.
        encryption_scheme (bytes): Encryption scheme type (b"cenc", b"cens", b"cbc1", or b"cbcs").
        crypt_byte_block (int): Number of encrypted 16-byte blocks in pattern encryption (for cbcs).
        skip_byte_block (int): Number of clear 16-byte blocks in pattern encryption (for cbcs).
    """

    def __init__(self, key_map: dict[bytes, bytes]):
        """
        Initializes the MP4Decrypter with a key map.

        Args:
            key_map (dict[bytes, bytes]): Mapping of track IDs to decryption keys.
        """
        self.key_map = key_map
        self.current_key = None
        self.trun_sample_sizes = array.array("I")
        self.current_sample_info = []
        self.total_encryption_overhead = 0  # Total overhead from all trafs (senc, saiz, saio)
        self.default_sample_size = 0
        # Track info for multi-track support: list of (data_offset, sample_sizes, sample_info, key, default_sample_size)
        self.track_infos = []
        # IV size from tenc box (default 8 for GPAC, 16 for Bento4)
        self.default_iv_size = 8
        # Encryption scheme: b"cenc" (AES-CTR), b"cens" (AES-CTR pattern), b"cbc1" (AES-CBC), b"cbcs" (AES-CBC pattern)
        self.encryption_scheme = b"cenc"  # Default to cenc (AES-CTR)
        # Pattern encryption parameters (for cbcs/cens) - default values
        self.crypt_byte_block = 1  # Default: encrypt 1 block
        self.skip_byte_block = 9  # Default: skip 9 blocks
        # Constant IV for CBCS (when default_Per_Sample_IV_Size is 0)
        self.constant_iv: Optional[bytes] = None
        # Per-track encryption settings (track_id -> {crypt, skip, iv})
        self.track_encryption_settings: dict[int, dict] = {}
        # Extracted KIDs from tenc boxes (track_id -> kid)
        self.extracted_kids: dict[int, bytes] = {}
        # Current track ID being processed
        self.current_track_id: int = 0

    def decrypt_segment(self, combined_segment: bytes, include_init: bool = True) -> bytes:
        """
        Decrypts a combined MP4 segment.

        Args:
            combined_segment (bytes): Combined initialization and media segment.
            include_init (bool): If True, include processed init atoms (ftyp, moov) in output.
                If False, only return media atoms (moof, sidx, mdat) for use with EXT-X-MAP.

        Returns:
            bytes: Decrypted segment content.
        """
        data = memoryview(combined_segment)
        parser = MP4Parser(data)
        atoms = parser.list_atoms()

        atom_process_order = [b"moov", b"moof", b"sidx", b"mdat"]

        processed_atoms = {}
        for atom_type in atom_process_order:
            if atom := next((a for a in atoms if a.atom_type == atom_type), None):
                processed_atoms[atom_type] = self._process_atom(atom_type, atom)

        result = bytearray()
        # Init atoms to skip when include_init is False
        # Note: styp is a segment type atom that should be kept in segments
        init_atoms = {b"ftyp", b"moov"}

        for atom in atoms:
            # Skip init atoms if not including init
            if not include_init and atom.atom_type in init_atoms:
                continue

            if atom.atom_type in processed_atoms:
                processed_atom = processed_atoms[atom.atom_type]
                result.extend(processed_atom.pack())
            else:
                result.extend(atom.pack())

        return bytes(result)

    def process_init_only(self, init_segment: bytes) -> bytes:
        """
        Processes only the initialization segment, removing encryption-related boxes.
        Used for EXT-X-MAP where init is served separately.

        Args:
            init_segment (bytes): Initialization segment data.

        Returns:
            bytes: Processed init segment with encryption boxes removed.
        """
        data = memoryview(init_segment)
        parser = MP4Parser(data)
        atoms = parser.list_atoms()

        processed_atoms = {}
        # Only process moov for init segments
        if moov_atom := next((a for a in atoms if a.atom_type == b"moov"), None):
            processed_atoms[b"moov"] = self._process_moov(moov_atom)

        result = bytearray()
        for atom in atoms:
            if atom.atom_type in processed_atoms:
                processed_atom = processed_atoms[atom.atom_type]
                result.extend(processed_atom.pack())
            else:
                result.extend(atom.pack())

        return bytes(result)

    def _process_atom(self, atom_type: bytes, atom: MP4Atom) -> MP4Atom:
        """
        Processes an MP4 atom based on its type.

        Args:
            atom_type (bytes): Type of the atom.
            atom (MP4Atom): The atom to process.

        Returns:
            MP4Atom: Processed atom.
        """
        if atom_type == b"moov":
            return self._process_moov(atom)
        elif atom_type == b"moof":
            return self._process_moof(atom)
        elif atom_type == b"sidx":
            return self._process_sidx(atom)
        elif atom_type == b"mdat":
            return self._decrypt_mdat(atom)
        else:
            return atom

    def _process_moov(self, moov: MP4Atom) -> MP4Atom:
        """
        Processes the 'moov' (Movie) atom, which contains metadata about the entire presentation.
        This includes information about tracks, media data, and other movie-level metadata.

        Args:
            moov (MP4Atom): The 'moov' atom to process.

        Returns:
            MP4Atom: Processed 'moov' atom with updated track information.
        """
        parser = MP4Parser(moov.data)
        new_moov_data = bytearray()

        for atom in iter(parser.read_atom, None):
            if atom.atom_type == b"trak":
                new_trak = self._process_trak(atom)
                new_moov_data.extend(new_trak.pack())
            elif atom.atom_type != b"pssh":
                # Skip PSSH boxes as they are not needed in the decrypted output
                new_moov_data.extend(atom.pack())

        return MP4Atom(b"moov", len(new_moov_data) + 8, new_moov_data)

    def _process_moof(self, moof: MP4Atom) -> MP4Atom:
        """
        Processes the 'moof' (Movie Fragment) atom, which contains metadata about a fragment.
        This includes information about track fragments, sample information, and encryption data.

        Args:
            moof (MP4Atom): The 'moof' atom to process.

        Returns:
            MP4Atom: Processed 'moof' atom with updated track information.
        """
        parser = MP4Parser(moof.data)
        atoms = parser.list_atoms()

        # Reset track infos for this moof
        self.track_infos = []

        # First pass: calculate total encryption overhead from all trafs
        self.total_encryption_overhead = 0
        for atom in atoms:
            if atom.atom_type == b"traf":
                traf_parser = MP4Parser(atom.data)
                traf_atoms = traf_parser.list_atoms()
                traf_overhead = sum(a.size for a in traf_atoms if a.atom_type in {b"senc", b"saiz", b"saio"})
                self.total_encryption_overhead += traf_overhead

        # Second pass: process atoms
        new_moof_data = bytearray()
        for atom in atoms:
            if atom.atom_type == b"traf":
                new_traf = self._process_traf(atom)
                new_moof_data.extend(new_traf.pack())
            else:
                new_moof_data.extend(atom.pack())

        return MP4Atom(b"moof", len(new_moof_data) + 8, new_moof_data)

    def _process_traf(self, traf: MP4Atom) -> MP4Atom:
        """
        Processes the 'traf' (Track Fragment) atom, which contains information about a track fragment.
        This includes sample information, sample encryption data, and other track-level metadata.

        Args:
            traf (MP4Atom): The 'traf' atom to process.

        Returns:
            MP4Atom: Processed 'traf' atom with updated sample information.
        """
        parser = MP4Parser(traf.data)
        new_traf_data = bytearray()
        tfhd = None
        sample_count = 0
        trun_data_offset = 0
        sample_info = []
        track_default_sample_size = 0

        atoms = parser.list_atoms()

        for atom in atoms:
            if atom.atom_type == b"tfhd":
                tfhd = atom
                new_traf_data.extend(atom.pack())
                # Extract default_sample_size from tfhd if present
                self._parse_tfhd(atom)
                track_default_sample_size = self.default_sample_size
            elif atom.atom_type == b"trun":
                sample_count, trun_data_offset = self._process_trun(atom)
                new_trun = self._modify_trun(atom)
                new_traf_data.extend(new_trun.pack())
            elif atom.atom_type == b"senc":
                # Parse senc but don't include it in the new decrypted traf data and similarly don't include saiz and saio
                sample_info = self._parse_senc(atom, sample_count)
            elif atom.atom_type not in {b"saiz", b"saio"}:
                new_traf_data.extend(atom.pack())

        if tfhd:
            tfhd_track_id = struct.unpack_from(">I", tfhd.data, 4)[0]
            track_key = self._get_key_for_track(tfhd_track_id)
            # Get per-track encryption settings if available
            track_enc_settings = self.track_encryption_settings.get(tfhd_track_id, {})
            # Store track info for multi-track mdat decryption
            # Copy the sample sizes array since it gets overwritten for each track
            track_sample_sizes = array.array("I", self.trun_sample_sizes)
            self.track_infos.append(
                {
                    "data_offset": trun_data_offset,
                    "sample_sizes": track_sample_sizes,
                    "sample_info": sample_info,
                    "key": track_key,
                    "default_sample_size": track_default_sample_size,
                    "track_id": tfhd_track_id,
                    "crypt_byte_block": track_enc_settings.get("crypt_byte_block", self.crypt_byte_block),
                    "skip_byte_block": track_enc_settings.get("skip_byte_block", self.skip_byte_block),
                    "constant_iv": track_enc_settings.get("constant_iv", self.constant_iv),
                }
            )
            # Keep backward compatibility for single-track case
            self.current_key = track_key
            self.current_sample_info = sample_info

        return MP4Atom(b"traf", len(new_traf_data) + 8, new_traf_data)

    def _parse_tfhd(self, tfhd: MP4Atom) -> None:
        """
        Parses the 'tfhd' (Track Fragment Header) atom to extract default sample size.

        Args:
            tfhd (MP4Atom): The 'tfhd' atom to parse.
        """
        data = tfhd.data
        flags = struct.unpack_from(">I", data, 0)[0] & 0xFFFFFF
        offset = 8  # Skip version_flags (4) + track_id (4)

        # Skip optional fields based on flags
        if flags & 0x000001:  # base-data-offset-present
            offset += 8
        if flags & 0x000002:  # sample-description-index-present
            offset += 4
        if flags & 0x000008:  # default-sample-duration-present
            offset += 4
        if flags & 0x000010:  # default-sample-size-present
            if offset + 4 <= len(data):
                self.default_sample_size = struct.unpack_from(">I", data, offset)[0]
            offset += 4
        # We don't need default-sample-flags (0x000020)

    def _decrypt_mdat(self, mdat: MP4Atom) -> MP4Atom:
        """
        Decrypts the 'mdat' (Media Data) atom, which contains the actual media data (audio, video, etc.).
        The decryption is performed using the current decryption key and sample information.
        Supports multiple tracks by using track_infos collected during moof processing.

        Args:
            mdat (MP4Atom): The 'mdat' atom to decrypt.

        Returns:
            MP4Atom: Decrypted 'mdat' atom with decrypted media data.
        """
        mdat_data = mdat.data

        # Use multi-track decryption if we have track_infos
        if self.track_infos:
            return self._decrypt_mdat_multi_track(mdat)

        # Fallback to single-track decryption for backward compatibility
        if not self.current_key or not self.current_sample_info:
            return mdat  # Return original mdat if we don't have decryption info

        decrypted_samples = bytearray()
        position = 0

        for i, info in enumerate(self.current_sample_info):
            if position >= len(mdat_data):
                break  # No more data to process

            # Get sample size from trun, or use default_sample_size from tfhd, or remaining data
            sample_size = 0
            if i < len(self.trun_sample_sizes):
                sample_size = self.trun_sample_sizes[i]

            # If sample size is 0 (not specified in trun), use default from tfhd
            if sample_size == 0:
                sample_size = self.default_sample_size if self.default_sample_size > 0 else len(mdat_data) - position

            sample = mdat_data[position : position + sample_size]
            position += sample_size
            decrypted_sample = self._decrypt_sample(sample, info, self.current_key)
            decrypted_samples.extend(decrypted_sample)

        return MP4Atom(b"mdat", len(decrypted_samples) + 8, decrypted_samples)

    def _decrypt_mdat_multi_track(self, mdat: MP4Atom) -> MP4Atom:
        """
        Decrypts the 'mdat' atom with support for multiple tracks.
        Each track's samples are located at their respective data_offset positions.

        The data_offset in trun is the byte offset from the start of the moof box
        to the first byte of sample data. Since mdat immediately follows moof,
        we can calculate the position within mdat as:
        position_in_mdat = data_offset - moof_size

        But we don't have moof_size here directly. However, we know that the first
        track's data_offset minus 8 (mdat header) gives us the moof size.

        For simplicity, we sort tracks by data_offset and process them in order,
        using the data_offset difference to determine where each track's samples start.

        Args:
            mdat (MP4Atom): The 'mdat' atom to decrypt.

        Returns:
            MP4Atom: Decrypted 'mdat' atom with decrypted media data from all tracks.
        """
        mdat_data = mdat.data

        if not self.track_infos:
            return mdat

        # Sort tracks by data_offset to process in order
        sorted_tracks = sorted(self.track_infos, key=lambda x: x["data_offset"])

        # The first track's data_offset tells us where mdat data starts relative to moof
        # data_offset = moof_size + 8 (mdat header) for the first sample
        # So mdat_data_start_in_file = moof_start + first_data_offset
        # And position_in_mdat = data_offset - first_data_offset
        first_data_offset = sorted_tracks[0]["data_offset"]

        # Pre-allocate output buffer with original data (in case some parts aren't encrypted)
        decrypted_data = bytearray(mdat_data)

        # Process each track's samples at their respective offsets
        for track_info in sorted_tracks:
            data_offset = track_info["data_offset"]
            sample_sizes = track_info["sample_sizes"]
            sample_info = track_info["sample_info"]
            key = track_info["key"]
            default_sample_size = track_info["default_sample_size"]
            # Get per-track encryption settings
            track_crypt = track_info.get("crypt_byte_block", self.crypt_byte_block)
            track_skip = track_info.get("skip_byte_block", self.skip_byte_block)
            track_constant_iv = track_info.get("constant_iv", self.constant_iv)

            if not key or not sample_info:
                continue

            # Calculate start position in mdat
            # position = data_offset - first_data_offset (relative to first track's start)
            mdat_position = data_offset - first_data_offset

            for i, info in enumerate(sample_info):
                sample_size = 0
                if i < len(sample_sizes):
                    sample_size = sample_sizes[i]

                if sample_size == 0:
                    sample_size = default_sample_size if default_sample_size > 0 else 0

                if sample_size == 0:
                    continue

                if mdat_position + sample_size > len(mdat_data):
                    break

                sample = mdat_data[mdat_position : mdat_position + sample_size]
                decrypted_sample = self._decrypt_sample_with_track_settings(
                    sample, info, key, track_crypt, track_skip, track_constant_iv
                )

                # Write decrypted sample to output at the same position
                decrypted_data[mdat_position : mdat_position + len(decrypted_sample)] = decrypted_sample
                mdat_position += sample_size

        return MP4Atom(b"mdat", len(decrypted_data) + 8, bytes(decrypted_data))

    def _parse_senc(self, senc: MP4Atom, sample_count: int) -> list[CENCSampleAuxiliaryDataFormat]:
        """
        Parses the 'senc' (Sample Encryption) atom, which contains encryption information for samples.
        This includes initialization vectors (IVs) and sub-sample encryption data.

        For CBCS with constant IV (default_iv_size == 0 in tenc), the senc box only contains
        subsample info, not per-sample IVs. The constant IV from tenc is used instead.

        Args:
            senc (MP4Atom): The 'senc' atom to parse.
            sample_count (int): The number of samples.

        Returns:
            list[CENCSampleAuxiliaryDataFormat]: List of sample auxiliary data formats with encryption information.
        """
        data = memoryview(senc.data)
        version_flags = struct.unpack_from(">I", data, 0)[0]
        version, flags = version_flags >> 24, version_flags & 0xFFFFFF
        position = 4

        if version == 0:
            sample_count = struct.unpack_from(">I", data, position)[0]
            position += 4

        # Use the IV size from tenc box (8 or 16 bytes, or 0 for constant IV)
        iv_size = self.default_iv_size

        # For CBCS with constant IV, use the IV from tenc instead of per-sample IVs
        use_constant_iv = self.encryption_scheme == b"cbcs" and self.constant_iv is not None

        sample_info = []
        for _ in range(sample_count):
            if use_constant_iv:
                # Use constant IV from tenc box
                iv = self.constant_iv
            else:
                # Read per-sample IV from senc
                if position + iv_size > len(data):
                    break
                iv = data[position : position + iv_size].tobytes()
                position += iv_size

            sub_samples = []
            if flags & 0x000002 and position + 2 <= len(data):  # Check if subsample information is present
                subsample_count = struct.unpack_from(">H", data, position)[0]
                position += 2

                for _ in range(subsample_count):
                    if position + 6 <= len(data):
                        clear_bytes, encrypted_bytes = struct.unpack_from(">HI", data, position)
                        position += 6
                        sub_samples.append((clear_bytes, encrypted_bytes))
                    else:
                        break

            sample_info.append(CENCSampleAuxiliaryDataFormat(True, iv, sub_samples))

        return sample_info

    def _get_key_for_track(self, track_id: int) -> bytes:
        """
        Retrieves the decryption key for a given track ID from the key map.
        Uses the KID extracted from the tenc box if available, otherwise falls back to
        using the first key if only one key is provided.

        Args:
            track_id (int): The track ID.

        Returns:
            bytes: The decryption key for the specified track ID.
        """
        # If we have an extracted KID for this track, use it to look up the key
        if track_id in self.extracted_kids:
            extracted_kid = self.extracted_kids[track_id]
            # If KID is all zeros, it's a placeholder - use the provided key_id directly
            # Check if all bytes are zero
            is_all_zeros = all(b == 0 for b in extracted_kid) and len(extracted_kid) == 16
            if is_all_zeros:
                # All zeros KID means use the provided key_id (first key in map)
                if len(self.key_map) == 1:
                    return next(iter(self.key_map.values()))
            else:
                # Use the extracted KID to look up the key
                key = self.key_map.get(extracted_kid)
                if key:
                    return key
                # If KID doesn't match, try fallback
                # Note: This is expected when KID in file doesn't match provided key_id
                # The provided key_id should still work if it's the correct decryption key

        # Fallback: if only one key provided, use it (backward compatibility)
        if len(self.key_map) == 1:
            return next(iter(self.key_map.values()))

        # Try using track_id as KID (for multi-key scenarios)
        track_id_bytes = track_id.to_bytes(4, "big")
        key = self.key_map.get(track_id_bytes)
        if not key:
            raise ValueError(f"No key found for track ID {track_id}")
        return key

    @staticmethod
    def _process_sample(
        sample: memoryview, sample_info: CENCSampleAuxiliaryDataFormat, key: bytes
    ) -> Union[memoryview, bytearray, bytes]:
        """
        Processes and decrypts a sample using the provided sample information and decryption key.
        This includes handling sub-sample encryption if present.

        Args:
            sample (memoryview): The sample data.
            sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format with encryption information.
            key (bytes): The decryption key.

        Returns:
            Union[memoryview, bytearray, bytes]: The decrypted sample.
        """
        if not sample_info.is_encrypted:
            return sample

        # pad IV to 16 bytes
        iv = sample_info.iv + b"\x00" * (16 - len(sample_info.iv))
        cipher = AES.new(key, AES.MODE_CTR, initial_value=iv, nonce=b"")

        if not sample_info.sub_samples:
            # If there are no sub_samples, decrypt the entire sample
            return cipher.decrypt(sample)

        result = bytearray()
        offset = 0
        for clear_bytes, encrypted_bytes in sample_info.sub_samples:
            result.extend(sample[offset : offset + clear_bytes])
            offset += clear_bytes
            result.extend(cipher.decrypt(sample[offset : offset + encrypted_bytes]))
            offset += encrypted_bytes

        # If there's any remaining data, treat it as encrypted
        if offset < len(sample):
            result.extend(cipher.decrypt(sample[offset:]))

        return result

    def _process_sample_cbcs(
        self, sample: memoryview, sample_info: CENCSampleAuxiliaryDataFormat, key: bytes
    ) -> Union[memoryview, bytearray, bytes]:
        """
        Processes and decrypts a sample using CBCS (AES-CBC with pattern encryption).

        CBCS uses AES-CBC mode with a constant IV (no counter increment between blocks).
        Pattern encryption encrypts 'crypt_byte_block' 16-byte blocks, then leaves
        'skip_byte_block' 16-byte blocks in the clear, repeating this pattern.

        Args:
            sample (memoryview): The sample data.
            sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format with encryption information.
            key (bytes): The decryption key.

        Returns:
            Union[memoryview, bytearray, bytes]: The decrypted sample.
        """
        if not sample_info.is_encrypted:
            return sample

        # CBCS uses constant IV - pad to 16 bytes
        iv = sample_info.iv + b"\x00" * (16 - len(sample_info.iv))

        if not sample_info.sub_samples:
            # Full sample encryption with pattern
            return self._decrypt_cbcs_pattern(bytes(sample), key, iv)

        # Subsample encryption
        result = bytearray()
        offset = 0
        for clear_bytes, encrypted_bytes in sample_info.sub_samples:
            # Copy clear bytes as-is
            result.extend(sample[offset : offset + clear_bytes])
            offset += clear_bytes

            # Decrypt encrypted portion using pattern encryption
            encrypted_part = bytes(sample[offset : offset + encrypted_bytes])
            decrypted = self._decrypt_cbcs_pattern(encrypted_part, key, iv)
            result.extend(decrypted)
            offset += encrypted_bytes

        # If there's any remaining data after subsamples, copy as-is (shouldn't happen)
        if offset < len(sample):
            result.extend(sample[offset:])

        return result

    def _decrypt_cbcs_pattern(self, data: bytes, key: bytes, iv: bytes) -> bytes:
        """
        Decrypts data using CBCS pattern encryption (AES-CBC with crypt/skip pattern).

        Pattern encryption decrypts 'crypt_byte_block' 16-byte blocks, then skips
        'skip_byte_block' 16-byte blocks (leaving them in clear), repeating.

        Important: In CBCS, the CBC cipher state (previous ciphertext) carries over
        between encrypted blocks, even though clear blocks are skipped. This means
        we need to collect all encrypted blocks, decrypt them as a continuous CBC
        stream, then interleave the results with the clear blocks.

        Args:
            data (bytes): The encrypted data.
            key (bytes): The decryption key.
            iv (bytes): The initialization vector.

        Returns:
            bytes: The decrypted data.
        """
        if not data:
            return data

        block_size = 16
        crypt_blocks = self.crypt_byte_block
        skip_blocks = self.skip_byte_block

        # If no pattern (crypt=0), no encryption
        if crypt_blocks == 0:
            return data

        # If skip=0, it's full encryption (all blocks encrypted)
        if skip_blocks == 0:
            # Decrypt complete blocks only
            complete_blocks_size = (len(data) // block_size) * block_size
            if complete_blocks_size > 0:
                cipher = AES.new(key, AES.MODE_CBC, iv)
                decrypted = cipher.decrypt(data[:complete_blocks_size])
                if complete_blocks_size < len(data):
                    return decrypted + data[complete_blocks_size:]
                return decrypted
            return data

        crypt_bytes = crypt_blocks * block_size
        skip_bytes = skip_blocks * block_size

        # Step 1: Collect all encrypted blocks
        encrypted_blocks = bytearray()
        block_positions = []  # Track where each encrypted block came from
        pos = 0

        while pos < len(data):
            # Encrypted portion
            if pos + crypt_bytes <= len(data):
                encrypted_blocks.extend(data[pos : pos + crypt_bytes])
                block_positions.append((pos, crypt_bytes))
                pos += crypt_bytes
            else:
                # Remaining data - encrypt complete blocks only
                remaining = len(data) - pos
                complete = (remaining // block_size) * block_size
                if complete > 0:
                    encrypted_blocks.extend(data[pos : pos + complete])
                    block_positions.append((pos, complete))
                    pos += complete
                break

            # Skip clear portion
            if pos + skip_bytes <= len(data):
                pos += skip_bytes
            else:
                break

        # Step 2: Decrypt all encrypted blocks as a continuous CBC stream
        if encrypted_blocks:
            cipher = AES.new(key, AES.MODE_CBC, iv)
            decrypted_blocks = cipher.decrypt(bytes(encrypted_blocks))
        else:
            decrypted_blocks = b""

        # Step 3: Reconstruct the output with decrypted blocks and clear blocks
        result = bytearray(data)  # Start with original data
        decrypted_pos = 0

        for orig_pos, length in block_positions:
            result[orig_pos : orig_pos + length] = decrypted_blocks[decrypted_pos : decrypted_pos + length]
            decrypted_pos += length

        return bytes(result)

    def _process_sample_cbc1(
        self, sample: memoryview, sample_info: CENCSampleAuxiliaryDataFormat, key: bytes
    ) -> Union[memoryview, bytearray, bytes]:
        """
        Processes and decrypts a sample using CBC1 (full sample AES-CBC encryption).

        Unlike CBCS, CBC1 encrypts the entire sample without pattern encryption.

        Args:
            sample (memoryview): The sample data.
            sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format with encryption information.
            key (bytes): The decryption key.

        Returns:
            Union[memoryview, bytearray, bytes]: The decrypted sample.
        """
        if not sample_info.is_encrypted:
            return sample

        # Pad IV to 16 bytes
        iv = sample_info.iv + b"\x00" * (16 - len(sample_info.iv))
        cipher = AES.new(key, AES.MODE_CBC, iv)

        if not sample_info.sub_samples:
            # Full sample encryption - decrypt complete blocks only
            block_size = 16
            complete_blocks_size = (len(sample) // block_size) * block_size
            if complete_blocks_size > 0:
                decrypted = cipher.decrypt(bytes(sample[:complete_blocks_size]))
                if complete_blocks_size < len(sample):
                    # Append remaining partial block as-is
                    return decrypted + bytes(sample[complete_blocks_size:])
                return decrypted
            return sample

        # Subsample encryption
        result = bytearray()
        offset = 0
        for clear_bytes, encrypted_bytes in sample_info.sub_samples:
            result.extend(sample[offset : offset + clear_bytes])
            offset += clear_bytes

            encrypted_part = bytes(sample[offset : offset + encrypted_bytes])
            # Only decrypt complete blocks
            block_size = 16
            complete_blocks_size = (len(encrypted_part) // block_size) * block_size
            if complete_blocks_size > 0:
                decrypted = cipher.decrypt(encrypted_part[:complete_blocks_size])
                result.extend(decrypted)
                if complete_blocks_size < len(encrypted_part):
                    result.extend(encrypted_part[complete_blocks_size:])
            else:
                result.extend(encrypted_part)
            offset += encrypted_bytes

        if offset < len(sample):
            result.extend(sample[offset:])

        return result

    def _decrypt_sample(
        self, sample: memoryview, sample_info: CENCSampleAuxiliaryDataFormat, key: bytes
    ) -> Union[memoryview, bytearray, bytes]:
        """
        Decrypts a sample using the appropriate scheme based on encryption_scheme attribute.

        Args:
            sample (memoryview): The sample data.
            sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format.
            key (bytes): The decryption key.

        Returns:
            Union[memoryview, bytearray, bytes]: The decrypted sample.
        """
        if self.encryption_scheme == b"cbcs":
            return self._process_sample_cbcs(sample, sample_info, key)
        elif self.encryption_scheme == b"cbc1":
            return self._process_sample_cbc1(sample, sample_info, key)
        else:
            # cenc and cens use AES-CTR
            return self._process_sample(sample, sample_info, key)

    def _decrypt_sample_with_track_settings(
        self,
        sample: memoryview,
        sample_info: CENCSampleAuxiliaryDataFormat,
        key: bytes,
        crypt_byte_block: int,
        skip_byte_block: int,
        constant_iv: Optional[bytes],
    ) -> Union[memoryview, bytearray, bytes]:
        """
        Decrypts a sample using per-track encryption settings.

        Args:
            sample (memoryview): The sample data.
            sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format.
            key (bytes): The decryption key.
            crypt_byte_block (int): Number of encrypted blocks in pattern.
            skip_byte_block (int): Number of clear blocks in pattern.
            constant_iv (Optional[bytes]): Constant IV for CBCS, or None.

        Returns:
            Union[memoryview, bytearray, bytes]: The decrypted sample.
        """
        if self.encryption_scheme == b"cbcs":
            return self._process_sample_cbcs_with_settings(
                sample, sample_info, key, crypt_byte_block, skip_byte_block, constant_iv
            )
        elif self.encryption_scheme == b"cbc1":
            return self._process_sample_cbc1(sample, sample_info, key)
        else:
            # cenc and cens use AES-CTR
            return self._process_sample(sample, sample_info, key)

    def _process_sample_cbcs_with_settings(
        self,
        sample: memoryview,
        sample_info: CENCSampleAuxiliaryDataFormat,
        key: bytes,
        crypt_byte_block: int,
        skip_byte_block: int,
        constant_iv: Optional[bytes],
    ) -> Union[memoryview, bytearray, bytes]:
        """
        Processes and decrypts a sample using CBCS with per-track settings.

        Args:
            sample (memoryview): The sample data.
            sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format.
            key (bytes): The decryption key.
            crypt_byte_block (int): Number of encrypted blocks in pattern.
            skip_byte_block (int): Number of clear blocks in pattern.
            constant_iv (Optional[bytes]): Constant IV for CBCS, or None.

        Returns:
            Union[memoryview, bytearray, bytes]: The decrypted sample.
        """
        if not sample_info.is_encrypted:
            return sample

        # Use constant IV if provided, otherwise use the IV from sample_info
        if constant_iv:
            iv = constant_iv + b"\x00" * (16 - len(constant_iv))
        else:
            iv = sample_info.iv + b"\x00" * (16 - len(sample_info.iv))

        if not sample_info.sub_samples:
            # Full sample encryption with pattern
            return self._decrypt_cbcs_pattern_with_settings(bytes(sample), key, iv, crypt_byte_block, skip_byte_block)

        # Subsample encryption
        result = bytearray()
        offset = 0
        for clear_bytes, encrypted_bytes in sample_info.sub_samples:
            # Copy clear bytes as-is
            result.extend(sample[offset : offset + clear_bytes])
            offset += clear_bytes

            # Decrypt encrypted portion using pattern encryption
            encrypted_part = bytes(sample[offset : offset + encrypted_bytes])
            decrypted = self._decrypt_cbcs_pattern_with_settings(
                encrypted_part, key, iv, crypt_byte_block, skip_byte_block
            )
            result.extend(decrypted)
            offset += encrypted_bytes

        # If there's any remaining data after subsamples, copy as-is
        if offset < len(sample):
            result.extend(sample[offset:])

        return result

    def _decrypt_cbcs_pattern_with_settings(
        self, data: bytes, key: bytes, iv: bytes, crypt_blocks: int, skip_blocks: int
    ) -> bytes:
        """
        Decrypts data using CBCS pattern encryption with explicit pattern settings.

        Args:
            data (bytes): The encrypted data.
            key (bytes): The decryption key.
            iv (bytes): The initialization vector.
            crypt_blocks (int): Number of encrypted blocks in pattern.
            skip_blocks (int): Number of clear blocks in pattern.

        Returns:
            bytes: The decrypted data.
        """
        if not data:
            return data

        block_size = 16

        # If both crypt=0 and skip=0, it means full sample CBC encryption (no pattern)
        # This is common for audio tracks in CBCS
        if crypt_blocks == 0 and skip_blocks == 0:
            # Decrypt complete blocks only
            complete_blocks_size = (len(data) // block_size) * block_size
            if complete_blocks_size > 0:
                cipher = AES.new(key, AES.MODE_CBC, iv)
                decrypted = cipher.decrypt(data[:complete_blocks_size])
                if complete_blocks_size < len(data):
                    return decrypted + data[complete_blocks_size:]
                return decrypted
            return data

        crypt_bytes = crypt_blocks * block_size
        skip_bytes = skip_blocks * block_size

        # Step 1: Collect all encrypted blocks
        encrypted_blocks = bytearray()
        block_positions = []  # Track where each encrypted block came from
        pos = 0

        while pos < len(data):
            # Encrypted portion
            if pos + crypt_bytes <= len(data):
                encrypted_blocks.extend(data[pos : pos + crypt_bytes])
                block_positions.append((pos, crypt_bytes))
                pos += crypt_bytes
            else:
                # Remaining data - encrypt complete blocks only
                remaining = len(data) - pos
                complete = (remaining // block_size) * block_size
                if complete > 0:
                    encrypted_blocks.extend(data[pos : pos + complete])
                    block_positions.append((pos, complete))
                    pos += complete
                break

            # Skip clear portion
            if pos + skip_bytes <= len(data):
                pos += skip_bytes
            else:
                break

        # Step 2: Decrypt all encrypted blocks as a continuous CBC stream
        if encrypted_blocks:
            cipher = AES.new(key, AES.MODE_CBC, iv)
            decrypted_blocks = cipher.decrypt(bytes(encrypted_blocks))
        else:
            decrypted_blocks = b""

        # Step 3: Reconstruct the output with decrypted blocks and clear blocks
        result = bytearray(data)  # Start with original data
        decrypted_pos = 0

        for orig_pos, length in block_positions:
            result[orig_pos : orig_pos + length] = decrypted_blocks[decrypted_pos : decrypted_pos + length]
            decrypted_pos += length

        return bytes(result)

    def _process_trun(self, trun: MP4Atom) -> tuple[int, int]:
        """
        Processes the 'trun' (Track Fragment Run) atom, which contains information about the samples in a track fragment.
        This includes sample sizes, durations, flags, and composition time offsets.

        Args:
            trun (MP4Atom): The 'trun' atom to process.

        Returns:
            tuple[int, int]: (sample_count, data_offset_value) where data_offset_value is the offset
                             into mdat where this track's samples start (0 if not present in trun).
        """
        trun_flags, sample_count = struct.unpack_from(">II", trun.data, 0)
        parse_offset = 8

        # Extract data_offset if present (flag 0x000001)
        trun_data_offset = 0
        if trun_flags & 0x000001:
            trun_data_offset = struct.unpack_from(">i", trun.data, parse_offset)[0]  # signed int
            parse_offset += 4
        if trun_flags & 0x000004:  # first-sample-flags-present
            parse_offset += 4

        self.trun_sample_sizes = array.array("I")

        for _ in range(sample_count):
            if trun_flags & 0x000100:  # sample-duration-present flag
                parse_offset += 4
            if trun_flags & 0x000200:  # sample-size-present flag
                sample_size = struct.unpack_from(">I", trun.data, parse_offset)[0]
                self.trun_sample_sizes.append(sample_size)
                parse_offset += 4
            else:
                self.trun_sample_sizes.append(0)  # Using 0 instead of None for uniformity in the array
            if trun_flags & 0x000400:  # sample-flags-present flag
                parse_offset += 4
            if trun_flags & 0x000800:  # sample-composition-time-offsets-present flag
                parse_offset += 4

        return sample_count, trun_data_offset

    def _modify_trun(self, trun: MP4Atom) -> MP4Atom:
        """
        Modifies the 'trun' (Track Fragment Run) atom to update the data offset.
        This is necessary to account for the total encryption overhead from all trafs,
        since mdat comes after all trafs in the moof.

        Args:
            trun (MP4Atom): The 'trun' atom to modify.

        Returns:
            MP4Atom: Modified 'trun' atom with updated data offset.
        """
        trun_data = bytearray(trun.data)
        current_flags = struct.unpack_from(">I", trun_data, 0)[0] & 0xFFFFFF

        # If the data-offset-present flag is set, update the data offset to account for encryption overhead
        # All trun data_offsets need to be reduced by the total encryption overhead from all trafs
        if current_flags & 0x000001:
            current_data_offset = struct.unpack_from(">i", trun_data, 8)[0]
            struct.pack_into(">i", trun_data, 8, current_data_offset - self.total_encryption_overhead)

        return MP4Atom(b"trun", len(trun_data) + 8, trun_data)

    def _process_sidx(self, sidx: MP4Atom) -> MP4Atom:
        """
        Processes the 'sidx' (Segment Index) atom, which contains indexing information for media segments.
        This includes references to media segments and their durations.

        Args:
            sidx (MP4Atom): The 'sidx' atom to process.

        Returns:
            MP4Atom: Processed 'sidx' atom with updated segment references.
        """
        sidx_data = bytearray(sidx.data)

        current_size = struct.unpack_from(">I", sidx_data, 32)[0]
        reference_type = current_size >> 31
        current_referenced_size = current_size & 0x7FFFFFFF

        # Remove total encryption overhead from referenced size
        new_referenced_size = current_referenced_size - self.total_encryption_overhead
        new_size = (reference_type << 31) | new_referenced_size
        struct.pack_into(">I", sidx_data, 32, new_size)

        return MP4Atom(b"sidx", len(sidx_data) + 8, sidx_data)

    def _process_trak(self, trak: MP4Atom) -> MP4Atom:
        """
        Processes the 'trak' (Track) atom, which contains information about a single track in the movie.
        This includes track header, media information, and other track-level metadata.

        Args:
            trak (MP4Atom): The 'trak' atom to process.

        Returns:
            MP4Atom: Processed 'trak' atom with updated track information.
        """
        parser = MP4Parser(trak.data)
        new_trak_data = bytearray()

        # First pass: find track ID from tkhd
        for atom in parser.list_atoms():
            if atom.atom_type == b"tkhd":
                # tkhd: version(1) + flags(3) + ... + track_id at offset 12 (v0) or 20 (v1)
                version = atom.data[0]
                if version == 0:
                    self.current_track_id = struct.unpack_from(">I", atom.data, 12)[0]
                else:
                    self.current_track_id = struct.unpack_from(">I", atom.data, 20)[0]
                break

        # Second pass: process atoms
        parser.position = 0
        for atom in iter(parser.read_atom, None):
            if atom.atom_type == b"mdia":
                new_mdia = self._process_mdia(atom)
                new_trak_data.extend(new_mdia.pack())
            else:
                new_trak_data.extend(atom.pack())

        return MP4Atom(b"trak", len(new_trak_data) + 8, new_trak_data)

    def _process_mdia(self, mdia: MP4Atom) -> MP4Atom:
        """
        Processes the 'mdia' (Media) atom, which contains media information for a track.
        This includes media header, handler reference, and media information container.

        Args:
            mdia (MP4Atom): The 'mdia' atom to process.

        Returns:
            MP4Atom: Processed 'mdia' atom with updated media information.
        """
        parser = MP4Parser(mdia.data)
        new_mdia_data = bytearray()

        for atom in iter(parser.read_atom, None):
            if atom.atom_type == b"minf":
                new_minf = self._process_minf(atom)
                new_mdia_data.extend(new_minf.pack())
            else:
                new_mdia_data.extend(atom.pack())

        return MP4Atom(b"mdia", len(new_mdia_data) + 8, new_mdia_data)

    def _process_minf(self, minf: MP4Atom) -> MP4Atom:
        """
        Processes the 'minf' (Media Information) atom, which contains information about the media data in a track.
        This includes data information, sample table, and other media-level metadata.

        Args:
            minf (MP4Atom): The 'minf' atom to process.

        Returns:
            MP4Atom: Processed 'minf' atom with updated media information.
        """
        parser = MP4Parser(minf.data)
        new_minf_data = bytearray()

        for atom in iter(parser.read_atom, None):
            if atom.atom_type == b"stbl":
                new_stbl = self._process_stbl(atom)
                new_minf_data.extend(new_stbl.pack())
            else:
                new_minf_data.extend(atom.pack())

        return MP4Atom(b"minf", len(new_minf_data) + 8, new_minf_data)

    def _process_stbl(self, stbl: MP4Atom) -> MP4Atom:
        """
        Processes the 'stbl' (Sample Table) atom, which contains information about the samples in a track.
        This includes sample descriptions, sample sizes, sample times, and other sample-level metadata.

        Args:
            stbl (MP4Atom): The 'stbl' atom to process.

        Returns:
            MP4Atom: Processed 'stbl' atom with updated sample information.
        """
        parser = MP4Parser(stbl.data)
        new_stbl_data = bytearray()

        for atom in iter(parser.read_atom, None):
            if atom.atom_type == b"stsd":
                new_stsd = self._process_stsd(atom)
                new_stbl_data.extend(new_stsd.pack())
            else:
                new_stbl_data.extend(atom.pack())

        return MP4Atom(b"stbl", len(new_stbl_data) + 8, new_stbl_data)

    def _process_stsd(self, stsd: MP4Atom) -> MP4Atom:
        """
        Processes the 'stsd' (Sample Description) atom, which contains descriptions of the sample entries in a track.
        This includes codec information, sample entry details, and other sample description metadata.

        Args:
            stsd (MP4Atom): The 'stsd' atom to process.

        Returns:
            MP4Atom: Processed 'stsd' atom with updated sample descriptions.
        """
        parser = MP4Parser(stsd.data)
        entry_count = struct.unpack_from(">I", parser.data, 4)[0]
        new_stsd_data = bytearray(stsd.data[:8])

        parser.position = 8  # Move past version_flags and entry_count

        for _ in range(entry_count):
            sample_entry = parser.read_atom()
            if not sample_entry:
                break

            processed_entry = self._process_sample_entry(sample_entry)
            new_stsd_data.extend(processed_entry.pack())

        return MP4Atom(b"stsd", len(new_stsd_data) + 8, new_stsd_data)

    def _process_sample_entry(self, entry: MP4Atom) -> MP4Atom:
        """
        Processes a sample entry atom, which contains information about a specific type of sample.
        This includes codec-specific information and other sample entry details.

        Args:
            entry (MP4Atom): The sample entry atom to process.

        Returns:
            MP4Atom: Processed sample entry atom with updated information.
        """
        # Determine the size of fixed fields based on sample entry type
        if entry.atom_type in {b"mp4a", b"enca"}:
            fixed_size = 28  # 8 bytes for size, type and reserved, 20 bytes for fixed fields in Audio Sample Entry.
        elif entry.atom_type in {b"mp4v", b"encv", b"avc1", b"hev1", b"hvc1"}:
            fixed_size = 78  # 8 bytes for size, type and reserved, 70 bytes for fixed fields in Video Sample Entry.
        else:
            fixed_size = 16  # 8 bytes for size, type and reserved, 8 bytes for fixed fields in other Sample Entries.

        new_entry_data = bytearray(entry.data[:fixed_size])
        parser = MP4Parser(entry.data[fixed_size:])
        codec_format = None

        for atom in iter(parser.read_atom, None):
            if atom.atom_type in {b"sinf", b"schi", b"tenc", b"schm"}:
                if atom.atom_type == b"sinf":
                    codec_format = self._extract_codec_format(atom)
                continue  # Skip encryption-related atoms
            new_entry_data.extend(atom.pack())

        # Replace the atom type with the extracted codec format
        new_type = codec_format if codec_format else entry.atom_type
        return MP4Atom(new_type, len(new_entry_data) + 8, new_entry_data)

    def _extract_codec_format(self, sinf: MP4Atom) -> Optional[bytes]:
        """
        Extracts the codec format from the 'sinf' (Protection Scheme Information) atom.
        This includes information about the original format of the protected content.
        Also extracts IV size from the 'tenc' box and encryption scheme from 'schm' box.

        Args:
            sinf (MP4Atom): The 'sinf' atom to extract from.

        Returns:
            Optional[bytes]: The codec format or None if not found.
        """
        parser = MP4Parser(sinf.data)
        codec_format = None
        for atom in iter(parser.read_atom, None):
            if atom.atom_type == b"frma":
                codec_format = atom.data
            elif atom.atom_type == b"schm":
                self._parse_schm(atom)
            elif atom.atom_type == b"schi":
                # Parse schi to find tenc
                schi_parser = MP4Parser(atom.data)
                for schi_atom in iter(schi_parser.read_atom, None):
                    if schi_atom.atom_type == b"tenc":
                        self._parse_tenc(schi_atom)
        return codec_format

    def _parse_schm(self, schm: MP4Atom) -> None:
        """
        Parses the 'schm' (Scheme Type) atom to detect the encryption scheme.

        Args:
            schm (MP4Atom): The 'schm' atom to parse.
        """
        # schm structure:
        # - version (1 byte) + flags (3 bytes) = 4 bytes
        # - scheme_type (4 bytes): "cenc", "cens", "cbc1", or "cbcs"
        # - scheme_version (4 bytes)
        data = schm.data
        if len(data) >= 8:
            scheme_type = bytes(data[4:8])
            if scheme_type in (b"cenc", b"cens", b"cbc1", b"cbcs"):
                self.encryption_scheme = scheme_type

    def _parse_tenc(self, tenc: MP4Atom) -> None:
        """
        Parses the 'tenc' (Track Encryption) atom to extract encryption parameters.
        Stores per-track encryption settings for multi-track support.

        Args:
            tenc (MP4Atom): The 'tenc' atom to parse.
        """
        # tenc structure:
        # - version (1 byte) + flags (3 bytes) = 4 bytes
        # - reserved (1 byte) + reserved (1 byte) if version == 0, or reserved (1 byte) + default_crypt_byte_block (4 bits) + default_skip_byte_block (4 bits) if version > 0
        # - default_isProtected (1 byte)
        # - default_Per_Sample_IV_Size (1 byte)
        # - default_KID (16 bytes)
        # For version 1 with IV size 0:
        # - default_constant_IV_size (1 byte)
        # - default_constant_IV (default_constant_IV_size bytes)
        data = tenc.data
        if len(data) >= 8:
            version = data[0]

            # Initialize per-track settings
            track_settings = {
                "crypt_byte_block": 1,  # Default
                "skip_byte_block": 9,  # Default
                "constant_iv": None,
                "iv_size": 8,
                "kid": None,  # KID from tenc box
            }

            # Extract pattern encryption parameters for version > 0 (used in cbcs)
            if version > 0 and len(data) >= 6:
                # Byte 5 contains crypt_byte_block (upper 4 bits) and skip_byte_block (lower 4 bits)
                pattern_byte = data[5]
                track_settings["crypt_byte_block"] = (pattern_byte >> 4) & 0x0F
                track_settings["skip_byte_block"] = pattern_byte & 0x0F
                # Also update global defaults (for backward compatibility)
                self.crypt_byte_block = track_settings["crypt_byte_block"]
                self.skip_byte_block = track_settings["skip_byte_block"]

            # Extract KID (default_KID is at offset 8, 16 bytes)
            kid_offset = 8
            if len(data) >= kid_offset + 16:
                kid = bytes(data[kid_offset : kid_offset + 16])
                track_settings["kid"] = kid
                # Also store globally for backward compatibility
                if not hasattr(self, "extracted_kids"):
                    self.extracted_kids = {}
                if self.current_track_id > 0:
                    self.extracted_kids[self.current_track_id] = kid

            # IV size is at offset 7 for both versions
            iv_size_offset = 7
            if len(data) > iv_size_offset:
                iv_size = data[iv_size_offset]
                if iv_size in (0, 8, 16):
                    # IV size of 0 means constant IV (used in cbcs)
                    track_settings["iv_size"] = iv_size if iv_size > 0 else 16
                    self.default_iv_size = track_settings["iv_size"]

                    # If IV size is 0, extract constant IV from tenc (for CBCS)
                    if iv_size == 0:
                        # After KID (16 bytes at offset 8), there's constant_IV_size (1 byte) and constant_IV
                        constant_iv_size_offset = 8 + 16  # offset 24
                        if len(data) > constant_iv_size_offset:
                            constant_iv_size = data[constant_iv_size_offset]
                            constant_iv_offset = constant_iv_size_offset + 1
                            if constant_iv_size > 0 and len(data) >= constant_iv_offset + constant_iv_size:
                                track_settings["constant_iv"] = bytes(
                                    data[constant_iv_offset : constant_iv_offset + constant_iv_size]
                                )
                                self.constant_iv = track_settings["constant_iv"]

            # Store per-track settings
            if self.current_track_id > 0:
                self.track_encryption_settings[self.current_track_id] = track_settings


def _build_key_map(key_id: str, key: str) -> dict:
    """
    Build a key_map dict from (possibly comma-separated) key_id and key strings.

    Both arguments may be comma-separated lists of equal length to support
    multi-key DRM streams where different tracks use different keys.

    Args:
        key_id: Hex key ID(s), comma-separated for multi-key.
        key: Hex key(s), comma-separated for multi-key.

    Returns:
        dict mapping key-ID bytes to key bytes.
    """
    key_ids = [k.strip() for k in key_id.split(",") if k.strip()]
    keys = [k.strip() for k in key.split(",") if k.strip()]
    return {bytes.fromhex(kid): bytes.fromhex(k) for kid, k in zip(key_ids, keys)}


def decrypt_segment(
    init_segment: bytes, segment_content: bytes, key_id: str, key: str, include_init: bool = True
) -> bytes:
    """
    Decrypts a CENC encrypted MP4 segment.

    Args:
        init_segment (bytes): Initialization segment data.
        segment_content (bytes): Encrypted segment content.
        key_id (str): Key ID(s) in hexadecimal format, comma-separated for multi-key DRM.
        key (str): Key(s) in hexadecimal format, comma-separated for multi-key DRM.
        include_init (bool): If True, include processed init segment in output.
            If False, only return decrypted media segment (for use with EXT-X-MAP).

    Returns:
        bytes: Decrypted segment with processed init (moov/ftyp) + decrypted media (moof/mdat),
            or just decrypted media if include_init is False.
    """
    key_map = _build_key_map(key_id, key)
    decrypter = MP4Decrypter(key_map)
    decrypted_content = decrypter.decrypt_segment(init_segment + segment_content, include_init=include_init)
    return decrypted_content


def process_drm_init_segment(init_segment: bytes, key_id: str, key: str) -> bytes:
    """
    Processes a DRM-protected init segment for use with EXT-X-MAP.
    Removes encryption-related boxes but keeps the moov structure.

    Args:
        init_segment (bytes): Initialization segment data.
        key_id (str): Key ID(s) in hexadecimal format, comma-separated for multi-key DRM.
        key (str): Key(s) in hexadecimal format, comma-separated for multi-key DRM.

    Returns:
        bytes: Processed init segment with encryption boxes removed.
    """
    key_map = _build_key_map(key_id, key)
    decrypter = MP4Decrypter(key_map)
    processed_init = decrypter.process_init_only(init_segment)
    return processed_init


def cli():
    """
    Command line interface for decrypting a CENC encrypted MP4 segment.
    """
    init_segment = b""

    if args.init and args.segment:
        with open(args.init, "rb") as f:
            init_segment = f.read()
        with open(args.segment, "rb") as f:
            segment_content = f.read()
    elif args.combined_segment:
        with open(args.combined_segment, "rb") as f:
            segment_content = f.read()
    else:
        print("Usage: python mp4decrypt.py --help")
        sys.exit(1)

    try:
        decrypted_segment = decrypt_segment(init_segment, segment_content, args.key_id, args.key)
        print(f"Decrypted content size is {len(decrypted_segment)} bytes")
        with open(args.output, "wb") as f:
            f.write(decrypted_segment)
        print(f"Decrypted segment written to {args.output}")
    except Exception as e:
        print(f"Error: {e}")
        sys.exit(1)


if __name__ == "__main__":
    arg_parser = argparse.ArgumentParser(description="Decrypts a MP4 init and media segment using CENC encryption.")
    arg_parser.add_argument("--init", help="Path to the init segment file", required=False)
    arg_parser.add_argument("--segment", help="Path to the media segment file", required=False)
    arg_parser.add_argument(
        "--combined_segment", help="Path to the combined init and media segment file", required=False
    )
    arg_parser.add_argument("--key_id", help="Key ID in hexadecimal format", required=True)
    arg_parser.add_argument("--key", help="Key in hexadecimal format", required=True)
    arg_parser.add_argument("--output", help="Path to the output file", required=True)
    args = arg_parser.parse_args()
    cli()
