import sys
import os
import re
import json
import time
import threading
from calendar import monthrange
from queue import Queue, Empty
from datetime import date

from curl_cffi import requests
from lxml import etree


URL_BASE = 'https://www.daysoftheyear.com/days/{m}/{d:02d}/{y}/'
MONTH_ABBR = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sept', 'oct', 'nov', 'dec']


def _normalize_to_target_year(date_start, date_end, target_year):
    """把日期年份归一化到目标年。
    站点对未来年页面的卡片 datetime 仍标历史年，
    策略：让 end.year == target_year（保持 end-start 的相对年差，end 对齐到目标年）。
    例：站点 2025-11-29 ~ 2026-01-07（Zagreb Advent 在 2026 年 1 月结束）
        target=2027 → 2026-11-29 ~ 2027-01-07（2027 年 1 月仍在过的 Advent）
    """
    def _parse_year(s):
        if not s or len(s) < 4:
            return None
        try:
            return int(s[:4])
        except Exception:
            return None

    def _replace_year(s, y):
        if not s or len(s) < 4:
            return s
        return f'{y}{s[4:]}'

    if not date_start:
        return date_start, date_end
    ys = _parse_year(date_start)
    ye = _parse_year(date_end) if date_end else ys
    if ys is None:
        return date_start, date_end
    # 以 end 年对齐 target_year（保持原数据的"跨年偏移"）
    anchor = ye if ye is not None else ys
    offset = target_year - anchor
    new_ds = _replace_year(date_start, ys + offset)
    new_de = _replace_year(date_end, (ye if ye is not None else ys) + offset) if date_end else new_ds
    return new_ds, new_de


def _parse_card_dates(c):
    """从卡片提取 (date_start, date_end)
    daily: 2027-01-01 / 2027-01-01
    weekly 同月范围: 2026-10-25 / 2026-10-31
    weekly 跨月范围: 2026-10-26 / 2026-11-01
    monthly: 2026-10-01 / 2026-10-31
    """
    times = c.xpath(".//p[contains(@class,'card__date')]//time")
    if not times:
        return '', ''
    if len(times) >= 2:  # 跨月范围（两个独立的 time 标签）
        return times[0].get('datetime', ''), times[1].get('datetime', '')
    t = times[0]
    dt = t.get('datetime', '')
    day_text = ''.join(t.xpath(".//span[contains(@class,'date-pill__day')]/text()")).strip()
    m_range = re.match(r'(\d+)\s*[–\-]\s*(\d+)', day_text)
    if m_range and re.match(r'^\d{4}-\d{2}-\d{2}$', dt):  # 同月范围
        y, mo, _ = dt.split('-')
        return f'{y}-{mo}-{int(m_range.group(1)):02d}', f'{y}-{mo}-{int(m_range.group(2)):02d}'
    if re.match(r'^\d{4}-\d{2}$', dt):  # 月份格式
        y, mo = dt.split('-')
        last_day = monthrange(int(y), int(mo))[1]
        return f'{y}-{mo}-01', f'{y}-{mo}-{last_day:02d}'
    return dt, dt  # 单日


def parse_day_page(html, page_date):
    """从单天 HTML 解析出 3 类活动：daily / weekly / monthly"""
    tree = etree.HTML(html)
    if tree is None:
        return {'daily': [], 'weekly': [], 'monthly': []}
    result = {'daily': [], 'weekly': [], 'monthly': []}

    def _extract_card(c, section_type):
        # 兼容 hero 卡（h2.card__title）和普通卡（h3）
        name_raw = c.xpath(".//h3//a/text() | .//h3/text() | "
                           ".//h2[contains(@class,'card__title')]//a/text() | "
                           ".//h2[contains(@class,'card__title')]/text()")
        # 清理零宽字符 / 不可见空白
        _clean = ''.join(name_raw)
        for _cp in (0x200B, 0x200C, 0x200D, 0xFEFF, 0x00A0):
            _clean = _clean.replace(chr(_cp), '')
        name = [_clean.strip()]
        url = c.xpath(".//h3//a/@href | .//h2[contains(@class,'card__title')]//a/@href")
        img = c.xpath(".//div[contains(@class,'card__media')]//img/@src")
        if section_type == 'daily':
            # daily 用 URL 当天日期：站点把活动列在哪个单天页 = 那天庆祝
            # 卡片自带 datetime 是"上一次实例"，不准（如 Guru Gobind 标 2025-12-27 但实际 2027 年是 1/15）
            date_start = page_date.isoformat()
            date_end = page_date.isoformat()
        else:
            # weekly 卡片自带准确日期范围，归一化到 URL 年
            date_start, date_end = _parse_card_dates(c)
            date_start, date_end = _normalize_to_target_year(date_start, date_end, page_date.year)
            if not date_start:
                date_start = page_date.isoformat()
            if not date_end:
                date_end = page_date.isoformat()
        return {
            'name': ''.join(name).strip(),
            'url': url[0] if url else '',
            'img': img[0] if img else '',
            'date_start': date_start,
            'date_end': date_end,
            'type': section_type,
        }

    # 1) Hero 卡（页面顶部"今日推荐"独立 section，归入 daily）
    hero_cards = tree.xpath("//section[contains(@class,'cards-grid--hero')]//article")
    for c in hero_cards:
        item = _extract_card(c, 'daily')
        if item['name']:
            result['daily'].append(item)

    # 2) 3 个 H2 章节下的 fixed-aspect 卡
    section_map = [
        ('daily',   'National days and holidays on'),
        ('weekly',  'Other holidays on'),
        ('monthly', 'Month-long holidays'),
    ]
    for section_type, h2_keyword in section_map:
        cards = tree.xpath(
            f"//h2[contains(., '{h2_keyword}')]/ancestor::section[1]"
            f"//article[contains(@class,'card--fixed-aspect')]"
        )
        for c in cards:
            item = _extract_card(c, section_type)
            if item['name']:
                result[section_type].append(item)
    return result


