#!/usr/bin/env python3
"""
Minecraft Launcher Token Extractor
==================================
Extracts Microsoft OAuth + Minecraft bearer tokens from the Minecraft Launcher's
local storage. This bypasses the need for Playwright browser automation entirely.

Usage:
    # On the machine with Minecraft Launcher installed:
    python launcher_token_extractor.py extract --output tokens.json
    
    # On a remote machine (tokens transferred from launcher machine):
    python launcher_token_extractor.py refresh --input tokens.json --output tokens.json

Token file format (tokens.json):
    [
        {
            "email": "user@gmail.com",
            "microsoft_access_token": "eyJ...",
            "microsoft_refresh_token": "0.AQ...",
            "xbl_token": "...",
            "xsts_token": "...",
            "mc_access_token": "eyJ...",
            "mc_uuid": "...",
            "gamertag": "Player123",
            "xuid": "2539xxxx...",
            "mc_token_expiry": "2026-05-16T21:00:00Z",
            "ms_refresh_expiry": "2026-06-16T20:00:00Z",
        }
    ]

The Minecraft Launcher stores tokens in:
    Windows: %APPDATA%\.minecraft\launcher_accounts.json (v1)
             %LOCALAPPDATA%\\Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalState\\games\\com.mojang\\minecraftService\\accounts.json (new)
    Linux:   ~/.minecraft/launcher_accounts.json
             ~/.var/app/com.mojang.Minecraft/.minecraft/accounts.json (flatpak)
    macOS:   ~/Library/Application Support/minecraft/launcher_accounts.json
"""

import argparse
import json
import os
import sys
import time
import base64
import urllib.parse
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional

import httpx

# Microsoft OAuth constants
MS_CLIENT_ID = "00000000402b5328"  # Xbox Live / Minecraft
MS_SCOPE = "XboxLive.signin"
MS_TOKEN_URL = "https://login.live.com/oauth20_token.srf"

# Xbox Live endpoints
XBL_AUTH_URL = "https://user.auth.xboxlive.com/user/authenticate"
XSTS_AUTH_URL = "https://xsts.auth.xboxlive.com/xsts/authorize"

# Minecraft services
MC_TOKEN_URL = "https://api.minecraftservices.com/authentication/login_with_xbox"


def find_launcher_token_files() -> list[dict]:
    """Search common Minecraft Launcher locations for token files."""
    candidates = []
    home = Path.home()
    
    # Linux paths
    linux_paths = [
        home / ".minecraft" / "launcher_accounts.json",
        home / ".minecraft" / "launcher_profiles.json",
        home / ".var" / "app" / "com.mojang.Minecraft" / ".minecraft" / "accounts.json",
        home / ".local" / "share" / "com.mojang" / "minecraft" / "launcher_accounts.json",
        home / ".config" / "minecraft" / "launcher_accounts.json",
    ]
    
    # Windows paths (for WSL or cross-platform)
    windows_paths = []
    if os.environ.get("LOCALAPPDATA"):
        windows_paths.append(Path(os.environ["LOCALAPPDATA"]) / "Packages" / 
            "Microsoft.4297127D64EC6_8wekyb3d8bbwe" / "LocalState" / "games" /
            "com.mojang" / "minecraftService" / "accounts.json")
    if os.environ.get("APPDATA"):
        windows_paths.append(Path(os.environ["APPDATA"]) / ".minecraft" / "launcher_accounts.json")
    
    # macOS paths
    mac_paths = [
        home / "Library" / "Application Support" / "minecraft" / "launcher_accounts.json",
    ]
    
    all_paths = linux_paths + windows_paths + mac_paths
    
    for p in all_paths:
        if p.exists():
            candidates.append({
                "path": str(p),
                "size": p.stat().st_size,
                "modified": datetime.fromtimestamp(p.stat().st_mtime).isoformat(),
            })
    
    return candidates


