import json
import os
import re
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
import asyncio
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


PORT = 8765
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
YT_DLP = os.path.join(ROOT_DIR, "yt-dlp.exe")
FFMPEG = os.path.join(ROOT_DIR, "ffmpeg.exe")
DEFAULT_DOWNLOAD_DIR = os.path.join(os.path.expanduser("~"), "Desktop", "MediaDownloads")
DOUYIN_API_DIR = os.path.join(ROOT_DIR, "douyin_api")
if os.path.isdir(DOUYIN_API_DIR) and DOUYIN_API_DIR not in sys.path:
    sys.path.insert(0, DOUYIN_API_DIR)

tasks = {}
tasks_lock = threading.Lock()

SUPPORTED_DOMAIN_PATTERNS = [
    r"youtube\.com",
    r"youtu\.be",
    r"douyin\.com",
    r"iesdouyin\.com",
    r"douyinvod\.com",
    r"bytecdn\.cn",
    r"snssdk\.com",
    r"tiktok\.com",
    r"bilibili\.com",
    r"b23\.tv",
    r"xiaohongshu\.com",
    r"xhslink\.com",
    r"music\.163\.com",
    r"x\.com",
    r"twitter\.com",
    r"envato\.com",
    r"elements\.envato\.com",
]

URL_CANDIDATE_RE = re.compile(r"https?://[^\s\"'<>，。！？；、]+", re.I)

COOKIE_ROOT_DOMAINS = {
    "youtube.com",
    "youtube-nocookie.com",
    "accounts.google.com",
    "douyin.com",
    "iesdouyin.com",
    "tiktok.com",
    "bilibili.com",
    "b23.tv",
    "xiaohongshu.com",
    "xhslink.com",
    "music.163.com",
    "x.com",
    "twitter.com",
    "envato.com",
    "elements.envato.com",
}

COOKIE_EXACT_DOMAINS = {
    "google.com",
    "accounts.google.com",
}


def set_task(task_id, **updates):
    with tasks_lock:
        tasks.setdefault(task_id, {})
        tasks[task_id].update(updates)


def get_task(task_id):
    with tasks_lock:
        return dict(tasks.get(task_id, {"status": "idle"}))


def normalize_cookie_list(raw_cookies):
    if isinstance(raw_cookies, str):
        try:
            raw_cookies = json.loads(raw_cookies)
        except json.JSONDecodeError:
            return []
    if isinstance(raw_cookies, dict):
        raw_cookies = [raw_cookies]
    if not isinstance(raw_cookies, list):
        return []
    return [cookie for cookie in raw_cookies if isinstance(cookie, dict)]


def normalize_url(url):
    youtube = re.search(r"(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/)([\w-]{11})", url)
    if youtube:
        return f"https://www.youtube.com/watch?v={youtube.group(1)}"
    douyin = re.search(r"(?:douyin\.com/video/|[?&](?:modal_id|aweme_id|item_id)=)(\d+)", url, re.I)
    if douyin and "douyinvod.com" not in url:
        return f"https://www.douyin.com/video/{douyin.group(1)}"
    return url


def is_supported_url(url):
    if not re.match(r"^https?://", url or "", re.I):
        return False
    return any(re.search(pattern, url, re.I) for pattern in SUPPORTED_DOMAIN_PATTERNS)


def extract_supported_url(text):
    value = str(text or "").strip()
    if is_supported_url(value):
        return value

    for match in URL_CANDIDATE_RE.findall(value):
        cleaned = re.sub(r"[\)\]\}\.,!?;，。！？；、]+$", "", match)
        if is_supported_url(cleaned):
            return cleaned

    return ""


def resolve_download_dir(value):
    raw = str(value or "").strip()
    if not raw:
        return DEFAULT_DOWNLOAD_DIR
    path = os.path.expandvars(os.path.expanduser(raw))
    return os.path.abspath(path)


def open_folder(path):
    folder = resolve_download_dir(path)
    if not os.path.isdir(folder):
        raise FileNotFoundError(f"文件夹不存在：{folder}")
    if os.name == "nt":
        subprocess.Popen(["explorer", folder], creationflags=subprocess.CREATE_NO_WINDOW)
    else:
        opener = "open" if sys.platform == "darwin" else "xdg-open"
        subprocess.Popen([opener, folder])
    return folder