def fetch_one_day(year, month_abbr, day):
    """抓单天页面"""
    url = URL_BASE.format(m=month_abbr, d=day, y=year)
    for attempt in range(3):
        try:
            resp = requests.get(url, timeout=30, impersonate="chrome")
            if resp.status_code == 200:
                return parse_day_page(
                    resp.text,
                    date(year, MONTH_ABBR.index(month_abbr) + 1, day),
                )
            print(f'[WARN] {url} status={resp.status_code}, retry {attempt+1}/3')
        except Exception as e:
            print(f'[ERROR] {url} attempt {attempt+1}/3: {e}')
        time.sleep(2)
    return None


def parse_month_page(html, year, month_idx):
    """从月页 HTML 解析完整 Monthly 节日列表"""
    tree = etree.HTML(html)
    if tree is None:
        return []
    month_first = date(year, month_idx, 1)
    cards = tree.xpath(
        "//h2[contains(., 'Month-long holidays')]/ancestor::section[1]"
        "//article[contains(@class,'card')]"
    )
    items = []
    seen_urls = set()
    for c in cards:
        name_raw = c.xpath(".//h3//a/text() | .//h3/text() | "
                           ".//h2[contains(@class,'card__title')]//a/text()")
        _clean_n = ''.join(name_raw)
        for _cp in (0x200B, 0x200C, 0x200D, 0xFEFF, 0x00A0):
            _clean_n = _clean_n.replace(chr(_cp), '')
        name_clean = _clean_n.strip()
        url = c.xpath(".//h3//a/@href | .//h2[contains(@class,'card__title')]//a/@href")
        img = c.xpath(".//div[contains(@class,'card__media')]//img/@src")
        if not url:
            continue
        if url[0] in seen_urls:
            continue
        seen_urls.add(url[0])
        date_start, date_end = _parse_card_dates(c)
        date_start, date_end = _normalize_to_target_year(date_start, date_end, year)
        items.append({
            'name': name_clean,
            'url': url[0],
            'img': img[0] if img else '',
            'date_start': date_start or month_first.isoformat(),
            'date_end': date_end or month_first.isoformat(),
            'type': 'monthly',
        })
    return items


def fetch_one_month(year, month_abbr):
    """抓月页，解析完整 Monthly 节日"""
    url = f'https://www.daysoftheyear.com/days/{month_abbr}/{year}/'
    month_idx = MONTH_ABBR.index(month_abbr) + 1
    for attempt in range(3):
        try:
            resp = requests.get(url, timeout=30, impersonate="chrome")
            if resp.status_code == 200:
                return parse_month_page(resp.text, year, month_idx)
            print(f'[WARN] {url} status={resp.status_code}, retry {attempt+1}/3')
        except Exception as e:
            print(f'[ERROR] {url} attempt {attempt+1}/3: {e}')
        time.sleep(2)
    return []


def _month_days(year, month_idx_1based):
    """计算某月天数"""
    if month_idx_1based == 12:
        return 31
    return (date(year, month_idx_1based + 1, 1) - date(year, month_idx_1based, 1)).days


