import argparse, json, sys
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from sqlite3 import OperationalError
from typing import Dict, List, Optional, Tuple

import yt_dlp

from xklb import consts, fs_extract, utils
from xklb.consts import DBType
from xklb.dl_config import yt_meaningless_errors, yt_recoverable_errors, yt_unrecoverable_errors
from xklb.utils import combine, log, safe_unpack


def tube_opts(args, func_opts=None, playlist_opts: Optional[str] = None) -> dict:
    if playlist_opts is None or playlist_opts == "":
        playlist_opts = "{}"
    if func_opts is None:
        func_opts = {}
    cli_opts = {}
    if hasattr(args, "dl_config"):
        cli_opts = args.dl_config

    default_opts = {
        "ignoreerrors": False,
        "no_warnings": False,
        "quiet": True,
        "noprogress": True,
        "skip_download": True,
        "lazy_playlist": True,
        "noplaylist": False,
        "extract_flat": True,
        "dynamic_mpd": False,
        "youtube_include_dash_manifest": False,
        "youtube_include_hls_manifest": False,
        "no_check_certificate": True,
        "check_formats": False,
        "ignore_no_formats_error": True,
        "skip_playlist_after_errors": 21,
        "clean_infojson": False,
        "playlistend": 20000,
        "rejecttitle": "|".join(
            [
                " AWARD",
                " Clip",
                " GPU ",
                " Scene",
                " Terror",
                "360",
                "Advert",
                "Announcement",
                "Apology",
                "Best of",
                "Bitcoin",
                "campaign",
                "Ceremony",
                "Clip ",
                "Compilation",
                "Crypto ",
                "Event",
                "Final Look",
                "First Look",
                "Graphics Card",
                "Horror",
                "In Theaters",
                "Live ",
                "Makeup",
                "Meetup",
                "Montage",
                "Now Playing",
                "Outtakes",
                "Panel",
                "Preview",
                "Promo",
                "Red Carpet Premiere",
                "Sneak Peek",
                "Stream",
                "Teaser",
                "Top 10",
                "Top 2",
                "Top 3",
                "Top 4",
                "Top 5",
                "Top 6",
                "Top 7",
                "Top 8",
                "Top 9",
                "Top Eight",
                "Top Five",
                "Top Four",
                "Top Nine",
                "Top Seven",
                "Top Six",
                "Top Ten",
                "Top Three",
                "Top Two",
                "Trailer",
                "TV Spot",
                "Twitch",
                "World Premiere",
            ]
        ),
    }

    all_opts = {
        **default_opts,
        **func_opts,
        **json.loads(playlist_opts),
        **cli_opts,
    }

    if args.verbose == 0 and not consts.PYTEST_RUNNING:
        all_opts.update(ignoreerrors="only_download")
    if args.verbose >= 2:
        all_opts.update(ignoreerrors=False, quiet=False)
    if args.ignore_errors:
        all_opts.update(ignoreerrors=True)

    log.debug(utils.dict_filter_bool(all_opts))

    if hasattr(args, "playlists") and args.playlists and hasattr(args, "no_sanitize") and not args.no_sanitize:
        args.playlists = [consts.sanitize_url(args, path) for path in args.playlists]

    return all_opts


def is_supported(url) -> bool:  # thank you @dbr
    ies = yt_dlp.extractor.gen_extractors()
    for ie in ies:
        if ie.suitable(url) and ie.IE_NAME != "generic":
            return True  # Site has dedicated extractor
    return False


def get_playlists(args, cols="path, dl_config", constrain=False) -> List[dict]:
    columns = args.db["playlists"].columns_dict
    sql_filters = []
    if "time_deleted" in columns:
        sql_filters.append("AND time_deleted=0")
    if constrain:
        if args.category:
            sql_filters.append(f"AND category='{args.category}'")
        if args.profile:
            sql_filters.append(f"AND profile='{args.profile}'")

    try:
        known_playlists = list(
            args.db.query(f"select {cols} from playlists where 1=1 {' '.join(sql_filters)} order by random()")
        )
    except OperationalError:
        known_playlists = []
    return known_playlists


def is_playlist_known(args, playlist_path) -> bool:
    try:
        known = args.db.execute("select 1 from playlists where path=?", [playlist_path]).fetchone()
    except Exception:
        return False
    if known is None:
        return False
    return True


def is_video_known(args, playlist_path, path) -> bool:
    try:
        known = args.db.execute(
            "select 1 from media where playlist_path=? and path=?", [playlist_path, path]
        ).fetchone()
    except Exception:
        return False
    if known is None:
        return False
    return True