def is_allowed_cookie_domain(domain):
    clean = str(domain or "").lstrip(".").lower()
    if clean in COOKIE_EXACT_DOMAINS:
        return True
    return any(clean == root or clean.endswith("." + root) for root in COOKIE_ROOT_DOMAINS)


def has_bad_cookie_chars(value):
    return any(ch in str(value or "") for ch in ("\t", "\r", "\n"))


def has_raw_cookie(raw_cookies, name):
    raw_cookies = normalize_cookie_list(raw_cookies)
    return any(str(cookie.get("name") or "") == name for cookie in raw_cookies or [])


def is_douyin_url(url):
    return bool(re.search(r"(douyin\.com|iesdouyin\.com)", url or "", re.I))


def cookie_header_for_domains(raw_cookies, domains):
    raw_cookies = normalize_cookie_list(raw_cookies)
    parts = []
    seen = set()
    for cookie in raw_cookies or []:
        domain = str(cookie.get("domain") or "").lstrip(".").lower()
        if not any(domain == root or domain.endswith("." + root) for root in domains):
            continue
        name = str(cookie.get("name") or "")
        value = str(cookie.get("value") or "")
        if not name or has_bad_cookie_chars(name) or has_bad_cookie_chars(value):
            continue
        key = (name, value)
        if key in seen:
            continue
        seen.add(key)
        parts.append(f"{name}={value}")
    return "; ".join(parts)


def extract_douyin_aweme_id(url):
    match = re.search(r"(?:douyin\.com/(?:video|note)/|[?&](?:modal_id|aweme_id|item_id)=)(\d+)", url or "", re.I)
    return match.group(1) if match else ""


def iter_url_lists(value):
    if isinstance(value, dict):
        for key, item in value.items():
            if key == "url_list" and isinstance(item, list):
                for url in item:
                    if isinstance(url, str):
                        yield url
            else:
                yield from iter_url_lists(item)
    elif isinstance(value, list):
        for item in value:
            yield from iter_url_lists(item)


def pick_douyin_video_url(aweme_detail):
    video = (aweme_detail or {}).get("video") or {}
    preferred = []

    for key in ("play_addr_h264", "play_addr", "play_addr_265"):
        preferred.extend(list(iter_url_lists(video.get(key))))

    for item in video.get("bit_rate") or []:
        preferred.extend(list(iter_url_lists(item.get("play_addr"))))

    preferred.extend(list(iter_url_lists(video.get("download_addr"))))

    def score(candidate):
        value = candidate.lower()
        points = 0
        if "douyinvod.com" in value:
            points += 30
        if "v26" in value:
            points += 20
        if "web" in value:
            points += 10
        if "v11" in value:
            points += 2
        if "mime_type=video_mp4" in value or ".mp4" in value:
            points += 5
        if "www.douyin.com/aweme/v1/play" in value:
            points -= 20
        if "douyinpic" in value or "byteimg" in value or "weboff" in value:
            points -= 100
        return points

    candidates = [url for url in preferred if isinstance(url, str) and url.startswith("http")]
    candidates = sorted(dict.fromkeys(candidates), key=score, reverse=True)
    return candidates[0] if candidates else ""


async def resolve_douyin_direct_async(url, cookies=None):
    from crawlers.douyin.web.utils import AwemeIdFetcher
    from crawlers.douyin.web.web_crawler import DouyinWebCrawler
    import crawlers.douyin.web.utils as douyin_utils
    import crawlers.douyin.web.web_crawler as douyin_web

    aweme_id = extract_douyin_aweme_id(url)
    if not aweme_id:
        aweme_id = await AwemeIdFetcher.get_aweme_id(url)

    cookie_header = cookie_header_for_domains(cookies, ("douyin.com", "iesdouyin.com"))
    if cookie_header:
        douyin_utils.config["TokenManager"]["douyin"]["headers"]["Cookie"] = cookie_header
        douyin_web.config["TokenManager"]["douyin"]["headers"]["Cookie"] = cookie_header

    data = await DouyinWebCrawler().fetch_one_video(aweme_id)
    aweme_detail = (data or {}).get("aweme_detail") or {}
    direct_url = pick_douyin_video_url(aweme_detail)
    if not direct_url:
        raise RuntimeError("Douyin parser returned video data but no playable video URL.")

    return {
        "aweme_id": aweme_id,
        "url": direct_url,
        "title": aweme_detail.get("desc") or f"Douyin {aweme_id}",
    }