def read_launcher_tokens(filepath: str) -> list[dict]:
    """Read and parse tokens from a launcher accounts file."""
    with open(filepath, 'r') as f:
        data = json.load(f)
    
    accounts = []
    
    # New launcher format: top-level "accounts" array
    if isinstance(data, dict):
        raw_accounts = data.get("accounts", [])
        if not raw_accounts:
            # Fallback: the dict itself might be an account
            if "access_token" in data or "accessToken" in data:
                raw_accounts = [data]
    elif isinstance(data, list):
        raw_accounts = data
    else:
        print(f"[ERROR] Unexpected file format in {filepath}")
        return []
    
    for acc in raw_accounts:
        # Normalize field names (new vs old launcher format)
        account = {
            "uuid": acc.get("uuid") or acc.get("UUID", ""),
            "username": acc.get("username") or acc.get("name") or acc.get("displayName", ""),
            "access_token": acc.get("access_token") or acc.get("accessToken") or acc.get("msAccessToken", ""),
            "refresh_token": acc.get("refresh_token") or acc.get("refreshToken") or acc.get("msRefreshToken", ""),
            "expires_at": acc.get("expires_at") or acc.get("expiresAt") or acc.get("msExpiresOn", ""),
            "xbox_user_id": acc.get("xbox_user_id") or acc.get("xuid") or acc.get("xblUserId", ""),
            "gamertag": acc.get("gamertag") or acc.get("gamertag") or acc.get("xblGamertag", ""),
            "minecraft_access_token": acc.get("minecraft_access_token") or acc.get("minecraftAccessToken") or acc.get("mcAccessToken", ""),
            "minecraft_refresh_token": acc.get("minecraft_refresh_token") or acc.get("minecraftRefreshToken") or acc.get("mcRefreshToken", ""),
            "token_type": acc.get("token_type") or acc.get("type") or acc.get("TokenType", ""),
            "display_name": acc.get("display_name") or acc.get("displayName") or acc.get("name", ""),
        }
        
        # Skip invalid entries
        if not account["access_token"] and not account["refresh_token"]:
            continue
        
        accounts.append(account)
    
    return accounts


def decode_jwt_payload(token: str) -> dict:
    """Decode JWT payload without verification (for reading expiry)."""
    try:
        parts = token.split('.')
        if len(parts) != 3:
            return {}
        payload = parts[1]
        # Add padding if needed
        payload += '=' * (4 - len(payload) % 4)
        decoded = base64.urlsafe_b64decode(payload)
        return json.loads(decoded)
    except Exception:
        return {}


def is_token_expired(token: str, buffer_seconds: int = 300) -> bool:
    """Check if a JWT token is expired (with buffer)."""
    payload = decode_jwt_payload(token)
    exp = payload.get("exp")
    if not exp:
        return True
    return time.time() > (exp - buffer_seconds)


def refresh_microsoft_token(refresh_token: str) -> dict:
    """
    Refresh a Microsoft OAuth token using the refresh token.
    Returns dict with access_token, refresh_token, expires_in.
    """
    url = MS_TOKEN_URL
    data = {
        "client_id": MS_CLIENT_ID,
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
        "scope": MS_SCOPE,
    }
    
    with httpx.Client(timeout=15.0) as client:
        resp = client.post(url, data=data)
        if resp.status_code != 200:
            # Try without scope (some refresh tokens don't need it)
            del data["scope"]
            resp = client.post(url, data=data)
            if resp.status_code != 200:
                raise Exception(
                    f"Microsoft token refresh failed: HTTP {resp.status_code} - {resp.text[:200]}"
                )
        return resp.json()


def get_xbl_token(ms_access_token: str) -> dict:
    """Exchange Microsoft access token for Xbox Live token."""
    data = {
        "Properties": {
            "AuthMethod": "RPS",
            "SiteName": "user.auth.xboxlive.com",
            "RpsTicket": f"d={ms_access_token}",
        },
        "RelyingParty": "http://auth.xboxlive.com",
        "TokenType": "JWT",
    }
    
    with httpx.Client(timeout=15.0) as client:
        resp = client.post(
            XBL_AUTH_URL,
            json=data,
            headers={"X-Xbl-Contract": "1"},
        )
        if resp.status_code not in (200, 201):
            raise Exception(f"XBL auth failed: HTTP {resp.status_code} - {resp.text[:200]}")
        return resp.json()


