import ctypes
import json
import os
import re
from ctypes import c_void_p, c_wchar_p
from decimal import Decimal
from logging import DEBUG, NullHandler, getLogger
from subprocess import CalledProcessError, check_output

from pymediainfo import MediaInfo
from pymediainfo import __version__ as pymediainfo_version

from knowit import VIDEO_EXTENSIONS
from knowit.core import MultiValue, Property
from knowit.properties import (
    AudioChannels,
    AudioCodec,
    AudioCompression,
    AudioProfile,
    Basic,
    BitRateMode,
    Duration,
    Language,
    Quantity,
    ScanType,
    SubtitleFormat,
    VideoCodec,
    VideoEncoder,
    VideoHdrFormat,
    VideoProfile,
    VideoProfileTier,
    YesNo,
)
from knowit.provider import (
    MalformedFileError,
    Provider,
)
from knowit.rules import (
    AtmosRule,
    AudioChannelsRule,
    ClosedCaptionRule,
    DtsHdRule,
    HearingImpairedRule,
    LanguageRule,
    ResolutionRule,
)
from knowit.rules.general import GuessTitleRule
from knowit.units import units
from knowit.utils import (
    define_candidate,
    detect_os, round_decimal,
)

logger = getLogger(__name__)
logger.addHandler(NullHandler())


WARN_MSG = r'''
=========================================================================================
MediaInfo not found on your system or could not be loaded.
Visit https://mediaarea.net/ to download it.
If you still have problems, please check if the downloaded version matches your system.
To load MediaInfo from a specific location, please define the location as follow:
  knowit --mediainfo /usr/local/mediainfo/lib <video_path>
  knowit --mediainfo /usr/local/mediainfo/bin <video_path>
  knowit --mediainfo "C:\Program Files\MediaInfo" <video_path>
  knowit --mediainfo C:\Software\MediaInfo.dll <video_path>
  knowit --mediainfo C:\Software\MediaInfo.exe <video_path>
  knowit --mediainfo /opt/mediainfo/libmediainfo.so <video_path>
  knowit --mediainfo /opt/mediainfo/libmediainfo.dylib <video_path>
=========================================================================================
'''


class MediaInfoExecutor:
    """Media info executable knows how to execute media info: using ctypes or cli."""

    version_re = re.compile(r'\bv(?P<version>\d+(?:\.\d+)+)\b')

    locations = {
        'unix': ('/usr/local/mediainfo/lib', '/usr/local/mediainfo/bin', '__PATH__'),
        'windows': ('C:\\Program Files\\MediaInfo', 'C:\\Program Files (x86)\\MediaInfo', '__PATH__'),
        'macos': ('__PATH__', ),
    }

    def __init__(self, location, version):
        """Initialize the object."""
        self.location = location
        self.version = version

    def extract_info(self, filename):
        """Extract media info."""
        return self._execute(filename)

    def _execute(self, filename):
        raise NotImplementedError

    @classmethod
    def _get_version(cls, output):
        match = cls.version_re.search(output)
        if match:
            version = tuple([int(v) for v in match.groupdict()['version'].split('.')])
            return version

    @classmethod
    def get_executor_instance(cls, suggested_path=None):
        """Return the executor instance."""
        os_family = detect_os()
        logger.debug('Detected os: %s', os_family)
        for exec_cls in (MediaInfoCTypesExecutor, MediaInfoCliExecutor):
            executor = exec_cls.create(os_family, suggested_path)
            if executor:
                return executor


class MediaInfoCliExecutor(MediaInfoExecutor):
    """Media info using cli."""

    names = {
        'unix': ('mediainfo', ),
        'windows': ('MediaInfo.exe', ),
        'macos': ('mediainfo', ),
    }

    def _execute(self, filename):
        data = check_output([self.location, '--Output=JSON', '--Full', filename]).decode()

        return json.loads(data) if data else {}

    @classmethod
    def _is_gui_exe(cls, candidate: str):
        if not candidate.endswith('MediaInfo.exe') or not os.path.isfile(candidate):
            return False

        try:
            shell32 = ctypes.WinDLL('shell32', use_last_error=True)  # type: ignore
            return bool(shell32.ExtractIconExW(candidate, 0, None, None, 1))
        except Exception:
            return False

    @classmethod
    def create(cls, os_family=None, suggested_path=None):
        """Create the executor instance."""
        for candidate in define_candidate(cls.locations, cls.names, os_family, suggested_path):
            if cls._is_gui_exe(candidate):
                continue

            try:
                output = check_output([candidate, '--version']).decode()
                version = cls._get_version(output)
                if version:
                    logger.debug('MediaInfo cli detected: %s', candidate)
                    return MediaInfoCliExecutor(candidate, version)
            except CalledProcessError as e:
                # old mediainfo returns non-zero exit code for mediainfo --version
                version = cls._get_version(e.output.decode())
                if version:
                    logger.debug('MediaInfo cli detected: %s', candidate)
                    return MediaInfoCliExecutor(candidate, version)
            except OSError:
                pass