def resolve_douyin_direct(url, cookies=None):
    if not os.path.isdir(DOUYIN_API_DIR):
        raise RuntimeError("Douyin parser files are missing.")
    return asyncio.run(resolve_douyin_direct_async(url, cookies=cookies))


def clean_cookie(cookie):
    domain = str(cookie.get("domain") or "").strip()
    name = str(cookie.get("name") or "")
    value = str(cookie.get("value") or "")
    path = str(cookie.get("path") or "/")

    if not domain or not name:
        return None
    if not is_allowed_cookie_domain(domain):
        return None
    if any(has_bad_cookie_chars(part) for part in (domain, name, value, path)):
        return None

    try:
        expires = int(cookie.get("expirationDate") or 2147483647)
    except (TypeError, ValueError):
        expires = 2147483647

    include_subdomains = "TRUE" if domain.startswith(".") else "FALSE"
    secure = "TRUE" if cookie.get("secure") else "FALSE"
    return domain, include_subdomains, path, secure, str(expires), name, value


def write_cookie_file(raw_cookies):
    raw_cookies = normalize_cookie_list(raw_cookies)
    rows = []
    for cookie in raw_cookies or []:
        item = clean_cookie(cookie)
        if item:
            rows.append(item)

    if not rows:
        return None, 0

    fd, path = tempfile.mkstemp(prefix="media_cookies_", suffix=".txt")
    with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
        f.write("# Netscape HTTP Cookie File\n")
        f.write("# Generated by local media downloader\n\n")
        for row in rows:
            f.write("\t".join(row) + "\n")
    return path, len(rows)


def build_yt_dlp_cmd(url, cookie_file, download_dir, referer_url=None):
    os.makedirs(download_dir, exist_ok=True)
    output_template = os.path.join(download_dir, "%(extractor_key)s", "media-%(epoch)s-%(autonumber)03d.%(ext)s")

    cmd = [
        YT_DLP,
        "--ffmpeg-location",
        FFMPEG,
        "--no-playlist",
        "--windows-filenames",
        "--restrict-filenames",
        "--trim-filenames",
        "120",
        "--newline",
        "--no-color",
        "--ignore-config",
        "--socket-timeout",
        "60",
        "--retries",
        "50",
        "--file-access-retries",
        "10",
        "--fragment-retries",
        "50",
        "--retry-sleep",
        "linear=1::5",
        "--http-chunk-size",
        "1M",
        "--user-agent",
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
        "--referer",
        referer_url or url,
        "--add-header",
        "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8",
        "-f",
        "bv*+ba/b",
        "--merge-output-format",
        "mp4",
        "-o",
        output_template,
    ]

    if cookie_file:
        cmd.extend(["--cookies", cookie_file])

    cmd.append(url)
    return cmd


def user_friendly_error(text):
    text = (text or "").strip()
    lower = text.lower()

    detail = text[-700:] if text else ""
    if "unsupported url" in lower:
        return f"当前 yt-dlp 不支持这个链接，或链接格式不完整。\n\n原始错误：{detail}"
    if "drm" in lower:
        return f"该内容可能受 DRM 保护，不能下载。\n\n原始错误：{detail}"
    if "forbidden" in lower or "http error 403" in lower:
        return f"访问被拒绝。请确认浏览器已登录该平台，且你有权限访问这个内容。\n\n原始错误：{detail}"
    if "http error 429" in lower or "too many requests" in lower:
        return f"平台暂时限制请求了，请稍后再试。\n\n原始错误：{detail}"
    if "sign in" in lower or "login required" in lower or "cookies" in lower:
        return f"该内容可能需要登录或 Cookie 没有传到下载器。请在 Chrome 里登录对应平台后，重新加载扩展再试。\n\n原始错误：{detail}"
    if "video unavailable" in lower or "private video" in lower:
        return f"内容不可用，可能是私密、地区限制、已删除或需要登录。\n\n原始错误：{detail}"
    if "unable to extract" in lower:
        return f"解析失败。可以尝试更新 yt-dlp.exe，或换用原始分享链接。\n\n原始错误：{detail}"
    return detail if detail else "下载失败，但 yt-dlp 没有返回详细错误。"