def crawl(year, month_list, thread_num=10):
    """多线程抓取指定月份：
    1) 月页抓 Monthly 完整列表（每月 1 次）
    2) 单天页抓 Daily / Weekly（每天 1 次）
    """
    all_results = {'daily': [], 'weekly': [], 'monthly_by_month': {}}

    # ===== 阶段 1：抓月页拿 Monthly =====
    print(f'\n阶段 1：抓 {len(month_list)} 个月页拿完整 Monthly')
    for m_abbr in month_list:
        items = fetch_one_month(year, m_abbr)
        all_results['monthly_by_month'][m_abbr] = items
        print(f'  {year}-{m_abbr}  monthly={len(items)} 个')
        time.sleep(0.3)

    # ===== 阶段 2：抓单天页拿 Daily / Weekly =====
    tasks = Queue()
    for m_abbr in month_list:
        m_idx = MONTH_ABBR.index(m_abbr) + 1
        days = _month_days(year, m_idx)
        for d in range(1, days + 1):
            tasks.put((year, m_abbr, d))
    total_tasks = tasks.qsize()
    print(f'\n阶段 2：抓 {total_tasks} 天单页拿 Daily/Weekly（{thread_num} 线程）')

    weekly_seen_urls = set()
    lock = threading.Lock()
    done_count = [0]

    def worker():
        while True:
            try:
                y, m, d = tasks.get(block=False)
            except Empty:
                break
            res = fetch_one_day(y, m, d)
            with lock:
                done_count[0] += 1
                progress = done_count[0]
                if res:
                    all_results['daily'].extend(res['daily'])
                    for it in res['weekly']:
                        if it['url'] not in weekly_seen_urls:
                            weekly_seen_urls.add(it['url'])
                            all_results['weekly'].append(it)
            dly = len(res['daily']) if res else 0
            wky = len(res['weekly']) if res else 0
            print(f'[{progress}/{total_tasks}] {y}-{m}-{d:02d}  daily={dly}  weekly={wky}')
            time.sleep(0.3)

    threads = [threading.Thread(target=worker) for _ in range(thread_num)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return all_results


def print_and_save(results, year, month_list, out_file):
    """打印汇总 + 写 JSON 文件"""
    # 先写文件（保证文件总能写入，不被后面打印的 unicode 编码异常打断）
    os.makedirs(os.path.dirname(out_file), exist_ok=True) if os.path.dirname(out_file) else None
    with open(out_file, 'w', encoding='utf-8') as f:
        json.dump(results, f, ensure_ascii=False, indent=2)

    print(f'\n{"=" * 60}')
    print(f'抓取完成：{year} 年 {",".join(month_list)} 月')
    print(f'Daily 总条目：{len(results["daily"])}')
    print(f'Weekly 总条目：{len(results["weekly"])}')
    print(f'Monthly 月数：{len(results["monthly_by_month"])}')
    print(f'{"=" * 60}')

    print('\n=== Daily 前 20 条 ===')
    daily_sorted = sorted(results['daily'], key=lambda x: (x['date_start'], x['name']))
    for it in daily_sorted[:20]:
        print(f"  {it['date_start']}  {it['name']:<40} {it['url']}")

    print('\n=== Weekly 前 20 条 ===')
    weekly_sorted = sorted(results['weekly'], key=lambda x: (x['date_start'], x['name']))
    for it in weekly_sorted[:20]:
        rng = f"{it['date_start']}~{it['date_end']}"
        print(f"  {rng:23} {it['name']:<40} {it['url']}")

    print('\n=== Monthly（按月全部）===')
    for m in month_list:
        items = results['monthly_by_month'].get(m, [])
        print(f'\n--- {m.upper()} 共 {len(items)} 个 ---')
        for it in items:
            rng = f"{it['date_start']}~{it['date_end']}"
            try:
                print(f"  {rng:23} {it['name']:<40} {it['url']}")
            except UnicodeEncodeError:
                print(f"  {rng:23} {it['name'].encode('ascii','replace').decode():<40} {it['url']}")

    # 先写文件，再打印（防打印异常导致文件没写）
    os.makedirs(os.path.dirname(out_file), exist_ok=True) if os.path.dirname(out_file) else None
    with open(out_file, 'w', encoding='utf-8') as f:
        json.dump(results, f, ensure_ascii=False, indent=2)
    try:
        print(f'\n完整数据已写入：{out_file}')
    except UnicodeEncodeError:
        pass


if __name__ == '__main__':
    YEAR = 2027
    # 全年 12 月
    MONTH_LIST = MONTH_ABBR
    OUT_FILE = rf'C:\Users\ASUS\daysoftheyear_{YEAR}_full.json'

    print(f'开始抓取 {YEAR} 年 {",".join(MONTH_LIST)} 月节日 ...')
    t0 = time.time()
    results = crawl(YEAR, MONTH_LIST, thread_num=10)
    print(f'\n抓取耗时 {time.time() - t0:.1f}s')
    print_and_save(results, YEAR, MONTH_LIST, OUT_FILE)
