# fflos_generator.py
#
# Usage:
#   python fflos_generator.py <REPORT_CODE> <FIGHT_ID> <TOP_LEVEL_LABEL> [--meta meta.json] [--debug]
#
# Env:
#   FFLOGS_CLIENT_ID=...
#   FFLOGS_CLIENT_SECRET=...
#
# Notes:
# - German names are fetched from XIVAPI (global client supports DE). :contentReference[oaicite:1]{index=1}
# - We request German via Accept-Language header (standard HTTP content negotiation). :contentReference[oaicite:2]{index=2}

from __future__ import annotations

import argparse
import json
import os
import sys
from typing import Any, Dict, Optional, Tuple

import requests

FFLOGS_TOKEN_URL = "https://www.fflogs.com/oauth/token"
FFLOGS_GQL_URL = "https://www.fflogs.com/api/v2/client"

XIVAPI_V2_BASE = "https://v2.xivapi.com"
XIVAPI_DE_HEADERS = {"Accept-Language": "de"}  # :contentReference[oaicite:3]{index=3}


def must_get_env(name: str) -> str:
    v = os.getenv(name)
    if not v:
        print(f"Missing env {name}", file=sys.stderr)
        sys.exit(2)
    return v


def to_hex_upper(n: Any) -> Optional[str]:
    if n is None:
        return None
    try:
        i = int(n)
    except Exception:
        return None
    if i < 0:
        return None
    return format(i, "X").upper()


def safe_read_json(path: Optional[str]) -> Dict[str, Any]:
    if not path:
        return {}
    if not os.path.exists(path):
        return {}
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def get_access_token(client_id: str, client_secret: str) -> str:
    data = {
        "grant_type": "client_credentials",
        "client_id": client_id,
        "client_secret": client_secret,
    }
    r = requests.post(
        FFLOGS_TOKEN_URL,
        data=data,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=30,
    )
    if not r.ok:
        raise RuntimeError(f"Token request failed: {r.status_code} {r.text}")
    j = r.json()
    tok = j.get("access_token")
    if not tok:
        raise RuntimeError("No access_token in token response")
    return tok


def gql(token: str, query: str, variables: Dict[str, Any]) -> Dict[str, Any]:
    r = requests.post(
        FFLOGS_GQL_URL,
        json={"query": query, "variables": variables},
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        timeout=60,
    )
    payload = r.json() if r.content else None
    if not r.ok:
        raise RuntimeError(f"GraphQL HTTP error: {r.status_code} {r.text}")
    if payload and payload.get("errors"):
        raise RuntimeError(f"GraphQL errors: {json.dumps(payload['errors'], ensure_ascii=False)}")
    return payload.get("data") if payload else {}


class XivApiNameResolver:
    """
    Resolve German names for Action/Status IDs via XIVAPI v2 sheets.
    Caches per (sheet, id).
    """

    def __init__(self, session: Optional[requests.Session] = None, debug: bool = False) -> None:
        self.sess = session or requests.Session()
        self.debug = debug
        self.cache: Dict[Tuple[str, int], Optional[str]] = {}

    def _get_sheet_name(self, sheet: str, row_id: int) -> Optional[str]:
        key = (sheet, row_id)
        if key in self.cache:
            return self.cache[key]

        url = f"{XIVAPI_V2_BASE}/api/sheet/{sheet}/{row_id}"
        params = {"fields": "Name"}
        try:
            r = self.sess.get(url, params=params, headers=XIVAPI_DE_HEADERS, timeout=20)
            if not r.ok:
                if self.debug:
                    print(f"[debug] XIVAPI {sheet}/{row_id} -> {r.status_code}", file=sys.stderr)
                self.cache[key] = None
                return None

            j = r.json()
            # v2 returns {"row_id":..., "fields":{"Name":"..."}} (example shown in docs) :contentReference[oaicite:4]{index=4}
            name = (j.get("fields") or {}).get("Name")
            if isinstance(name, str) and name.strip():
                self.cache[key] = name.strip()
                return self.cache[key]

            self.cache[key] = None
            return None
        except Exception as e:
            if self.debug:
                print(f"[debug] XIVAPI error {sheet}/{row_id}: {e}", file=sys.stderr)
            self.cache[key] = None
            return None

    def action_de(self, action_id: Any) -> Optional[str]:
        try:
            i = int(action_id)
        except Exception:
            return None
        return self._get_sheet_name("Action", i)

    def status_de(self, status_id: Any) -> Optional[str]:
        try:
            i = int(status_id)
        except Exception:
            return None
        return self._get_sheet_name("Status", i)