def execute_download(task_id, cmd):
    proc = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        encoding="utf-8",
        errors="replace",
        creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
    )

    output_lines = []
    last_error = ""
    set_task(task_id, status="downloading")

    for raw_line in proc.stdout:
        line = raw_line.strip()
        if not line:
            continue

        output_lines.append(line)
        set_task(task_id, raw_output=output_lines[-12:])

        destination = re.search(r"\[download\]\s+Destination:\s+(.+)", line)
        if destination:
            set_task(task_id, title=os.path.basename(destination.group(1)))

        percent = re.search(r"\[download\]\s+(\d+(?:\.\d+)?)%", line)
        if percent:
            set_task(task_id, percent=round(float(percent.group(1)), 1))

        if "Merging formats" in line or "Merger" in line:
            set_task(task_id, percent=99)

        if re.search(r"(ERROR|unable|failed|forbidden|unavailable|sign in|login required|HTTP Error|unsupported|DRM|Fresh cookies)", line, re.I):
            last_error = line

    proc.wait()
    return proc.returncode, output_lines, last_error


def run_yt_dlp(task_id, url, cookies, download_dir, source_url=None):
    cookie_file = None
    try:
        url = normalize_url(url)
        original_url = source_url or url
        output_dir = resolve_download_dir(download_dir)
        cookie_file, cookie_count = write_cookie_file(cookies)
        parser_title = ""

        if is_douyin_url(url):
            try:
                set_task(task_id, status="parsing", percent=0, raw_output=["Parsing Douyin link locally..."])
                parsed = resolve_douyin_direct(url, cookies=cookies)
                parser_title = parsed.get("title") or ""
                source_url = original_url
                url = parsed["url"]
            except Exception as exc:
                set_task(task_id, raw_output=[f"Douyin local parser failed: {exc}", "Falling back to yt-dlp..."])

        set_task(
            task_id,
            status="starting",
            url=url,
            title=parser_title,
            percent=0,
            error="",
            cookie_count=cookie_count,
            output_dir=output_dir,
            raw_output=[],
            started_at=time.time(),
        )

        cmd = build_yt_dlp_cmd(url, cookie_file, output_dir, referer_url=source_url)
        return_code, output_lines, last_error = execute_download(task_id, cmd)

        if return_code != 0 and "fresh cookies" in (last_error or "\n".join(output_lines[-10:])).lower():
            if is_douyin_url(url):
                has_verify_cookie = has_raw_cookie(cookies, "s_v_web_id")
                error_text = (
                    "Douyin still rejected the browser cookies exported by the extension. "
                    f"s_v_web_id present: {'yes' if has_verify_cookie else 'no'}. "
                    "Open https://www.douyin.com/ in Chrome, play a video for a few seconds, reload the extension, and try again. "
                    "If this keeps happening, the current yt-dlp Douyin extractor cannot handle this Douyin session/link yet."
                )
                set_task(task_id, status="error", error=error_text, raw_output=output_lines[-20:], finished_at=time.time())
                return

        if return_code == 0:
            set_task(task_id, status="done", percent=100, error="", finished_at=time.time())
            return

        error_text = last_error or "\n".join(output_lines[-10:])
        set_task(task_id, status="error", error=user_friendly_error(error_text), raw_output=output_lines[-20:], finished_at=time.time())

    except Exception as exc:
        set_task(task_id, status="error", error=str(exc), finished_at=time.time())
    finally:
        if cookie_file and os.path.exists(cookie_file):
            try:
                os.remove(cookie_file)
            except OSError:
                pass


