"""
Forex Factory calendar service.
- CSV bulk import from forex_factory_cache.csv (2007-2025)
- Live sync from nfs.faireconomy.media JSON endpoint (this week)
- HTML scraper for upcoming weeks (forexfactory.com/calendar?week=...)
"""
import csv
import json
import logging
from datetime import datetime, date, timezone, timedelta
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo

import httpx
from bs4 import BeautifulSoup

logger = logging.getLogger(__name__)

_ET = ZoneInfo("America/New_York")   # FF publishes times in US Eastern

_FF_THIS_WEEK_URL = "https://nfs.faireconomy.media/ff_calendar_thisweek.json"
_FF_NEXT_WEEK_URL = "https://nfs.faireconomy.media/ff_calendar_nextweek.json"

# Map FF impact strings → normalized
_IMPACT_MAP = {
    "High Impact Expected": "high",
    "High":                 "high",
    "Medium Impact Expected": "medium",
    "Medium":               "medium",
    "Low Impact Expected":  "low",
    "Low":                  "low",
    "Non-Economic":         "non-economic",
    "Holiday":              "non-economic",
}

# Currencies we care about (drop 'All' pseudo-currency)
SUPPORTED_CURRENCIES = {"USD", "EUR", "GBP", "JPY", "AUD", "CAD", "NZD", "CHF", "CNY"}

# ── FF event name → FRED series_id mapping ────────────────────────────────────
# Exact FF event names → our FRED series catalog
FF_TO_FRED: dict[str, str] = {
    # Employment
    "Non-Farm Employment Change":       "PAYEMS",
    "ADP Non-Farm Employment Change":   "ADP_NFP",
    "Unemployment Rate":                "UNRATE",
    "Unemployment Claims":              "ICSA",
    # Inflation — CPI
    "CPI m/m":                          "CPIAUCSL",
    "CPI y/y":                          "CPIAUCSL",
    "Core CPI m/m":                     "CPILFESL",
    "Core CPI y/y":                     "CPILFESL",
    # Inflation — PCE
    "Core PCE Price Index m/m":         "PCEPILFE",
    "Core PCE Price Index y/y":         "PCEPILFE",
    # Monetary policy
    "Federal Funds Rate":               "FEDFUNDS",
    "FOMC Statement":                   "FEDFUNDS",
    # Growth — GDP
    "Advance GDP q/q":                  "GDPC1",
    "Prelim GDP q/q":                   "GDPC1",
    "Final GDP q/q":                    "GDPC1",
    "GDP q/q":                          "GDPC1",
}


def _parse_ff_datetime(dt_str: str) -> tuple[str, str]:
    """
    Parse an ISO datetime with offset (e.g. '2007-01-05T17:00:00+03:30')
    into (event_date YYYY-MM-DD UTC, event_time HH:MM UTC).
    """
    try:
        dt = datetime.fromisoformat(dt_str)
        dt_utc = dt.astimezone(timezone.utc)
        return dt_utc.strftime("%Y-%m-%d"), dt_utc.strftime("%H:%M")
    except Exception:
        # Fallback: take the date/time literally
        parts = dt_str[:16].split("T")
        return parts[0], parts[1] if len(parts) > 1 else "00:00"