class MediaInfoCTypesExecutor(MediaInfoExecutor):
    """Media info ctypes."""

    names = {
        'unix': ('libmediainfo.so.0', ),
        'windows': ('MediaInfo.dll', ),
        'macos': ('libmediainfo.0.dylib', 'libmediainfo.dylib'),
    }

    def _execute(self, filename):
        # Create a MediaInfo handle
        data = MediaInfo.parse(filename, library_file=self.location, output='JSON')

        return json.loads(data) if data else {}

    @classmethod
    def create(cls, os_family=None, suggested_path=None):
        """Create the executor instance."""
        for candidate in define_candidate(cls.locations, cls.names, os_family, suggested_path):
            if MediaInfo.can_parse(candidate):
                lib, handle, lib_version_str, lib_version = MediaInfo._get_library(candidate)
                lib.MediaInfo_Option.argtypes = [c_void_p, c_wchar_p, c_wchar_p]
                lib.MediaInfo_Option.restype = c_wchar_p
                version = MediaInfoExecutor._get_version(lib.MediaInfo_Option(None, "Info_Version", ""))

                logger.debug('MediaInfo library detected: %s (v%s)', candidate, '.'.join(map(str, version)))
                return MediaInfoCTypesExecutor(candidate, version)


class MediaInfoProvider(Provider):
    """Media Info provider."""

    executor = None

    def __init__(self, config, suggested_path):
        """Init method."""
        super().__init__(config, {
            'general': {
                'title': Property('Title', description='media title'),
                'path': Property('CompleteName', description='media path'),
                'duration': Duration('Duration', resolution=1000, description='media duration'),
                'size': Quantity('FileSize', unit=units.byte, description='media size'),
                'bit_rate': Quantity('OverallBitRate', unit=units.bps, description='media bit rate'),
            },
            'video': {
                'id': Basic('ID', data_type=int, allow_fallback=True, description='video track number'),
                'name': Property('Title', description='video track name'),
                'language': Language('Language', description='video language'),
                'duration': Duration('Duration', resolution=1000, description='video duration'),
                'size': Quantity('StreamSize', unit=units.byte, description='video stream size'),
                'width': Quantity('Width', unit=units.pixel),
                'height': Quantity('Height', unit=units.pixel),
                'scan_type': ScanType(config, 'ScanType', default='Progressive', description='video scan type'),
                'aspect_ratio': Basic('DisplayAspectRatio', data_type=Decimal,
                                      processor=lambda x: round_decimal(x, min_digits=1, max_digits=3),
                                      description='display aspect ratio'),
                'pixel_aspect_ratio': Basic('PixelAspectRatio', data_type=Decimal,
                                            processor=lambda x: round_decimal(x, min_digits=1, max_digits=3),
                                            description='pixel aspect ratio'),
                'resolution': None,  # populated with ResolutionRule
                'frame_rate': Quantity('FrameRate', unit=units.FPS, data_type=Decimal, description='video frame rate'),
                # frame_rate_mode
                'bit_rate': Quantity('BitRate', unit=units.bps, description='video bit rate'),
                'bit_depth': Quantity('BitDepth', unit=units.bit, description='video bit depth'),
                'codec': VideoCodec(config, 'CodecID', description='video codec'),
                'profile': VideoProfile(config, 'Format_Profile', description='video codec profile'),
                'profile_level': Property('Format_Level', description='video codec profile level'),
                'profile_tier': VideoProfileTier(config, 'Format_Tier', description='video codec profile tier'),
                'encoder': VideoEncoder(config, 'Encoded_Library_Name', description='video encoder'),
                'hdr_format': MultiValue(VideoHdrFormat(config, 'HDR_Format', description='video hdr format'),
                                         delimiter=' / '),
                'media_type': Property('InternetMediaType', description='video media type'),
                'forced': YesNo('Forced', hide_value=False, description='video track forced'),
                'default': YesNo('Default', hide_value=False, description='video track default'),
            },
            'audio': {
                'id': Basic('ID', data_type=int, allow_fallback=True, description='audio track number'),
                'name': Property('Title', description='audio track name'),
                'language': Language('Language', description='audio language'),
                'duration': Duration('Duration', resolution=1000, description='audio duration'),
                'size': Quantity('StreamSize', unit=units.byte, description='audio stream size'),
                'codec': MultiValue(AudioCodec(config, 'CodecID', description='audio codec')),
                'format_commercial': Property('Format_Commercial', private=True),
                'profile': MultiValue(AudioProfile(config, 'Format_Profile', 'Format_AdditionalFeatures',
                                                   description='audio codec profile'),
                                      delimiter=' / '),
                'channels_count': MultiValue(AudioChannels('Channels_Original', 'Channels',
                                                           description='audio channels count')),
                'channel_positions': MultiValue(name='ChannelPositions_String2', handler=(lambda x, *args: x),
                                                delimiter=' / ', private=True, description='audio channels position'),
                'channels': None,  # populated with AudioChannelsRule
                'bit_depth': Quantity('BitDepth', unit=units.bit, description='audio bit depth'),
                'bit_rate': MultiValue(Quantity('BitRate', unit=units.bps, description='audio bit rate')),
                'bit_rate_mode': MultiValue(BitRateMode(config, 'BitRate_Mode', description='audio bit rate mode')),
                'sampling_rate': MultiValue(Quantity('SamplingRate', unit=units.Hz, description='audio sampling rate')),
                'compression': MultiValue(AudioCompression(config, 'Compression_Mode',
                                                           description='audio compression')),
                'forced': YesNo('Forced', hide_value=False, description='audio track forced'),
                'default': YesNo('Default', hide_value=False, description='audio track default'),
            },
            'subtitle': {
                'id': Basic('ID', data_type=int, allow_fallback=True, description='subtitle track number'),
                'name': Property('Title', description='subtitle track name'),
                'language': Language('Language', description='subtitle language'),
                'hearing_impaired': None,  # populated with HearingImpairedRule
                '_closed_caption': Property('ClosedCaptionsPresent', private=True),
                'closed_caption': None,  # populated with ClosedCaptionRule
                'format': SubtitleFormat(config, 'CodecID', description='subtitle format'),
                'forced': YesNo('Forced', hide_value=False, description='subtitle track forced'),
                'default': YesNo('Default', hide_value=False, description='subtitle track default'),
            },
        }, {
            'video': {
                'guessed': GuessTitleRule('guessed properties', private=True),
                'language': LanguageRule('video language', override=True),
                'resolution': ResolutionRule('video resolution'),
            },
            'audio': {
                'guessed': GuessTitleRule('guessed properties', private=True),
                'language': LanguageRule('audio language', override=True),
                'channels': AudioChannelsRule('audio channels'),
                'atmos': AtmosRule(config, 'atmos rule', private=True),
                'dtshd': DtsHdRule(config, 'dts-hd rule', private=True),
            },
            'subtitle': {
                'guessed': GuessTitleRule('guessed properties', private=True),
                'language': LanguageRule('subtitle language', override=True),
                'hearing_impaired': HearingImpairedRule('subtitle hearing impaired', override=True),
                'closed_caption': ClosedCaptionRule('closed caption', override=True),
            }
        })
        self.executor = MediaInfoExecutor.get_executor_instance(suggested_path)

    def accepts(self, video_path):
        """Accept any video when MediaInfo is available."""
        if self.executor is None:
            logger.warning(WARN_MSG)
            self.executor = False

        return self.executor and video_path.lower().endswith(VIDEO_EXTENSIONS)

    def describe(self, video_path, context):
        """Return video metadata."""
        data = self.executor.extract_info(video_path)

        def debug_data():
            """Debug data."""
            return json.dumps(data, indent=4)

        context['debug_data'] = debug_data

        if logger.isEnabledFor(DEBUG):
            logger.debug('Video %r scanned using mediainfo %r has raw data:\n%s',
                         video_path, self.executor.location, debug_data())

        result = {}
        tracks = data.get('media', {}).get('track', [])
        if tracks:
            general_tracks = []
            video_tracks = []
            audio_tracks = []
            subtitle_tracks = []
            for track in tracks:
                track_type = track.get('@type')
                if track_type == 'General':
                    general_tracks.append(track)
                elif track_type == 'Video':
                    video_tracks.append(track)
                elif track_type == 'Audio':
                    audio_tracks.append(track)
                elif track_type == 'Text':
                    subtitle_tracks.append(track)

            result = self._describe_tracks(video_path, general_tracks[0] if general_tracks else {},
                                           video_tracks, audio_tracks, subtitle_tracks, context)
        if not result:
            raise MalformedFileError

        result['provider'] = {
            'name': 'mediainfo',
            'version': self.version
        }

        return result

    @property
    def version(self):
        """Return mediainfo version information."""
        versions = {'pymediainfo': pymediainfo_version}
        if self.executor:
            executor_version = '.'.join(map(str, self.executor.version))
            versions[self.executor.location] = f'v{executor_version}'
        return versions