class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        return

    def send_cors(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")

    def send_json(self, data, status=200):
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_cors()
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_OPTIONS(self):
        self.send_response(204)
        self.send_cors()
        self.end_headers()

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        if parsed.path == "/api/health":
            self.send_json({
                "status": "ok",
                "yt_dlp": os.path.exists(YT_DLP),
                "ffmpeg": os.path.exists(FFMPEG),
                "default_download_dir": DEFAULT_DOWNLOAD_DIR,
                "platforms": ["YouTube", "Douyin", "Bilibili", "Xiaohongshu", "NetEase Music", "X", "Envato"],
            })
            return

        if parsed.path == "/api/status":
            query = urllib.parse.parse_qs(parsed.query)
            task_id = query.get("id", [""])[0]
            self.send_json(get_task(task_id))
            return

        if parsed.path == "/api/tasks":
            with tasks_lock:
                all_tasks = list(tasks.items())
            active = []
            recent = []
            for task_id, info in all_tasks:
                status = (info or {}).get("status", "idle")
                if status in ("queued", "starting", "parsing", "downloading"):
                    active.append({"id": task_id, **info})
                elif status in ("done", "error", "idle"):
                    recent.append({"id": task_id, **info})
            recent.sort(key=lambda t: t.get("finished_at") or t.get("started_at") or 0, reverse=True)
            self.send_json({"active": active, "recent": recent[:30]})
            return

        self.send_json({"error": "not found"}, 404)

    def do_POST(self):
        if self.path == "/api/open-folder":
            try:
                length = int(self.headers.get("Content-Length", "0"))
                data = json.loads(self.rfile.read(length) or b"{}")
                folder = open_folder(data.get("path") or DEFAULT_DOWNLOAD_DIR)
                self.send_json({"status": "ok", "path": folder})
            except Exception as exc:
                self.send_json({"error": str(exc)}, 400)
            return

        if self.path != "/api/download":
            self.send_json({"error": "not found"}, 404)
            return

        try:
            length = int(self.headers.get("Content-Length", "0"))
            data = json.loads(self.rfile.read(length) or b"{}")
            url = extract_supported_url(data.get("url") or "")
            cookies = data.get("cookies") or []
            download_dir = data.get("downloadDir") or ""
            source_url = extract_supported_url(data.get("sourceUrl") or "") or url
        except Exception:
            self.send_json({"error": "请求格式不正确"}, 400)
            return

        if not url:
            self.send_json({"error": "缺少媒体链接"}, 400)
            return

        if not is_supported_url(url):
            self.send_json({"error": "暂不支持这个链接。支持 YouTube、抖音、B站、小红书、网易云音乐、X、Envato 等链接。"}, 400)
            return

        try:
            output_dir = resolve_download_dir(download_dir)
            os.makedirs(output_dir, exist_ok=True)
        except Exception as exc:
            self.send_json({"error": f"下载路径不可用：{exc}"}, 400)
            return

        task_id = str(int(time.time() * 1000))
        set_task(task_id, status="queued", url=url, title="", percent=0, error="", output_dir=output_dir, submitted_at=time.time())
        thread = threading.Thread(target=run_yt_dlp, args=(task_id, url, cookies, output_dir, source_url), daemon=True)
        thread.start()
        self.send_json({"status": "ok", "task_id": task_id})


def main():
    if not os.path.exists(YT_DLP):
        print(f"Cannot find yt-dlp.exe: {YT_DLP}")
        sys.exit(1)
    if not os.path.exists(FFMPEG):
        print(f"Cannot find ffmpeg.exe: {FFMPEG}")
        sys.exit(1)

    os.makedirs(DEFAULT_DOWNLOAD_DIR, exist_ok=True)
    server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
    print("Media downloader service started")
    print(f"URL: http://127.0.0.1:{PORT}")
    print(f"Default download directory: {DEFAULT_DOWNLOAD_DIR}")
    server.serve_forever()


if __name__ == "__main__":
    main()