def consolidate(v: dict) -> Optional[dict]:
    if v.get("title") in ("[Deleted video]", "[Private video]"):
        return None

    for k in list(v):
        if k.startswith("_") or k in consts.TUBE_IGNORE_KEYS:
            v.pop(k, None)

    release_date = v.pop("release_date", None)
    upload_date = v.pop("upload_date", None) or release_date
    if upload_date:
        upload_date = int(datetime.strptime(upload_date, "%Y%m%d").timestamp())

    cv = {}
    cv["path"] = safe_unpack(v.pop("webpage_url", None), v.pop("url", None), v.pop("original_url", None))
    size_bytes = v.pop("filesize_approx", None)
    cv["size"] = 0 if not size_bytes else int(size_bytes)
    duration = v.pop("duration", None)
    cv["duration"] = 0 if not duration else int(duration)
    cv["time_uploaded"] = upload_date
    cv["time_created"] = int(datetime.now().timestamp())
    cv["time_modified"] = 0
    cv["time_deleted"] = 0
    cv["time_downloaded"] = 0
    cv["play_count"] = 0
    cv["time_played"] = 0
    language = v.pop("language", None)
    cv["tags"] = combine(
        "language:" + language if language else None,
        v.pop("description", None),
        v.pop("categories", None),
        v.pop("tags", None),
    )
    cv["id"] = v.pop("id")
    cv["ie_key"] = safe_unpack(v.pop("ie_key", None), v.pop("extractor_key", None), v.pop("extractor", None))
    cv["title"] = safe_unpack(v.pop("title", None), v.get("playlist_title"))
    cv["view_count"] = v.pop("view_count", None)
    cv["width"] = v.pop("width", None)
    cv["height"] = v.pop("height", None)
    fps = v.pop("fps", None)
    cv["fps"] = 0 if not fps else int(fps)
    cv["average_rating"] = v.pop("average_rating", None)
    cv["live_status"] = v.pop("live_status", None)
    cv["age_limit"] = v.pop("age_limit", None)
    cv["uploader"] = safe_unpack(
        v.pop("playlist_uploader_id", None),
        v.pop("channel_id", None),
        v.pop("playlist_uploader", None),
        v.pop("uploader_url", None),
        v.pop("channel_url", None),
        v.pop("uploader", None),
        v.pop("channel", None),
        v.pop("uploader_id", None),
    )

    if v != {}:
        log.info("Extra data %s", v)
        # breakpoint()

    return utils.dict_filter_bool(cv)


def log_problem(args, playlist_path) -> None:
    if is_playlist_known(args, playlist_path):
        log.warning("Start of known playlist reached %s", playlist_path)
    else:
        log.warning("Could not add playlist %s", playlist_path)


def _add_playlist(args, playlist_path, pl: dict, media_path: Optional[str] = None) -> None:
    extractor = safe_unpack(pl.get("ie_key"), pl.get("extractor_key"), pl.get("extractor"))
    playlist = {
        "ie_key": extractor,
        "title": pl.get("playlist_title"),
        "path": playlist_path,
        "uploader": safe_unpack(pl.get("playlist_uploader_id"), pl.get("playlist_uploader")),
        "id": pl.get("playlist_id"),
        "dl_config": args.dl_config,
        "time_deleted": 0,
        "category": args.category or extractor,
        "profile": args.profile,
        **args.extra_playlist_data,
    }

    if not playlist.get("id") or media_path == playlist["path"]:
        log.warning("Importing playlist-less media %s", playlist["path"])
    else:
        args.db["playlists"].upsert(utils.dict_filter_bool(playlist), pk="path", alter=True)


def save_undownloadable(args, playlist_path):
    entry = {
        "path": playlist_path,
        "title": "No data from ydl.extract_info",
        "category": args.category or "Uncategorized",
        "profile": args.profile,
        "dl_config": args.dl_config,
        **args.extra_playlist_data,
    }
    args.db["playlists"].upsert(utils.dict_filter_bool(entry), pk="path", alter=True)


playlists_of_playlists = []
added_media_count = 0