def ensure_enemy_node(encounter_obj: Dict[str, Any], enemy_name: str, enemy_id: Any, meta: Dict[str, Any]) -> Dict[str, Any]:
    if enemy_name not in encounter_obj:
        meta_c = (meta.get("combatants") or {}).get(str(enemy_id), {})
        encounter_obj[enemy_name] = {
            "id": str(enemy_id) if enemy_id is not None else "",
            "maxHP": meta_c.get("maxHP", None),
            "minHP": meta_c.get("minHP", None),
            "skill": {},
            "status": {},
        }
    return encounter_obj[enemy_name]


def apply_skill_agg(
    enemy_node: Dict[str, Any],
    ability_id_num: Any,
    fflogs_ability_name: str,
    amount: Optional[float],
    meta: Dict[str, Any],
    german_name: Optional[str] = None,
) -> None:
    hex_id = to_hex_upper(ability_id_num)
    if not hex_id:
        return

    meta_skill = (meta.get("skills") or {}).get(hex_id, {})
    skills = enemy_node.setdefault("skill", {})

    resolved_name = (
        meta_skill.get("name")
        or german_name
        or fflogs_ability_name
        or f"Ability {hex_id}"
    )

    if hex_id not in skills:
        skills[hex_id] = {
            "type_id": meta_skill.get("type_id", ""),
            "name": resolved_name,
            "damage_type": meta_skill.get("damage_type", "None"),
            "element": meta_skill.get("element", "None"),
        }

    if isinstance(amount, (int, float)):
        node = skills[hex_id]
        dmg = node.get("damage")
        if not isinstance(dmg, dict):
            node["damage"] = {"min": float(amount), "max": float(amount)}
        else:
            node["damage"]["min"] = float(min(dmg.get("min", amount), amount))
            node["damage"]["max"] = float(max(dmg.get("max", amount), amount))


def apply_status_seen(
    enemy_node: Dict[str, Any],
    status_id_num: Any,
    meta: Dict[str, Any],
    fflogs_status_name: Optional[str] = None,
    german_name: Optional[str] = None,
) -> None:
    hex_id = to_hex_upper(status_id_num)
    if not hex_id:
        return

    meta_status = (meta.get("statuses") or {}).get(hex_id, {})
    statuses = enemy_node.setdefault("status", {})

    if hex_id in statuses:
        return

    resolved_name = (
        meta_status.get("name")
        or german_name
        or fflogs_status_name
        or f"Status {hex_id}"
    )

    statuses[hex_id] = {
        "type_id": meta_status.get("type_id", "30"),
        "name": resolved_name,
        "icon": meta_status.get("icon", "ui/icon/000000/000000.tex"),
        "duration": meta_status.get("duration", [9999]),
    }


def get_report_fight_and_actors(
    token: str, report_code: str, fight_id: int
) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Dict[str, Any]]]:
    q = """
      query ($code: String!) {
        reportData {
          report(code: $code) {
            code
            zone { id name }
            fights { id startTime endTime name encounterID kill difficulty }
            masterData {
              actors {
                id
                name
                type
                subType
              }
            }
          }
        }
      }
    """
    data = gql(token, q, {"code": report_code})
    report = (((data or {}).get("reportData") or {}).get("report")) if data else None
    if not report:
        raise RuntimeError("Report not found (or no access).")

    fights = report.get("fights") or []
    fight = next((f for f in fights if f.get("id") == fight_id), None)
    if not fight:
        raise RuntimeError(f"Fight {fight_id} not found in report.")
    if fight.get("startTime") is None or fight.get("endTime") is None:
        raise RuntimeError("Fight missing startTime/endTime.")

    actors = (((report.get("masterData") or {}).get("actors")) or [])
    actor_map: Dict[str, Dict[str, Any]] = {}
    for a in actors:
        if a and a.get("id") is not None:
            actor_map[str(a["id"])] = a

    return report, fight, actor_map


def resolve_name(actor_map: Dict[str, Dict[str, Any]], actor_id: Any) -> Optional[str]:
    if actor_id is None:
        return None
    a = actor_map.get(str(actor_id))
    if not a:
        return None
    return a.get("name")