def import_csv(csv_path: str, progress_cb=None) -> Dict[str, Any]:
    """
    Bulk-import forex_factory_cache.csv into ff_calendar table.
    Skips non-economic events and unsupported currencies.
    Returns {inserted, skipped, total}.
    """
    from services.database import get_conn

    conn = get_conn()
    inserted = 0
    skipped = 0
    total = 0

    try:
        with open(csv_path, encoding="utf-8", errors="replace") as f:
            reader = csv.DictReader(f)
            batch = []

            for row in reader:
                total += 1
                currency = (row.get("Currency") or "").strip()
                impact_raw = (row.get("Impact") or "").strip()
                impact = _IMPACT_MAP.get(impact_raw, "low")

                # Skip non-economic and unsupported currencies
                if currency not in SUPPORTED_CURRENCIES:
                    skipped += 1
                    continue
                if impact == "non-economic":
                    skipped += 1
                    continue

                event_name = (row.get("Event") or "").strip()
                if not event_name:
                    skipped += 1
                    continue

                dt_str = (row.get("DateTime") or "").strip()
                event_date, event_time = _parse_ff_datetime(dt_str)

                actual   = (row.get("Actual") or "").strip() or None
                forecast = (row.get("Forecast") or "").strip() or None
                previous = (row.get("Previous") or "").strip() or None
                detail   = (row.get("Detail") or "").strip() or None

                series_id = FF_TO_FRED.get(event_name)
                batch.append((
                    event_date, event_time, currency, impact, event_name,
                    actual, forecast, previous, series_id, detail, "ff_cache",
                ))

                if len(batch) >= 500:
                    _upsert_batch(conn, batch)
                    inserted += len(batch)
                    batch = []
                    if progress_cb:
                        progress_cb({"inserted": inserted, "total": total})

            if batch:
                _upsert_batch(conn, batch)
                inserted += len(batch)

        conn.commit()
        print(f"[FF import] Done: {inserted} inserted, {skipped} skipped, {total} total", flush=True)
        return {"inserted": inserted, "skipped": skipped, "total": total}
    except Exception as e:
        logger.error(f"[FF import] Failed: {e}")
        print(f"[FF import] ERROR: {e}", flush=True)
        return {"error": str(e)}
    finally:
        conn.close()


def _upsert_batch(conn, batch: list):
    conn.executemany(
        """INSERT INTO ff_calendar
           (event_date, event_time, currency, impact, event_name,
            actual_value, forecast_value, previous_value, series_id, detail, source)
           VALUES (?,?,?,?,?,?,?,?,?,?,?)
           ON CONFLICT(event_date, event_time, currency, event_name)
           DO UPDATE SET
             actual_value   = COALESCE(excluded.actual_value, ff_calendar.actual_value),
             forecast_value = COALESCE(excluded.forecast_value, ff_calendar.forecast_value),
             previous_value = COALESCE(excluded.previous_value, ff_calendar.previous_value),
             series_id      = COALESCE(excluded.series_id, ff_calendar.series_id),
             source         = excluded.source,
             fetched_at     = datetime('now')
        """,
        batch,
    )


def sync_live() -> Dict[str, Any]:
    """
    Fetch this week + next week from faireconomy.media and upsert into ff_calendar.
    Updates actual_value when it becomes available (real-time detection).
    """
    from services.database import get_conn

    results = {}
    conn = get_conn()

    for label, url in [("thisweek", _FF_THIS_WEEK_URL), ("nextweek", _FF_NEXT_WEEK_URL)]:
        try:
            resp = httpx.get(url, timeout=15, follow_redirects=True)
            if resp.status_code == 404:
                results[label] = {"status": "not_available"}
                continue
            resp.raise_for_status()
            events = resp.json()

            batch = []
            for ev in events:
                currency = (ev.get("country") or "").strip().upper()
                if currency not in SUPPORTED_CURRENCIES:
                    continue

                impact = _IMPACT_MAP.get(ev.get("impact") or "", "low")
                if impact == "non-economic":
                    continue

                event_name = (ev.get("title") or "").strip()
                if not event_name:
                    continue

                dt_str = ev.get("date") or ""
                event_date, event_time = _parse_ff_datetime(dt_str)

                actual   = (ev.get("actual")   or "").strip() or None
                forecast = (ev.get("forecast") or "").strip() or None
                previous = (ev.get("previous") or "").strip() or None

                series_id = FF_TO_FRED.get(event_name)
                batch.append((
                    event_date, event_time, currency, impact, event_name,
                    actual, forecast, previous, series_id, None, "ff_live",
                ))

            if batch:
                _upsert_batch(conn, batch)
                conn.commit()

            results[label] = {"status": "ok", "events": len(batch)}
            print(f"[FF sync] {label}: {len(batch)} events upserted", flush=True)

        except Exception as e:
            logger.warning(f"[FF sync] {label} failed: {e}")
            results[label] = {"status": "error", "error": str(e)}

    conn.close()
    return results