def process_playlist(args, playlist_path, ydl_opts, playlist_root=True) -> Optional[List[Dict]]:
    for k, v in args.extra_playlist_data.items():
        setattr(args, k, v)

    class ExistingPlaylistVideoReached(yt_dlp.DownloadCancelled):
        pass

    class AddToArchivePP(yt_dlp.postprocessor.PostProcessor):
        def run(self, info) -> Tuple[list, dict]:  # pylint: disable=arguments-renamed
            global added_media_count

            if info:
                url = safe_unpack(info.get("webpage_url"), info.get("url"), info.get("original_url"))
                if url != playlist_path and info.get("webpage_url_basename") == "playlist":
                    if playlist_root:
                        _add_playlist(args, playlist_path, deepcopy(info))

                    if url in playlists_of_playlists:
                        raise ExistingPlaylistVideoReached  # prevent infinite bug

                    process_playlist(args, url, ydl_opts, playlist_root=False)
                    playlists_of_playlists.append(url)
                    return [], info

                entry = consolidate(deepcopy(info))
                if entry:
                    entry["playlist_path"] = playlist_path
                    _add_playlist(args, playlist_path, deepcopy(info), entry["path"])

                    if is_video_known(args, playlist_path, entry["path"]):
                        raise ExistingPlaylistVideoReached

                    entry = {**entry, **args.extra_media_data}
                    args.db["media"].upsert(utils.dict_filter_bool(entry), pk="path", alter=True)

                    added_media_count += 1
                    if added_media_count > 1:
                        sys.stdout.write("\033[K\r")
                        print(f"[{playlist_path}] Added {added_media_count} media", end="\r", flush=True)

            return [], info

    with yt_dlp.YoutubeDL(ydl_opts) as ydl:
        ydl.add_post_processor(AddToArchivePP(), when="pre_process")

        try:
            pl = ydl.extract_info(playlist_path, download=False, process=True)
        except ExistingPlaylistVideoReached:
            log_problem(args, playlist_path)
        else:
            sys.stdout.write("\n")
            if not pl:
                log.warning("Logging undownloadable media")
                save_undownloadable(args, playlist_path)


def get_extra_metadata(args, playlist_path, playlist_dl_opts=None) -> Optional[List[Dict]]:
    m_columns = args.db["media"].columns_dict

    with yt_dlp.YoutubeDL(
        tube_opts(
            args,
            func_opts={
                "subtitlesformat": "srt/best",
                "writesubtitles": True,
                "writeautomaticsub": True,
                "subtitleslangs": ["en.*", "EN.*"],
                "extract_flat": False,
                "lazy_playlist": False,
                "skip_download": True,
                "check_formats": False,
                "ignoreerrors": True,
            },
            playlist_opts=playlist_dl_opts,
        )
    ) as ydl:
        videos = args.db.execute(
            f"""
            SELECT
                path
            , ie_key
            , play_count
            , time_played
            FROM media
            WHERE 1=1
                {'and width is null' if 'width' in m_columns else ''}
                and path not like '%playlist%'
                and playlist_path = ?
            ORDER by random()
            """,
            [playlist_path],
        ).fetchall()

        current_video_count = 0
        for path, ie_key, play_count, time_played in videos:
            entry = ydl.extract_info(path, ie_key=ie_key, download=False)
            if entry is None:
                continue

            entry = consolidate(entry)
            if entry is None:
                continue

            entry["playlist_path"] = playlist_path
            entry["play_count"] = play_count
            entry["time_played"] = time_played
            args.db["media"].upsert(utils.dict_filter_bool(entry), pk="path", alter=True)

            current_video_count += 1
            sys.stdout.write("\033[K\r")
            print(
                f"[{playlist_path}] {current_video_count} of {len(videos)} extra metadata fetched", end="\r", flush=True
            )


def update_playlists(args, playlists):
    for d in playlists:
        process_playlist(
            args,
            d["path"],
            tube_opts(args, playlist_opts=d["dl_config"], func_opts={"ignoreerrors": "only_download"}),
        )

        if args.extra:
            log.warning("[%s]: Getting extra metadata", d["path"])
            get_extra_metadata(args, d["path"], playlist_dl_opts=d["dl_config"])


def save_tube_entry(args, m, info: Optional[dict] = None, error=None, URE=False) -> None:
    webpath = m["path"]
    error = None if not error else error.replace(m["id"], "").replace(" :", ":")
    if not info:  # not downloaded
        entry = {
            "path": webpath,
            "time_downloaded": 0,
            "time_modified": consts.NOW,
            "time_deleted": consts.NOW if URE else 0,
            "error": error,
        }
        args.db["media"].upsert(utils.dict_filter_bool(entry), pk="path", alter=True)  # type: ignore
        return

    assert info["local_path"] != ""
    if Path(info["local_path"]).exists():
        fs_args = argparse.Namespace(
            profile=m["profile"],
            scan_subtitles=True if m["profile"] == DBType.video else False,
            delete_unplayable=False,
            ocr=False,
            speech_recognition=False,
        )
        fs_tags = utils.dict_filter_bool(fs_extract.extract_metadata(fs_args, info["local_path"]), keep_0=False) or {}
        for p in Path(consts.SUB_TEMP_DIR).glob("*.srt"):
            p.unlink()
    else:
        fs_tags = {}

    tube_entry = consolidate(info) or {}
    tube_entry.pop("play_count", None)
    tube_entry.pop("time_played", None)

    entry = {
        **tube_entry,
        **fs_tags,
        "webpath": webpath,
        "time_modified": consts.NOW,
        "time_downloaded": 0 if error else consts.NOW,
        "time_deleted": consts.NOW if URE else 0,
        "error": error,
    }
    args.db["media"].upsert(utils.dict_filter_bool(entry), pk="path", alter=True)  # type: ignore

    if fs_tags:
        args.db["media"].delete(webpath)


