import json
import shutil
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from contextlib import chdir
from datetime import UTC, datetime
from pathlib import Path
from random import choice
from threading import Lock
from time import sleep
from typing import ClassVar
from urllib.parse import quote, unquote

import requests
from bs4 import BeautifulSoup
from requests import Session
from requests.adapters import HTTPAdapter, Retry

from ..utils import LOGGER, call, get_files_sorted


class Dumper:

    title: str = ''

    _user_input_map: ClassVar[dict[str, str]]

    _headers: ClassVar[dict[str, str]] = {
        'Connection': 'keep-alive',
        'Accept': '*/*',
        'User-Agent': (
            'Mozilla/5.0 (X11; Linux x86_64) '
            'AppleWebKit/537.36 (KHTML, like Gecko) '
            'Chrome/138.0.0.0 YaBrowser/25.8.0.0 Safari/537.36'
        ),
        'Sec-Fetch-Site': 'same-site',
        'Sec-Fetch-Mode': 'cors',
        'Accept-Language': 'ru,en;q=0.9',
        'Accept-Encoding': 'gzip, deflate, sdch, br',
    }

    _media_ext: ClassVar[set[str]] = {'.ts', '.m4s'}

    registry: ClassVar[list[type['Dumper']]] = []

    def __init_subclass__(cls):
        super().__init_subclass__()
        cls.registry.append(cls)

    def __init__(self, *, target_dir: Path, timeout: int = 3, concurrent: int = 10, sleepy: bool = False) -> None:
        self._target_dir = target_dir
        self._timeout = timeout
        self._concurrent = concurrent
        self._user_input_map = self._user_input_map or {}
        self._session = self._get_session()
        self._sleepy = sleepy

    def __str__(self):
        return self.title

    def _get_session(self) -> Session:
        # todo при ошибках сессия в нитях блокируется. можно попробовать несколько сессий
        session = requests.Session()
        session.headers = self._headers
        retries = Retry(total=3, backoff_factor=0.1, status_forcelist=[500])
        session.mount('http://', HTTPAdapter(max_retries=retries))
        session.mount('https://', HTTPAdapter(max_retries=retries))
        return session

    def _get_args(self, *, get_param_hook: Callable[[str, str], str]) -> dict:
        input_data = {}

        for param, hint in self._user_input_map.items():
            input_data[param] = get_param_hook(param, hint)

        return input_data

    def _chunks_get_list(self, url: str, *, url_prefix: str = '') -> list[str]:
        """Get video chunks names from playlist file at URL.

        * Links to other playlists:
            #EXTM3U
            #EXT-X-VERSION:7
            #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",AUTOSELECT=YES,NAME="xxx",URI="a1/index.m3u8"
            #EXT-X-STREAM-INF:BANDWIDTH=5205192,RESOLUTION=1920x1080,CODECS="avc1.42C02A,mp4a.40.2",AUDIO="audio"
            v1/index.m3u8

        * Links to chunks:
            #EXTM3U
            #EXT-X-VERSION:6
            #EXT-X-MEDIA-SEQUENCE:1
            #EXT-X-INDEPENDENT-SEGMENTS
            #EXT-X-TARGETDURATION:12
            #EXT-X-MAP:URI="init/1.m4s"
            #EXTINF:2.000,
            media/1.m4s
            #EXT-X-DISCONTINUITY

        :param url: File URL.
        :param url_prefix: File URL prefix.

        """
        LOGGER.info(f'Getting video chunks from playlist {url} ...')

        sub_playlist = self._get_response_simple(url)
        playlists = []
        chunk_lists = []
        media_ext = self._media_ext

        for line in sub_playlist.splitlines():
            line = line.strip()

            if 'EXT-X-MAP:URI' in line:
                # naive parsing. todo: respect EXT-X-DISCONTINUITY and EXT-X-MEDIA:TYPE=AUDIO,...,AUTOSELECT=YES
                line = line.rpartition("=")[2].strip('"')

            if line.endswith('.m3u8'):
                playlists.append(line)
                continue

            path = Path(line.partition('?')[0])
            if path.suffix not in media_ext:
                continue

            if url_prefix:
                line = f'{url_prefix}/{line}'

            chunk_lists.append(line)

        if playlists:
            LOGGER.info('Sub playlists found. Will use the first one ...')

            sub_playlist = playlists[0]
            sub_playlist_url_prefix = sub_playlist.rpartition('/')[0]
            sub_playlist_url = f'{url.rpartition("/")[0]}/{sub_playlist}'

            chunk_lists = self._chunks_get_list(sub_playlist_url, url_prefix=sub_playlist_url_prefix)

        else:
            assert chunk_lists, 'No video chunks found in playlist file'

        return chunk_lists

    def _chunks_download(
        self,
        *,
        url_video_root: str,
        dump_dir: Path,
        chunk_names: list[str],
        start_chunk: str,
        headers: dict[str, str] | None = None,
        concurrent: int = 10,
    ) -> None:

        chunks_total = len(chunk_names)

        progress_file = (dump_dir / 'files.txt')
        progress_file.touch()

        files_done = dict.fromkeys(progress_file.read_text().splitlines())
        lock = Lock()

        def dump(*, name: str, file_idx: int, url: str, session: Session, sleepy: bool, timeout: int) -> None:

            name = name.partition('?')[0]  # drop GET-args

            if name in files_done:
                LOGGER.info(f'File {name} has already been downloaded before. Skipping.')
                return

            filename = name.rpartition('/')[2]  # drop url prefix
            filename = f'{file_idx}_{filename}'

            LOGGER.info(f'Trying to download {filename} {url} ...')

            with session.get(url, headers=headers or {}, stream=True, timeout=timeout) as r:
                r.raise_for_status()
                with (dump_dir / filename).open('wb') as f:
                    f.writelines(r.iter_content(chunk_size=8192))

            files_done[name] = True
            with lock:
                progress_file.write_text('\n'.join(files_done))

            if sleepy:
                sleep(choice([1, 0.5, 0.7, 0.6]))

        with ThreadPoolExecutor(max_workers=concurrent) as executor:

            future_url_map = {}

            for idx, chunk_name in enumerate(chunk_names, 1):

                if chunk_name == start_chunk:
                    start_chunk = ''  # clear to allow further download

                if start_chunk:
                    continue

                chunk_url = f'{url_video_root.rstrip("/")}/{chunk_name}'
                submitted = executor.submit(
                    dump,
                    name=chunk_name,
                    file_idx=idx,
                    url=chunk_url,
                    session=self._session,
                    sleepy=self._sleepy,
                    timeout=self._timeout,
                )

                future_url_map[submitted] = (chunk_name, chunk_url)

            if future_url_map:
                LOGGER.info(f'Downloading up to {concurrent} files concurrently ...')

                counter = 1
                for future in as_completed(future_url_map):
                    chunk_name, chunk_url = future_url_map[future]
                    future.result()
                    percent = round(counter * 100 / chunks_total, 1)
                    counter += 1
                    LOGGER.info(f'Got {counter}/{chunks_total} ({chunk_name.partition("?")[0]}) [{percent}%] ...')

    def _video_concat(self, path: Path) -> Path:

        LOGGER.info('Concatenating video ...')

        fname_video = 'all_chunks.mp4'
        fname_index = 'all_chunks.txt'

        mode_m4s = False

        filenames = get_files_sorted(path, suffixes=self._media_ext)

        for filename in filenames:
            if filename.endswith('m4s'):
                mode_m4s = True
                break

        def create_index(line_tpl: str = '%s'):
            with (path / fname_index).open('w') as f:
                f.writelines([f'{line_tpl % fname}\n' for fname in filenames])

        if mode_m4s:
            fname_raw = 'all_chunks.mp4'
            create_index()
            call(f'xargs cat < {fname_index} >> {fname_raw}', path=path)
            call(f'ffmpeg -y -i {fname_raw} -c copy {fname_video}', path=path)

        else:
            # presumably ts
            create_index('file %s')
            call(f'ffmpeg -y -f concat -i {fname_index} -c copy -bsf:a aac_adtstoasc {fname_video}', path=path)

        return path / fname_video

    def _get_soup(self, content: str) -> BeautifulSoup:
        return BeautifulSoup(content, 'html.parser')

    def _extract_js_objects(self, contents: str, *, key: str = '') -> list[dict]:
        """Returns a list of js-objects (as dicts) for html contents.

        :param contents: Html.
        :param key: Object key to filter objects.
        """
        soup = self._get_soup(contents)
        found = []

        for script in soup.find_all('script'):
            text = script.text.strip()
            if text.startswith('{') and text.endswith('}'):
                try:
                    obj = json.loads(text)
                    if not key or key in obj:
                        found.append(obj)

                except json.decoder.JSONDecodeError:
                    pass

        return found

    def _handle_response_simple(self, response, *, json: bool = False, dump: bool = False) -> str | dict:
        """Returns a text or a dictionary from a URL.

        :param response:
        :param json: Decode json
        :param dump: Dump response to file

        """
        response.raise_for_status()
        val = response.json() if json else response.text

        if dump:
            Path(f'dmp_{datetime.now(tz=UTC)}.dump').write_text(val)

        return val

    def _get_response_simple(self, url: str, *, json: bool = False, dump: bool = False) -> str | dict:
        """Returns a text or a dictionary from a URL.

        :param url:
        :param json: Decode json
        :param dump: Dump response to file

        """
        response = self._session.get(url)
        return self._handle_response_simple(response, json=json, dump=dump)

    def _sanitize_title(self, title: str) -> str:
        return unquote(title)

    def _video_dump(
        self,
        *,
        title: str,
        url_playlist: str,
        url_referer: str,
        start_chunk: str = '',
    ) -> Path:
        assert url_playlist.endswith('m3u8'), f'No playlist in `{url_playlist}`'
        title = self._sanitize_title(title)

        LOGGER.info(f'Title: {title}')

        chunk_names = self._chunks_get_list(url_playlist)

        target_dir = self._target_dir
        LOGGER.info(f'Downloading video into {target_dir} ...')

        with chdir(target_dir):
            dump_dir = (target_dir / title).absolute()
            dump_dir.mkdir(parents=True, exist_ok=True)

            url_root = url_playlist.rpartition('/')[0]  # strip playlist filename

            self._chunks_download(
                url_video_root=url_root,
                dump_dir=dump_dir,
                chunk_names=chunk_names,
                start_chunk=start_chunk,
                headers={'Referer': quote(url_referer.strip())},
                concurrent=self._concurrent,
            )

            fpath_video_target = Path(f'{title}.mp4').absolute()
            fpath_video = self._video_concat(dump_dir)

            shutil.move(fpath_video, fpath_video_target)
            shutil.rmtree(dump_dir, ignore_errors=True)

        LOGGER.info(f'Video is ready: {fpath_video_target}')
        return fpath_video_target

    def _gather(self, *, url_video: str, start_chunk: str = '', **params) -> Path:
        raise NotImplementedError

    def run(self, params_or_hook: Callable[[str, str], str] | dict[str, str]) -> Path:
        params = params_or_hook if isinstance(params_or_hook, dict) else self._get_args(get_param_hook=params_or_hook)
        return self._gather(**params)