def get_calendar(
    period: str = "recent",
    date_from: Optional[str] = None,
    date_to: Optional[str] = None,
    currencies: Optional[List[str]] = None,
    impacts: Optional[List[str]] = None,
    limit: int = 300,
    offset: int = 0,
) -> Dict[str, Any]:
    """
    Query ff_calendar with period filter.

    period options:
      recent | today | tomorrow | yesterday | this_week | next_week |
      previous_week | this_month | next_month | previous_month | custom
    When period=custom, date_from and date_to are used directly.
    """
    from services.database import get_conn

    today = datetime.now(timezone.utc).date()
    monday = today - timedelta(days=today.weekday())
    sunday = monday + timedelta(days=6)

    # For custom period, date_from/date_to come from the caller — don't override
    if period != "custom":
        date_from = None
        date_to   = None

    if period == "custom":
        pass  # date_from / date_to already set by caller
    elif period == "today":
        date_from = date_to = str(today)
    elif period == "tomorrow":
        t = today + timedelta(days=1)
        date_from = date_to = str(t)
    elif period == "yesterday":
        t = today - timedelta(days=1)
        date_from = date_to = str(t)
    elif period == "this_week":
        date_from = str(monday)
        date_to   = str(sunday)
    elif period == "next_week":
        nm = monday + timedelta(weeks=1)
        date_from = str(nm)
        date_to   = str(nm + timedelta(days=6))
    elif period == "previous_week":
        pm = monday - timedelta(weeks=1)
        date_from = str(pm)
        date_to   = str(pm + timedelta(days=6))
    elif period == "this_month":
        date_from = str(today.replace(day=1))
        # last day of month
        if today.month == 12:
            last = today.replace(year=today.year + 1, month=1, day=1) - timedelta(days=1)
        else:
            last = today.replace(month=today.month + 1, day=1) - timedelta(days=1)
        date_to = str(last)
    elif period == "next_month":
        if today.month == 12:
            first = today.replace(year=today.year + 1, month=1, day=1)
        else:
            first = today.replace(month=today.month + 1, day=1)
        if first.month == 12:
            last = first.replace(year=first.year + 1, month=1, day=1) - timedelta(days=1)
        else:
            last = first.replace(month=first.month + 1, day=1) - timedelta(days=1)
        date_from = str(first)
        date_to   = str(last)
    elif period == "previous_month":
        if today.month == 1:
            first = today.replace(year=today.year - 1, month=12, day=1)
        else:
            first = today.replace(month=today.month - 1, day=1)
        last = today.replace(day=1) - timedelta(days=1)
        date_from = str(first)
        date_to   = str(last)
    else:  # recent: last 7 days + next 14 days
        date_from = str(today - timedelta(days=7))
        date_to   = str(today + timedelta(days=14))

    conn = get_conn()
    where: List[str] = []
    params: List[Any] = []

    if date_from:
        where.append("event_date >= ?")
        params.append(date_from)
    if date_to:
        where.append("event_date <= ?")
        params.append(date_to)

    if currencies:
        ph = ",".join("?" * len(currencies))
        where.append(f"currency IN ({ph})")
        params.extend(currencies)

    if impacts:
        ph = ",".join("?" * len(impacts))
        where.append(f"impact IN ({ph})")
        params.extend(impacts)

    where_sql = ("WHERE " + " AND ".join(where)) if where else ""

    count_sql = f"SELECT COUNT(*) FROM ff_calendar {where_sql}"
    data_sql  = (
        f"SELECT * FROM ff_calendar {where_sql} "
        f"ORDER BY event_date ASC, event_time ASC "
        f"LIMIT ? OFFSET ?"
    )

    try:
        total = conn.execute(count_sql, params).fetchone()[0]
        rows  = conn.execute(data_sql, params + [limit, offset]).fetchall()
        events = [dict(r) for r in rows]
        return {
            "total": total,
            "offset": offset,
            "limit": limit,
            "period": period,
            "date_from": date_from,
            "date_to":   date_to,
            "events": events,
        }
    finally:
        conn.close()