def get_xsts_token(xbl_token_data: dict) -> dict:
    """Exchange XBL token for XSTS token."""
    token = xbl_token_data.get("Token", "")
    user_hns = xbl_token_data.get("DisplayClaims", {}).get("xui", [{}])[0].get("uhs", "")
    
    data = {
        "Properties": {
            "SandboxId": "RETAIL",
            "UserTokens": [token],
        },
        "RelyingParty": "rp://api.minecraftservices.com/",
        "TokenType": "JWT",
    }
    
    with httpx.Client(timeout=15.0) as client:
        resp = client.post(
            XSTS_AUTH_URL,
            json=data,
            headers={"X-Xbl-Contract": "1"},
        )
        if resp.status_code not in (200, 201):
            raise Exception(f"XSTS auth failed: HTTP {resp.status_code} - {resp.text[:200]}")
        return resp.json()


def get_minecraft_token(xsts_token_data: dict) -> dict:
    """Exchange XSTS token for Minecraft bearer token."""
    xsts_token = xsts_token_data.get("Token", "")
    
    data = {
        "identityToken": f"XBL3.0 x={xsts_token_data.get('DisplayClaims', {}).get('xui', [{}])[0].get('uhs', '')},{xsts_token}",
    }
    
    with httpx.Client(timeout=15.0) as client:
        resp = client.post(
            MC_TOKEN_URL,
            json=data,
            headers={"Content-Type": "application/json"},
        )
        if resp.status_code != 200:
            raise Exception(f"Minecraft token failed: HTTP {resp.status_code} - {resp.text[:200]}")
        return resp.json()


def get_gamertag_from_xsts(xsts_data: dict) -> str:
    """Extract gamertag from XSTS display claims."""
    claims = xsts_data.get("DisplayClaims", {})
    return claims.get("xui", [{}])[0].get("gtg", "")


def get_xuid_from_xsts(xsts_data: dict) -> str:
    """Extract XUID from XSTS display claims."""
    claims = xsts_data.get("DisplayClaims", {})
    return claims.get("xui", [{}])[0].get("xid", "")


def full_auth_chain(refresh_token: str, email: str = "") -> dict:
    """
    Run the full auth chain: MS refresh → XBL → XSTS → Minecraft.
    Returns dict with all tokens and account info.
    """
    # Step 1: Refresh Microsoft token
    ms_result = refresh_microsoft_token(refresh_token)
    ms_access = ms_result["access_token"]
    ms_refresh = ms_result.get("refresh_token", refresh_token)  # May get a new refresh token
    
    # Step 2: XBL
    xbl = get_xbl_token(ms_access)
    
    # Step 3: XSTS
    xsts = get_xsts_token(xbl)
    
    # Step 4: Minecraft
    mc = get_minecraft_token(xsts)
    
    # Extract account info
    gamertag = get_gamertag_from_xsts(xsts)
    xuid = get_xuid_from_xsts(xsts)
    mc_uuid = mc.get("username", "")
    
    # Decode expiry times
    mc_payload = decode_jwt_payload(mc["access_token"])
    mc_expiry = datetime.fromtimestamp(mc_payload.get("exp", 0), tz=timezone.utc).isoformat() if mc_payload.get("exp") else ""
    
    ms_payload = decode_jwt_payload(ms_access)
    ms_expiry = datetime.fromtimestamp(ms_payload.get("exp", 0), tz=timezone.utc).isoformat() if ms_payload.get("exp") else ""
    
    return {
        "email": email,
        "microsoft_access_token": ms_access,
        "microsoft_refresh_token": ms_refresh,
        "xbl_token": xbl.get("Token", ""),
        "xsts_token": xsts.get("Token", ""),
        "mc_access_token": mc["access_token"],
        "mc_uuid": mc_uuid,
        "gamertag": gamertag,
        "xuid": xuid,
        "mc_token_expiry": mc_expiry,
        "ms_refresh_expiry": ms_expiry,
    }