def is_player_like(actor_map: Dict[str, Dict[str, Any]], actor_id: Any) -> bool:
    a = actor_map.get(str(actor_id))
    if not a:
        return False
    t = (a.get("type") or "").lower()
    st = (a.get("subType") or "").lower()
    return "player" in t or "character" in t or st in {"player", "character"}


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("report_code")
    ap.add_argument("fight_id", type=int)
    ap.add_argument("top_label")
    ap.add_argument("--meta", default=None)
    ap.add_argument("--debug", action="store_true")
    args = ap.parse_args()

    meta = safe_read_json(args.meta)
    client_id = "a09d4cbc-4170-48dc-9c1e-bc8df6fb1e08"# must_get_env("FFLOGS_CLIENT_ID")
    client_secret = "pgtbarmg9K4W6WSJAOgZVZWhF2FA0nlLvoTKP0SK"#must_get_env("FFLOGS_CLIENT_SECRET")
    token = get_access_token(client_id, client_secret)

    report, fight, actor_map = get_report_fight_and_actors(token, args.report_code, args.fight_id)

    xiv = XivApiNameResolver(debug=args.debug)

    encounter_obj: Dict[str, Any] = {}
    out: Dict[str, Any] = {args.top_label: encounter_obj}

    if isinstance(meta.get("music"), list):
        encounter_obj["music"] = list(meta["music"])
    if isinstance(meta.get("contentzoneid"), list):
        encounter_obj["contentzoneid"] = list(meta["contentzoneid"])

    combatants_map: Dict[str, str] = encounter_obj.setdefault("combatants", {})

    q_events = """
      query ($code: String!, $start: Float!, $end: Float!) {
        reportData {
          report(code: $code) {
            events(startTime: $start, endTime: $end, limit: 10000) {
              data
              nextPageTimestamp
            }
          }
        }
      }
    """

    start = float(fight["startTime"])
    end = float(fight["endTime"])

    if args.debug:
        print(
            f"[debug] zone={((report.get('zone') or {}).get('name'))} fight={fight.get('name')} start={start} end={end}",
            file=sys.stderr,
        )
        print(f"[debug] actors={len(actor_map)}", file=sys.stderr)

    safety = 0
    total_events = 0

    while True:
        data = gql(token, q_events, {"code": args.report_code, "start": start, "end": end})
        events = (((data or {}).get("reportData") or {}).get("report") or {}).get("events") or {}
        batch = events.get("data") or []
        total_events += len(batch)

        for ev in batch:
            target_id = ev.get("targetID", ev.get("targetId"))
            source_id = ev.get("sourceID", ev.get("sourceId"))

            target_name = ev.get("targetName") or resolve_name(actor_map, target_id)
            source_name = ev.get("sourceName") or resolve_name(actor_map, source_id)

            if target_id is not None and target_name:
                combatants_map[str(target_id)] = str(target_name)
            if source_id is not None and source_name:
                combatants_map[str(source_id)] = str(source_name)

            # choose "enemy nodes"
            if target_id is None or not target_name:
                continue
            if is_player_like(actor_map, target_id):
                continue

            enemy_node = ensure_enemy_node(encounter_obj, str(target_name), target_id, meta)

            ability_obj = ev.get("ability") or {}
            ability_id = ev.get("abilityGameID") or ability_obj.get("gameID") or ability_obj.get("id")
            fflogs_ability_name = ability_obj.get("name") or ev.get("abilityName") or ""

            # damage-ish: amount + ability id
            amount = None
            if isinstance(ev.get("amount"), (int, float)):
                amount = ev["amount"]
            elif isinstance(ev.get("unmitigatedAmount"), (int, float)):
                amount = ev["unmitigatedAmount"]

            if ability_id is not None and amount is not None:
                german_action = xiv.action_de(ability_id)
                apply_skill_agg(
                    enemy_node,
                    ability_id,
                    str(fflogs_ability_name),
                    amount,
                    meta,
                    german_name=german_action,
                )

            # status-ish
            ev_type = ev.get("type")
            if ev_type in {"applybuff", "applydebuff", "refreshbuff", "refreshdebuff"}:
                status_id = (
                    ev.get("statusGameID")
                    or ev.get("buffGameID")
                    or ev.get("debuffGameID")
                    or ev.get("extraAbilityGameID")
                )
                # If FFLogs doesn't give a dedicated status id, don't guess with ability_id here.
                if status_id is not None:
                    german_status = xiv.status_de(status_id)
                    apply_status_seen(
                        enemy_node,
                        status_id,
                        meta,
                        fflogs_status_name=str(fflogs_ability_name) if fflogs_ability_name else None,
                        german_name=german_status,
                    )

        next_page = events.get("nextPageTimestamp")
        if not next_page or float(next_page) <= start:
            break
        start = float(next_page)

        safety += 1
        if safety > 500:
            break

    if args.debug:
        enemies = [k for k in encounter_obj.keys() if k not in {"combatants", "music", "contentzoneid"}]
        print(f"[debug] total_events={total_events} combatants={len(combatants_map)} enemies={len(enemies)}", file=sys.stderr)

    return out


if __name__ == "__main__":
    fflogs_data = main()
    fixnames(fflogs_data)