# ── FF HTML scraper for upcoming weeks ────────────────────────────────────────
#
# Forex Factory renders its calendar server-side; the HTML table is parseable
# without JavaScript.  Times are in US Eastern Time (ET).
#
# URL pattern: https://www.forexfactory.com/calendar?week=jun26.2026
# (any date in the week works; we use each Monday)

_FF_BASE = "https://www.forexfactory.com"

_FF_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/125.0.0.0 Safari/537.36"
    ),
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
    "Accept-Encoding": "gzip, deflate, br",
    "Connection": "keep-alive",
    "Upgrade-Insecure-Requests": "1",
    "Referer": "https://www.forexfactory.com/",
}

# FF impact icon CSS class suffix → our normalized impact label
_FF_IMPACT_CSS = {
    "red": "high",
    "ora": "medium",   # orange
    "yel": "low",      # yellow
    "gra": "non-economic",
}


def _week_url(monday: date) -> str:
    """Return FF calendar URL for the week containing 'monday'."""
    month = monday.strftime("%b").lower()   # "jun", "jul", …
    return f"{_FF_BASE}/calendar?week={month}{monday.day}.{monday.year}"


def _parse_et_time(time_str: str, ref_date: date) -> tuple[str, str]:
    """
    Parse an FF time string (e.g. '8:30am', 'Tentative', 'All Day')
    expressed in US Eastern Time and return (event_date_utc, event_time_utc).
    """
    s = (time_str or "").strip().lower()
    if not s or s in ("all day", "tentative", ""):
        return str(ref_date), "00:00"
    try:
        # FF uses "8:30am" / "12:45pm" format
        fmt = "%I:%M%p" if ":" in s else "%I%p"
        t = datetime.strptime(s, fmt)
        dt_et = datetime(
            ref_date.year, ref_date.month, ref_date.day,
            t.hour, t.minute, tzinfo=_ET,
        )
        dt_utc = dt_et.astimezone(timezone.utc)
        return dt_utc.strftime("%Y-%m-%d"), dt_utc.strftime("%H:%M")
    except Exception:
        return str(ref_date), "00:00"


def _parse_impact(impact_td) -> str:
    if impact_td is None:
        return "low"
    span = impact_td.find("span", class_=lambda c: c and "icon--ff-impact" in c)
    if span:
        cls = " ".join(span.get("class", []))
        for suffix, label in _FF_IMPACT_CSS.items():
            if suffix in cls:
                return label
    # Fallback: check title attribute
    title = (impact_td.get("title") or "").lower()
    if "high" in title:   return "high"
    if "medium" in title: return "medium"
    return "low"


def _fetch_via_flaresolverr(url: str) -> Optional[str]:
    """
    Fetch a page through FlareSolverr (a headless-browser proxy that solves Cloudflare's
    JS challenge) instead of a plain HTTP client. FF started 403'ing our direct requests
    — the response is Cloudflare's "Just a moment..." interstitial, which no amount of
    header/cookie spoofing can pass (confirmed: replaying a real logged-in session's
    cf_clearance cookie from a different IP still 403s — it's IP-bound). FlareSolverr runs
    as a sibling container (see deploy/docker-compose.yml) and is reached over the internal
    docker network; not configured (e.g. local dev without that container) → returns None
    so the caller falls back to the direct httpx path unchanged.
    """
    import os
    base = os.environ.get("FLARESOLVERR_URL", "http://flaresolverr:8191/v1")
    try:
        resp = httpx.post(
            base,
            json={"cmd": "request.get", "url": url, "maxTimeout": 60000},
            timeout=70,
        )
        resp.raise_for_status()
        data = resp.json()
        if data.get("status") != "ok":
            print(f"[FF scrape] FlareSolverr error for {url}: {data.get('message')}", flush=True)
            return None
        return data.get("solution", {}).get("response")
    except Exception as e:
        print(f"[FF scrape] FlareSolverr unavailable ({e}) — falling back to direct fetch", flush=True)
        return None