def cmd_extract(args):
    """Extract tokens from Minecraft Launcher storage."""
    print("=" * 60)
    print("Minecraft Launcher Token Extractor")
    print("=" * 60)
    
    if args.input:
        files = [{"path": args.input, "size": 0, "modified": ""}]
    else:
        files = find_launcher_token_files()
    
    if not files:
        print("\n[ERROR] No Minecraft Launcher token files found.")
        print("\nExpected locations:")
        print("  Linux:  ~/.minecraft/launcher_accounts.json")
        print("  Windows: %APPDATA%\\.minecraft\\launcher_accounts.json")
        print("  Windows (new): %LOCALAPPDATA%\\Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalState\\games\\com.mojang\\minecraftService\\accounts.json")
        print("  macOS:  ~/Library/Application Support/minecraft/launcher_accounts.json")
        print("\nTip: Log into the Minecraft Launcher first, then run this tool.")
        print("Or specify a file: python launcher_token_extractor.py extract --input /path/to/file")
        return 1
    
    all_tokens = []
    
    for file_info in files:
        filepath = file_info["path"]
        print(f"\n📁 Found: {filepath} ({file_info.get('size', '?')} bytes)")
        
        try:
            launcher_accounts = read_launcher_tokens(filepath)
            print(f"   Accounts found: {len(launcher_accounts)}")
            
            for acc in launcher_accounts:
                display = acc.get("display_name") or acc.get("username") or acc.get("uuid", "unknown")[:12]
                has_ms = bool(acc.get("access_token"))
                has_refresh = bool(acc.get("refresh_token"))
                has_mc = bool(acc.get("minecraft_access_token"))
                
                print(f"\n   🎮 {display}")
                print(f"      MS access token:   {'✓' if has_ms else '✗'}")
                print(f"      MS refresh token:  {'✓' if has_refresh else '✗'}")
                print(f"      MC access token:   {'✓' if has_mc else '✗'}")
                
                # Check if MS access token is still valid
                if has_ms and is_token_expired(acc["access_token"]):
                    print(f"      ⚠️  MS token EXPIRED")
                elif has_ms:
                    payload = decode_jwt_payload(acc["access_token"])
                    if payload.get("exp"):
                        expires = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
                        remaining = expires - datetime.now(timezone.utc)
                        print(f"      ⏰ MS token expires in {remaining}")
                
                # Build output record
                token_record = {
                    "email": acc.get("display_name") or acc.get("username") or "",
                    "uuid": acc.get("uuid", ""),
                    "microsoft_access_token": acc.get("access_token", ""),
                    "microsoft_refresh_token": acc.get("refresh_token", ""),
                    "mc_access_token": acc.get("minecraft_access_token", ""),
                    "mc_uuid": acc.get("username", ""),
                    "gamertag": acc.get("gamertag", ""),
                    "xuid": acc.get("xbox_user_id", ""),
                    "source_file": filepath,
                    "extracted_at": datetime.now(timezone.utc).isoformat(),
                }
                all_tokens.append(token_record)
                
        except json.JSONDecodeError:
            print(f"   [ERROR] Invalid JSON in {filepath}")
        except Exception as e:
            print(f"   [ERROR] Failed to read {filepath}: {e}")
    
    # Save output
    if all_tokens and args.output:
        with open(args.output, 'w') as f:
            json.dump(all_tokens, f, indent=2)
        print(f"\n✅ Saved {len(all_tokens)} account(s) to {args.output}")
    elif all_tokens:
        print(f"\n📋 Extracted {len(all_tokens)} account(s). Use --output to save.")
        print(json.dumps(all_tokens, indent=2))
    
    return 0 if all_tokens else 1