def yt(args, m) -> None:
    ydl_log = {"warning": [], "error": []}

    class BadToTheBoneLogger:
        def debug(self, msg):
            if msg.startswith("[debug] "):
                pass
            else:
                self.info(msg)

        def info(self, msg):
            pass

        def warning(self, msg):
            ydl_log["warning"].append(msg)

        def error(self, msg):
            ydl_log["error"].append(msg)

    out_dir = lambda p: str(Path(args.prefix, m["category"], p))
    Path(out_dir("tunnel_snakes_rule")).parent.mkdir(parents=True, exist_ok=True)
    ydl_opts = tube_opts(
        args,
        func_opts={
            "ignoreerrors": False,
            "subtitleslangs": ["en.*", "EN.*"],
            "extractor_args": {"youtube": {"skip": ["authcheck"]}},
            "logger": BadToTheBoneLogger(),
            "writesubtitles": True,
            "writeautomaticsub": True,
            "skip_download": True if consts.PYTEST_RUNNING else False,
            "subtitlesformat": "srt/best",
            "extract_flat": False,
            "lazy_playlist": False,
            "postprocessors": [{"key": "FFmpegMetadata"}, {"key": "FFmpegEmbedSubtitle"}],
            "restrictfilenames": True,
            "extractor_retries": 13,
            "retries": 13,
            "outtmpl": {
                "default": out_dir("%(uploader|uploader_id)s/%(title).200B_[%(id).60B].%(ext)s"),
                "chapter": out_dir(
                    "%(uploader|uploader_id)s/%(title).200B_%(section_number)03d_%(section_title)s_[%(id).60B].%(ext)s"
                ),
            },
        },
        playlist_opts=m["dl_config"],
    )

    download_archive = Path("~/.local/share/yt_archive.txt").resolve()
    if download_archive.exists():
        ydl_opts["download_archive"] = str(download_archive)
        ydl_opts["cookiesfrombrowser"] = (("firefox",),)

    if args.small:
        ydl_opts["format"] = "bestvideo[height<=576]+bestaudio/best[height<=576]/best"

    if args.ext == "DEFAULT":
        if m["profile"] == DBType.audio:
            args.ext = "opus"
        else:
            args.ext = None

    if m["profile"] == DBType.audio:
        ydl_opts[
            "format"
        ] = "bestaudio[ext=opus]/bestaudio[ext=webm]/bestaudio[ext=ogg]/bestaudio[ext=oga]/bestaudio/best"
        ydl_opts["postprocessors"].append({"key": "FFmpegExtractAudio", "preferredcodec": args.ext})

    match_filters = ["live_status=?not_live"]
    if args.small:
        match_filters.append("duration >? 59 & duration <? 14399")
    match_filter_user_config = ydl_opts.get("match_filter")
    if match_filter_user_config is not None:
        match_filters.append(match_filter_user_config)
    ydl_opts["match_filter"] = yt_dlp.utils.match_filter_func(" & ".join(match_filters).split(" | "))

    with yt_dlp.YoutubeDL(ydl_opts) as ydl:
        try:
            info = ydl.extract_info(m["path"], download=True)
        except yt_dlp.DownloadError as e:
            error = consts.REGEX_ANSI_ESCAPE.sub("", str(e))
            log.warning("[%s]: yt-dlp %s", m["path"], error)
            save_tube_entry(args, m, error=error)
            return
        if info is None:
            log.warning("[%s]: yt-dlp returned no info", m["path"])
            save_tube_entry(args, m, error="yt-dlp returned no info")
            return

        if m["profile"] == DBType.audio:
            info["local_path"] = ydl.prepare_filename({**info, "ext": args.ext})
        else:
            info["local_path"] = ydl.prepare_filename(info)

        ydl_errors = ydl_log["error"] + ydl_log["warning"]
        ydl_errors = "\n".join([s for s in ydl_errors if not yt_meaningless_errors.match(s)])

        if not ydl_log["error"]:
            log.debug("[%s]: No news is good news", m["path"])
            save_tube_entry(args, m, info)
        elif yt_recoverable_errors.match(ydl_errors):
            log.info("[%s]: Recoverable error matched. %s", m["path"], ydl_errors)
            save_tube_entry(args, m, info, error=ydl_errors)
        elif yt_unrecoverable_errors.match(ydl_errors):
            log.info("[%s]: Unrecoverable error matched. %s", m["path"], ydl_errors)
            save_tube_entry(args, m, info, error=ydl_errors, URE=True)
        else:
            log.warning("[%s]: Unknown error. %s", m["path"], ydl_errors)
            save_tube_entry(args, m, info, error=ydl_errors)