def _scrape_week(client: httpx.Client, monday: date) -> list[tuple]:
    """Fetch and parse one week of FF calendar. Returns list of row tuples."""
    url = _week_url(monday)
    print(f"[FF scrape] GET {url}", flush=True)

    html = _fetch_via_flaresolverr(url)
    if html is None:
        try:
            resp = client.get(url, timeout=20)
            resp.raise_for_status()
            html = resp.text
        except Exception as e:
            print(f"[FF scrape] fetch error {url}: {e}", flush=True)
            return []

    soup = BeautifulSoup(html, "lxml")
    table = soup.find("table", class_="calendar__table")
    if not table:
        # Try alternate selector (FF occasionally restructures)
        table = soup.find("table", attrs={"id": "calendar"})
    if not table:
        print(f"[FF scrape] table not found for {url}", flush=True)
        return []

    rows = table.find_all("tr", class_="calendar__row")
    batch: list[tuple] = []
    current_date: date = monday   # carry forward

    for tr in rows:
        # ── Date cell (only filled on day-breaker rows) ─────────────────
        date_td = tr.find("td", class_="calendar__date")
        if date_td and date_td.get_text(strip=True):
            raw = date_td.get_text(" ", strip=True)  # e.g. "Fri Jun 27"
            for fmt in ("%a %b %d", "%A %b %d", "%a %B %d"):
                try:
                    parsed = datetime.strptime(raw, fmt)
                    current_date = date(monday.year, parsed.month, parsed.day)
                    # Handle year wrap (Dec → Jan)
                    if current_date < monday - timedelta(days=6):
                        current_date = date(monday.year + 1, parsed.month, parsed.day)
                    break
                except ValueError:
                    continue

        # ── Time ────────────────────────────────────────────────────────
        time_td = tr.find("td", class_="calendar__time")
        time_str = time_td.get_text(strip=True) if time_td else ""

        # ── Currency ────────────────────────────────────────────────────
        cur_td = tr.find("td", class_="calendar__currency")
        currency = cur_td.get_text(strip=True).upper() if cur_td else ""
        if currency not in SUPPORTED_CURRENCIES:
            continue

        # ── Impact ──────────────────────────────────────────────────────
        impact_td = tr.find("td", class_="calendar__impact")
        impact = _parse_impact(impact_td)
        if impact == "non-economic":
            continue

        # ── Event name ──────────────────────────────────────────────────
        event_td = tr.find("td", class_="calendar__event")
        if not event_td:
            continue
        title_span = event_td.find("span", class_="calendar__event-title")
        event_name = (title_span or event_td).get_text(strip=True)
        if not event_name:
            continue

        # ── Values ──────────────────────────────────────────────────────
        actual   = _td_val(tr, "calendar__actual")
        forecast = _td_val(tr, "calendar__forecast")
        previous = _td_val(tr, "calendar__previous")

        # ── Convert time ET → UTC ────────────────────────────────────────
        ev_date, ev_time = _parse_et_time(time_str, current_date)

        series_id = FF_TO_FRED.get(event_name)
        batch.append((
            ev_date, ev_time, currency, impact, event_name,
            actual or None, forecast or None, previous or None,
            series_id, None, "ff_scrape",
        ))

    return batch


def _td_val(tr, cls: str) -> str:
    td = tr.find("td", class_=cls)
    if not td:
        return ""
    span = td.find("span")
    text = (span or td).get_text(strip=True)
    return text if text not in ("", "\xa0") else ""