def cmd_refresh(args):
    """Refresh tokens using stored Microsoft refresh tokens."""
    print("=" * 60)
    print("Token Refresh")
    print("=" * 60)
    
    with open(args.input, 'r') as f:
        tokens = json.load(f)
    
    refreshed = []
    for idx, token_record in enumerate(tokens):
        email = token_record.get("email", f"account_{idx}")
        ms_refresh = token_record.get("microsoft_refresh_token", "")
        
        if not ms_refresh:
            print(f"\n❌ {email}: No Microsoft refresh token available")
            # Keep the old record as-is
            refreshed.append(token_record)
            continue
        
        print(f"\n🔄 Refreshing {email}...")
        
        try:
            result = full_auth_chain(ms_refresh, email)
            print(f"   ✅ {result.get('gamertag', email)} ({result.get('mc_uuid', 'unknown')})")
            print(f"      MC token valid until: {result.get('mc_token_expiry', '?')}")
            refreshed.append(result)
        except Exception as e:
            print(f"   ❌ Failed: {e}")
            # Keep old record
            refreshed.append(token_record)
    
    # Save
    output = args.output or args.input
    with open(output, 'w') as f:
        json.dump(refreshed, f, indent=2)
    print(f"\n✅ Saved to {output}")
    
    return 0


def cmd_status(args):
    """Check token status without refreshing."""
    with open(args.input, 'r') as f:
        tokens = json.load(f)
    
    print("=" * 60)
    print("Token Status")
    print("=" * 60)
    
    for token_record in tokens:
        email = token_record.get("email", "unknown")
        mc_token = token_record.get("mc_access_token", "")
        ms_refresh = token_record.get("microsoft_refresh_token", "")
        gamertag = token_record.get("gamertag", "?")
        mc_uuid = token_record.get("mc_uuid", "?")
        
        print(f"\n🎮 {gamertag} ({email})")
        print(f"   Minecraft UUID: {mc_uuid}")
        
        if mc_token:
            expired = is_token_expired(mc_token)
            payload = decode_jwt_payload(mc_token)
            if payload.get("exp"):
                expires = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
                remaining = expires - datetime.now(timezone.utc)
                if remaining.total_seconds() > 0:
                    print(f"   MC token: {'⚠️ EXPIRED' if expired else f'✓ valid ({remaining})'}")
                else:
                    ago = abs(remaining)
                    print(f"   MC token: ✗ expired {ago} ago")
            else:
                print(f"   MC token: ? (cannot determine expiry)")
        else:
            print(f"   MC token: ✗ missing")
        
        print(f"   MS refresh: {'✓ present' if ms_refresh else '✗ missing'}")
        print(f"   Extracted: {token_record.get('extracted_at', '?')}")


def main():
    parser = argparse.ArgumentParser(
        description="Minecraft Launcher Token Extractor",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Auto-detect and extract from Minecraft Launcher
  python launcher_token_extractor.py extract --output tokens.json

  # Extract from specific file
  python launcher_token_extractor.py extract --input ~/.minecraft/launcher_accounts.json --output tokens.json

  # Refresh tokens using stored refresh tokens
  python launcher_token_extractor.py refresh --input tokens.json --output tokens.json

  # Check token status
  python launcher_token_extractor.py status --input tokens.json
        """
    )
    
    subparsers = parser.add_subparsers(dest="command", help="Command to run")
    
    # extract
    extract_parser = subparsers.add_parser("extract", help="Extract tokens from Minecraft Launcher")
    extract_parser.add_argument("--input", "-i", help="Input file path (auto-detect if omitted)")
    extract_parser.add_argument("--output", "-o", required=True, help="Output tokens.json file")
    
    # refresh
    refresh_parser = subparsers.add_parser("refresh", help="Refresh tokens using Microsoft refresh tokens")
    refresh_parser.add_argument("--input", "-i", required=True, help="Input tokens.json file")
    refresh_parser.add_argument("--output", "-o", help="Output file (defaults to input)")
    
    # status
    status_parser = subparsers.add_parser("status", help="Check token status")
    status_parser.add_argument("--input", "-i", required=True, help="Input tokens.json file")
    
    args = parser.parse_args()
    
    if args.command == "extract":
        return cmd_extract(args)
    elif args.command == "refresh":
        return cmd_refresh(args)
    elif args.command == "status":
        return cmd_status(args)
    else:
        parser.print_help()
        return 1


if __name__ == "__main__":
    sys.exit(main() or 0)
