"""DMM 爬蟲（官方 GraphQL API + 動態學習）"""
import json
import re
import requests
from pathlib import Path
from typing import Optional

from core.logger import get_logger

logger = get_logger(__name__)
from .base import BaseScraper
from .models import Video, Actress, ScraperConfig
from .utils import rate_limit


# 快取檔案路徑（專案根目錄）
PROJECT_ROOT = Path(__file__).parent.parent.parent
CACHE_FILE = PROJECT_ROOT / "dmm_content_ids.json"      # 完整番號 → content_id
PREFIX_FILE = PROJECT_ROOT / "dmm_prefix_hints.json"    # 番號前綴 → DMM 前綴

# module-level capability cache（三態）
# None = 未知（首次或暫時性失敗），True = schema 支援，False = schema 不支援
_genres_supported: Optional[bool] = None
_sample_images_supported: Optional[bool] = None


class DMMScraper(BaseScraper):
    """
    DMM 爬蟲（使用官方 GraphQL API）

    優點：
    - 官方資料來源，資料最準確
    - 封面無浮水印、高畫質
    - 有完整簡介、導演資訊

    特點：
    - 雙層快取：前綴映射 + content_id 快取
    - 動態學習：發現新前綴會自動記錄
    - 無需預設映射表，完全由用戶運行時生成

    注意：
    - 需要日本 IP（VPN）
    - API 可能隨時變動（非公開 API）
    """

    API_URL = "https://api.video.dmm.co.jp/graphql"

    DETAIL_QUERY = """
        query ContentPageData($id: ID!) {
            ppvContent(id: $id) {
                id
                title
                description
                packageImage { largeUrl }
                makerReleasedAt
                duration
                actresses { name }
                directors { name }
                series { name }
                maker { name }
                makerContentId
            }
        }
    """

    SEARCH_QUERY = """
        query AvSearch($limit: Int!, $sort: ContentSearchPPVSort!, $queryWord: String) {
            legacySearchPPV(limit: $limit, sort: $sort, queryWord: $queryWord) {
                result { contents { id } }
            }
        }
    """

    SEARCH_LIST_QUERY = """
        query AvSearch($limit: Int!, $offset: Int!, $sort: ContentSearchPPVSort!, $queryWord: String) {
            legacySearchPPV(limit: $limit, offset: $offset, sort: $sort, queryWord: $queryWord) {
                result {
                    contents {
                        id
                        title
                        packageImage { largeUrl }
                        actresses { name }
                        maker { name }
                    }
                }
            }
        }
    """

    # 獨立 probe query — 與 DETAIL_QUERY 分離，失敗不影響主流程
    GENRES_PROBE_QUERY = """
        query ProbeGenres($id: ID!) {
            ppvContent(id: $id) {
                genres { name }
                label { name }
            }
        }
    """

    SAMPLE_IMAGES_PROBE_QUERY = """
        query ProbeSampleImages($id: ID!) {
            ppvContent(id: $id) {
                sampleImages { imageUrl }
            }
        }
    """

    # GraphQL schema error patterns — 不同實作回傳的訊息格式不同
    SCHEMA_ERROR_PATTERNS = ('Unknown field', 'Cannot query field')

    def __init__(self, config: Optional[ScraperConfig] = None):
        super().__init__(config)
        self._session = requests.Session()
        self._session.headers.update({
            'User-Agent': self.config.user_agent,
            'Content-Type': 'application/json',
            'Accept': 'application/json',
        })
        if self.config.proxy_url:
            self._session.proxies = {
                'http': self.config.proxy_url,
                'https': self.config.proxy_url,
            }
        else:
            # direct 模式：明確不走任何 proxy（包括環境變數）
            self._session.trust_env = False

    def _get_source_name(self) -> str:
        return "dmm"

    def _probe_genres(self, content_id: str) -> tuple[list[str], str]:
        """
        探測 ppvContent 是否支援 genres/label 欄位。

        三態 cache 控制：
        - _genres_supported is False → 立即回傳空（永久跳過）
        - _genres_supported is True  → 仍查詢（該片可能有 tags）
        - _genres_supported is None  → 首次查詢，依結果更新 cache

        Returns:
            (tags, label) — 探測失敗時回傳 ([], '')
        """
        global _genres_supported

        # 已確認 schema 不支援 → 永久跳過
        if _genres_supported is False:
            return [], ''

        try:
            payload = {
                'query': self.GENRES_PROBE_QUERY,
                'variables': {'id': content_id}
            }
            resp = self._session.post(self.API_URL, json=payload, timeout=5)

            if resp.status_code != 200:
                # HTTP 錯誤 → 暫時性失敗，維持 None
                return [], ''

            resp_json = resp.json()
            errors = resp_json.get('errors', [])

            # 判定 1：schema error（unknown field / validation error）→ 確認不支援
            if any(
                any(pat in (e.get('message', '') or '') for pat in self.SCHEMA_ERROR_PATTERNS)
                for e in errors
            ):
                _genres_supported = False
                logger.info("[DMM] GraphQL schema 不支援 genres，已永久停用 probe")
                return [], ''

            # 判定 2：GraphQL 錯誤但非 schema error → 暫時性，維持 None
            data = resp_json.get('data') or {}
            item = data.get('ppvContent')

            if item is None:
                # content_id 不存在或其他 null，無法判定 → 維持 None
                return [], ''

            # 判定 3：正常回應 → schema 支援（即使此片 tags 為空）
            _genres_supported = True
            genres = item.get('genres') or []
            tags = [g['name'] for g in genres if g.get('name')]
            label = (item.get('label') or {}).get('name', '')
            return tags, label

        except Exception:
            # 網路錯誤、timeout → 暫時性失敗，維持 None（不設 False）
            return [], ''

    def _probe_sample_images(self, content_id: str) -> list[str]:
        """
        探測 ppvContent 是否支援 sampleImages 欄位。

        獨立於 genres/label probe，避免互相干擾。
        三態 cache 控制同 _probe_genres()。
        """
        global _sample_images_supported

        if _sample_images_supported is False:
            return []

        try:
            payload = {
                'query': self.SAMPLE_IMAGES_PROBE_QUERY,
                'variables': {'id': content_id}
            }
            resp = self._session.post(self.API_URL, json=payload, timeout=5)

            if resp.status_code != 200:
                return []

            resp_json = resp.json()
            errors = resp_json.get('errors', [])

            if any(
                any(pat in (e.get('message', '') or '') for pat in self.SCHEMA_ERROR_PATTERNS)
                for e in errors
            ):
                _sample_images_supported = False
                logger.info("[DMM] GraphQL schema 不支援 sampleImages，已永久停用 probe")
                return []

            data = resp_json.get('data') or {}
            item = data.get('ppvContent')

            if item is None:
                return []

            _sample_images_supported = True
            raw_samples = item.get('sampleImages') or []
            return [re.sub(r'(?<!jp)-(\d+)\.jpg$', r'jp-\1.jpg', s['imageUrl']) for s in raw_samples if s.get('imageUrl')]

        except Exception:
            return []

    def _fetch_tags_from_html(self, content_id: str) -> list[str]:
        """
        從 DMM 商品頁 HTML 抓取 genres（ジャンル）。
        使用同一 session（已設定 proxy），傳 age_check_done=1 cookie 繞過年齡驗證。

        兩種解析策略：
        1. JSON-LD VideoObject.genre（較快）
        2. XPath ジャンル 欄（備援）

        Returns:
            tags list（失敗時回傳 []，不 raise）
        """
        url = f"https://www.dmm.co.jp/digital/videoa/-/detail/=/cid={content_id}/"
        try:
            resp = self._session.get(
                url,
                timeout=self.config.timeout,
                cookies={"age_check_done": "1"}
            )
            if resp.status_code != 200:
                return []

            from lxml import etree
            import json as _json

            html = etree.fromstring(resp.content, etree.HTMLParser())

            # 方法 1: JSON-LD VideoObject.genre
            for script in html.xpath('//script[@type="application/ld+json"]/text()'):
                try:
                    ld = _json.loads(script)
                    if isinstance(ld, dict) and ld.get('@type') == 'VideoObject':
                        genre = ld.get('genre')
                        if genre and isinstance(genre, list):
                            return [g for g in genre if isinstance(g, str)]
                except _json.JSONDecodeError:
                    continue

            # 方法 2: XPath ジャンル 表格欄
            tags = html.xpath(
                '//th[contains(.//text(),"ジャンル")]/following-sibling::td//a/text()'
            )
            return [t.strip() for t in tags if t.strip()]

        except Exception:
            return []

    # ========== 快取管理 ==========

    def _load_json(self, path: Path) -> dict:
        """讀取 JSON，檔案不存在返回空 dict"""
        if path.exists():
            try:
                return json.loads(path.read_text(encoding='utf-8'))
            except (json.JSONDecodeError, IOError):
                return {}
        return {}

    def _save_json(self, path: Path, data: dict):
        """儲存 JSON"""
        try:
            path.write_text(
                json.dumps(data, indent=2, ensure_ascii=False),
                encoding='utf-8'
            )
        except IOError:
            pass

    def _load_cache(self) -> dict:
        """讀取 content_id 快取"""
        return self._load_json(CACHE_FILE)

    def _save_cache(self, number: str, content_id: str):
        """儲存到 content_id 快取"""
        cache = self._load_cache()
        cache[number.upper()] = content_id
        self._save_json(CACHE_FILE, cache)

    def _load_prefix_hints(self) -> dict:
        """讀取前綴映射"""
        return self._load_json(PREFIX_FILE)

    def _save_prefix_hint(self, prefix: str, dmm_prefix: str):
        """儲存新學習的前綴映射"""
        hints = self._load_prefix_hints()
        hints[prefix.lower()] = dmm_prefix
        self._save_json(PREFIX_FILE, hints)

    # ========== content_id 轉換 ==========

    def _parse_number(self, number: str) -> tuple[str, str]:
        """
        解析番號，返回 (前綴, 數字)

        Examples:
            SONE-205 → ("sone", "205")
            STARS-804 → ("stars", "804")
        """
        number = number.upper().strip()
        match = re.match(r'^([A-Z]+)-?(\d+)$', number)
        if match:
            return match.group(1).lower(), match.group(2)
        return "", ""

    def _convert_with_hints(self, number: str) -> str:
        """
        用前綴映射轉換番號

        Examples:
            SONE-205 + hints={} → sone00205
            STARS-804 + hints={"stars": "1"} → 1stars00804
        """
        prefix, num = self._parse_number(number)
        if not prefix or not num:
            return ""

        # 數字補零到 5 位
        num_padded = num.zfill(5)

        # 查前綴映射
        hints = self._load_prefix_hints()
        dmm_prefix = hints.get(prefix, "")

        return f"{dmm_prefix}{prefix}{num_padded}"

    def _learn_prefix(self, number: str, content_id: str):
        """
        從成功的 content_id 學習前綴映射

        Examples:
            number=STARS-804, content_id=1stars00804
            → 學習到 stars → "1"
        """
        prefix, _ = self._parse_number(number)
        if not prefix:
            return

        # content_id 格式：{dmm_prefix}{prefix}{num_padded}
        # 例如：1stars00804
        # 找出 dmm_prefix
        idx = content_id.lower().find(prefix)
        if idx > 0:
            dmm_prefix = content_id[:idx]
            # 儲存學習到的映射
            self._save_prefix_hint(prefix, dmm_prefix)

    def _content_id_to_number(self, content_id: str) -> str:
        """
        從 content_id 推導標準番號格式。

        DMM content_id 固定 5 位數字零補位（zfill(5)）。
        反向推導時 strip leading zeros 但保留至少 3 位數字。

        Examples:
            sone00205   → SONE-205
            1stars00804 → STARS-804
            ssni00001   → SSNI-001
            ofje00709   → OFJE-709
            abp01234    → ABP-1234
        """
        m = re.match(r'^(\d*)([a-z]+)(\d+)$', content_id.lower())
        if m:
            alpha = m.group(2).upper()
            num = m.group(3)
            # Strip leading zeros but keep at least 3 digits
            stripped = num.lstrip('0') or '0'
            if len(stripped) < 3 and len(num) >= 3:
                stripped = num[-3:]
            return f"{alpha}-{stripped}"
        return content_id

    def _search_content_id(self, number: str) -> Optional[str]:
        """
        用搜索 API 查找正確的 content_id（MDCX 方法）
        """
        query_word = number.upper().replace('-', '')
        prefix, _ = self._parse_number(number)

        if not prefix:
            return None

        try:
            payload = {
                'query': self.SEARCH_QUERY,
                'variables': {
                    'limit': 5,
                    'sort': 'RELEASE_DATE',
                    'queryWord': query_word
                }
            }
            resp = self._session.post(self.API_URL, json=payload, timeout=10)

            if resp.status_code != 200:
                return None

            data = resp.json()
            if not data.get('data') or not data['data'].get('legacySearchPPV'):
                return None

            contents = data['data']['legacySearchPPV']['result']['contents']
            if not contents:
                return None

            # 找包含番號前綴的結果
            for content in contents:
                cid = content['id']
                if prefix in cid.lower():
                    return cid

            # 沒找到匹配的，返回第一個
            return contents[0]['id']

        except Exception:
            return None

    def _fetch_by_id(self, content_id: str) -> Optional[Video]:
        """用 content_id 取得影片詳細資訊"""
        if not content_id:
            return None

        try:
            payload = {
                'query': self.DETAIL_QUERY,
                'variables': {'id': content_id}
            }

            response = self._session.post(
                self.API_URL,
                json=payload,
                timeout=self.config.timeout
            )

            if response.status_code != 200:
                return None

            data = response.json()

            if not data.get('data') or not data['data'].get('ppvContent'):
                return None

            item = data['data']['ppvContent']

            actresses = [
                Actress(name=a['name'])
                for a in item.get('actresses', [])
            ]

            release_date = item.get('makerReleasedAt', '')
            if release_date and 'T' in release_date:
                release_date = release_date.split('T')[0]

            # T5a: GraphQL probe → T5b: HTML fallback
            tags, label = self._probe_genres(content_id)
            if not tags:
                tags = self._fetch_tags_from_html(content_id)

            sample_images = self._probe_sample_images(content_id)

            # 新欄位提取
            directors_list = item.get('directors') or []
            director = directors_list[0]['name'] if directors_list else ''

            raw_duration = item.get('duration')
            duration = raw_duration // 60 if raw_duration is not None else None

            series = (item.get('series') or {}).get('name', '')

            video = Video(
                number=item.get('makerContentId', ''),
                title=item.get('title', ''),
                actresses=actresses,
                date=release_date,
                maker=item.get('maker', {}).get('name', ''),
                cover_url=item.get('packageImage', {}).get('largeUrl', ''),
                tags=tags,
                source=self.source_name,
                detail_url=f"https://www.dmm.co.jp/digital/videoa/-/detail/=/cid={content_id}/",
                director=director,
                duration=duration,
                label=label,
                series=series,
                sample_images=sample_images,
            )

            return video

        except requests.Timeout:
            raise TimeoutError(f"DMM API timeout for {content_id}")
        except Exception:
            return None

    # ========== 主要搜尋方法 ==========

    def search(self, number: str) -> Optional[Video]:
        """
        搜尋影片資訊

        流程：
        1. 查快取 → 有就直接用（最快）
        2. 用前綴映射轉換 → 嘗試查詢（快）
        3. 搜索 API 發現 → 學習前綴（慢，但只需一次）
        4. 都失敗 → 返回 None

        Args:
            number: 番號（如 SONE-205）

        Returns:
            Video 物件，找不到返回 None
        """
        # 正規化番號
        number = self.normalize_number(number)
        number_upper = number.upper()

        # 不支援 FC2
        if 'FC2' in number_upper:
            return None

        # 1. 查快取（最快）
        cache = self._load_cache()
        if number_upper in cache:
            cached_cid = cache[number_upper]
            result = self._fetch_by_id(cached_cid)
            if result:
                rate_limit(self.config.delay)
                return result

        # 2. 用前綴映射轉換（快）
        converted_cid = self._convert_with_hints(number)
        if converted_cid:
            result = self._fetch_by_id(converted_cid)
            if result:
                self._save_cache(number, converted_cid)
                rate_limit(self.config.delay)
                return result

        # 3. 搜索 API 發現（慢，但會學習）
        discovered_cid = self._search_content_id(number)
        if discovered_cid:
            result = self._fetch_by_id(discovered_cid)
            if result:
                self._save_cache(number, discovered_cid)
                self._learn_prefix(number, discovered_cid)  # 學習新前綴
                rate_limit(self.config.delay)
                return result

        # 4. 完全失敗
        return None

    def search_by_keyword_with_ids(self, keyword: str, limit: int = 20, offset: int = 0) -> list[tuple[str, Video]]:
        """
        關鍵字搜尋（輕量版）— 回傳 (content_id, shallow_Video) tuples。
        供 facade 層 ThreadPoolExecutor enrichment 使用。
        不呼叫 _fetch_by_id（不做 enrichment）。
        """
        try:
            payload = {
                'query': self.SEARCH_LIST_QUERY,
                'variables': {
                    'limit': limit,
                    'offset': offset,
                    'sort': 'RELEASE_DATE',
                    'queryWord': keyword,
                }
            }
            response = self._session.post(
                self.API_URL,
                json=payload,
                timeout=self.config.timeout,
            )

            if response.status_code != 200:
                return []

            data = response.json()
            if not data.get('data') or not data['data'].get('legacySearchPPV'):
                return []

            contents = data['data']['legacySearchPPV']['result']['contents']
            if not contents:
                return []

            pairs = []
            for item in contents:
                content_id = item.get('id', '')
                if not content_id:
                    continue
                actresses = [
                    Actress(name=a['name'])
                    for a in (item.get('actresses') or [])
                    if a.get('name')
                ]
                video = Video(
                    number=self._content_id_to_number(content_id),
                    title=item.get('title', ''),
                    actresses=actresses,
                    maker=(item.get('maker') or {}).get('name', ''),
                    cover_url=(item.get('packageImage') or {}).get('largeUrl', ''),
                    source=self.source_name,
                    detail_url=f"https://www.dmm.co.jp/digital/videoa/-/detail/=/cid={content_id}/",
                )
                pairs.append((content_id, video))

            return pairs

        except Exception:
            return []

    def search_by_keyword(self, keyword: str, limit: int = 20, offset: int = 0) -> list[Video]:
        """關鍵字搜尋（女優名、片商名等日文關鍵字）"""
        try:
            payload = {
                'query': self.SEARCH_LIST_QUERY,
                'variables': {
                    'limit': limit,
                    'offset': offset,
                    'sort': 'RELEASE_DATE',
                    'queryWord': keyword,
                }
            }
            response = self._session.post(
                self.API_URL,
                json=payload,
                timeout=self.config.timeout,
            )

            if response.status_code != 200:
                return []

            data = response.json()
            if not data.get('data') or not data['data'].get('legacySearchPPV'):
                return []

            contents = data['data']['legacySearchPPV']['result']['contents']
            if not contents:
                return []

            results = []
            for item in contents:
                content_id = item.get('id', '')
                if not content_id:
                    continue

                # Enrichment: 逐筆 _fetch_by_id 取得完整 Video
                try:
                    video = self._fetch_by_id(content_id)
                except Exception:
                    video = None
                if video is None:
                    # Fallback: 從搜尋結果建構 shallow Video
                    actresses = [
                        Actress(name=a['name'])
                        for a in (item.get('actresses') or [])
                        if a.get('name')
                    ]
                    video = Video(
                        number=self._content_id_to_number(content_id),
                        title=item.get('title', ''),
                        actresses=actresses,
                        maker=(item.get('maker') or {}).get('name', ''),
                        cover_url=(item.get('packageImage') or {}).get('largeUrl', ''),
                        source=self.source_name,
                        detail_url=f"https://www.dmm.co.jp/digital/videoa/-/detail/=/cid={content_id}/",
                    )
                results.append(video)
                rate_limit(self.config.delay)

            return results

        except Exception:
            return []