def scrape_upcoming(weeks_ahead: int = 5) -> Dict[str, Any]:
    """
    Scrape FF calendar HTML for the next N weeks and upsert into ff_calendar.
    Includes the current week so we catch events added after the JSON sync.
    """
    from services.database import get_conn

    today_date = date.today()
    monday = today_date - timedelta(days=today_date.weekday())

    total_inserted = 0
    week_results = {}

    conn = get_conn()
    with httpx.Client(headers=_FF_HEADERS, follow_redirects=True, timeout=30) as client:
        # Warm up session — get cookies from homepage
        try:
            client.get(_FF_BASE, timeout=10)
        except Exception:
            pass

        for w in range(weeks_ahead):
            week_monday = monday + timedelta(weeks=w)
            batch = _scrape_week(client, week_monday)

            if batch:
                _upsert_batch(conn, batch)
                conn.commit()
                total_inserted += len(batch)

            week_results[str(week_monday)] = len(batch)
            print(f"[FF scrape] week {week_monday}: {len(batch)} events", flush=True)

    conn.close()
    print(f"[FF scrape] total upserted: {total_inserted}", flush=True)
    return {"weeks_scraped": weeks_ahead, "total_upserted": total_inserted, "by_week": week_results}


def sync_historical_range(from_date: date, to_date: Optional[date] = None) -> Dict[str, Any]:
    """
    Backfill ff_calendar for a historical date range by scraping FF HTML week by week.
    Skips weeks that already have events with actual_value populated (avoids redundant requests).
    Adds a small delay between requests to be respectful of the server.
    Returns progress stats.
    """
    import time
    from services.database import get_conn

    if to_date is None:
        to_date = date.today()

    # Align from_date to the Monday of its week
    monday_start = from_date - timedelta(days=from_date.weekday())
    monday_today = to_date - timedelta(days=to_date.weekday())

    # Collect all Mondays in range
    mondays: list[date] = []
    cur = monday_start
    while cur <= monday_today:
        mondays.append(cur)
        cur += timedelta(weeks=1)

    if not mondays:
        return {"weeks_scraped": 0, "total_upserted": 0, "skipped": 0, "by_week": {}}

    conn = get_conn()

    # Pre-check which weeks already have actual values to avoid redundant scraping
    # A week is "done" if it has ≥5 events with actual_value for supported currencies
    weeks_with_actuals: set[str] = set()
    for m in mondays:
        sunday = m + timedelta(days=6)
        cnt = conn.execute(
            """SELECT COUNT(*) FROM ff_calendar
               WHERE event_date >= ? AND event_date <= ?
                 AND actual_value IS NOT NULL""",
            (str(m), str(sunday))
        ).fetchone()[0]
        if cnt >= 5:
            weeks_with_actuals.add(str(m))

    total_inserted = 0
    skipped = 0
    week_results: Dict[str, Any] = {}

    with httpx.Client(headers=_FF_HEADERS, follow_redirects=True, timeout=30) as client:
        # Warm up session
        try:
            client.get(_FF_BASE, timeout=10)
            time.sleep(1.0)
        except Exception:
            pass

        for monday in mondays:
            monday_str = str(monday)
            if monday_str in weeks_with_actuals:
                week_results[monday_str] = {"status": "skipped", "count": 0}
                skipped += 1
                print(f"[FF backfill] {monday_str}: already populated, skipping", flush=True)
                continue

            batch = _scrape_week(client, monday)

            if batch:
                _upsert_batch(conn, batch)
                conn.commit()
                total_inserted += len(batch)
                week_results[monday_str] = {"status": "ok", "count": len(batch)}
            else:
                week_results[monday_str] = {"status": "empty", "count": 0}

            print(f"[FF backfill] {monday_str}: {len(batch)} events", flush=True)
            time.sleep(1.5)  # respectful delay between requests

    conn.close()
    total_weeks = len(mondays)
    print(f"[FF backfill] done: {total_inserted} events upserted, {skipped}/{total_weeks} weeks skipped", flush=True)
    return {
        "from_date":      str(from_date),
        "to_date":        str(to_date),
        "weeks_total":    total_weeks,
        "weeks_scraped":  total_weeks - skipped,
        "weeks_skipped":  skipped,
        "total_upserted": total_inserted,
        "by_week":        week_results,
    }
