import hashlib
import json
import os
import re
import secrets
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import smtplib
from email.message import EmailMessage
from urllib.parse import urlencode

import psycopg
from flask import Flask, jsonify, make_response, request, redirect
from werkzeug.security import check_password_hash, generate_password_hash


USERNAME_RE = re.compile(r"^[a-z0-9_-]{3,16}$")

COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "psopeeps_session")
COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "true").lower() == "true"
COOKIE_SAMESITE = os.environ.get("SESSION_COOKIE_SAMESITE", "Lax")
SESSION_DAYS = int(os.environ.get("SESSION_DAYS", "30"))


def utcnow():
    return datetime.now(timezone.utc)


def db_dsn():
    return (
        f"postgresql://{os.environ['POSTGRES_USER']}:{os.environ['POSTGRES_PASSWORD']}"
        f"@{os.environ.get('POSTGRES_HOST', 'postgres')}:{os.environ.get('POSTGRES_PORT', '5432')}"
        f"/{os.environ['POSTGRES_DB']}"
    )


def connect():
    return psycopg.connect(db_dsn(), autocommit=True)


def init_db():
    last_err = None
    for _ in range(30):
        try:
            with connect() as conn:
                with conn.cursor() as cur:
                    cur.execute("""
                        CREATE TABLE IF NOT EXISTS site_users (
                            id BIGSERIAL PRIMARY KEY,
                            username TEXT NOT NULL UNIQUE,
                            password_hash TEXT NOT NULL,
                            created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
                            updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
                        )
                    """)

                    cur.execute("ALTER TABLE site_users ADD COLUMN IF NOT EXISTS email TEXT")
                    cur.execute("ALTER TABLE site_users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ")
                    cur.execute("""
                        CREATE UNIQUE INDEX IF NOT EXISTS site_users_email_lower_unique_idx
                        ON site_users (lower(email))
                        WHERE email IS NOT NULL
                    """)
                    cur.execute("""
                        CREATE TABLE IF NOT EXISTS email_verification_tokens (
                            id BIGSERIAL PRIMARY KEY,
                            user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE,
                            email TEXT NOT NULL,
                            token_hash TEXT NOT NULL UNIQUE,
                            created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
                            expires_at TIMESTAMPTZ NOT NULL,
                            used_at TIMESTAMPTZ
                        )
                    """)
                    cur.execute("""
                        CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
                        ON email_verification_tokens(user_id)
                    """)
                    cur.execute("""
                        CREATE INDEX IF NOT EXISTS email_verification_tokens_expires_at_idx
                        ON email_verification_tokens(expires_at)
                    """)
                    cur.execute("""
                        CREATE TABLE IF NOT EXISTS site_sessions (
                            id BIGSERIAL PRIMARY KEY,
                            user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE,
                            token_hash TEXT NOT NULL UNIQUE,
                            created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
                            expires_at TIMESTAMPTZ NOT NULL
                        )
                    """)
                    cur.execute("""
                        CREATE INDEX IF NOT EXISTS site_sessions_user_id_idx
                        ON site_sessions(user_id)
                    """)
                    cur.execute("""
                        CREATE INDEX IF NOT EXISTS site_sessions_expires_at_idx
                        ON site_sessions(expires_at)
                    """)
                    cur.execute("""
                        CREATE TABLE IF NOT EXISTS bb_accounts (
                            user_id BIGINT PRIMARY KEY REFERENCES site_users(id) ON DELETE CASCADE,
                            account_id BIGINT NOT NULL UNIQUE,
                            username TEXT NOT NULL UNIQUE,
                            created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
                            updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
                        )
                    """)
            return
        except Exception as e:
            last_err = e
            time.sleep(1)
    raise last_err


def clean_username(value):
    value = (value or "").strip().lower()
    if not USERNAME_RE.match(value):
        return None
    return value



EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


def clean_email(value):
    value = (value or "").strip().lower()
    if not value or len(value) > 254 or not EMAIL_RE.match(value):
        return None
    return value


def public_base_url():
    return os.environ.get("PUBLIC_BASE_URL", "https://psopeeps.online").rstrip("/")


def user_email_payload(user):
    email = user.get("email")
    verified_at = user.get("email_verified_at")
    return {
        "email": email,
        "verified": bool(verified_at),
        "verified_at": verified_at.isoformat() if hasattr(verified_at, "isoformat") else verified_at,
        "required": True,
    }


def create_email_verification_token(user_id, email):
    token = secrets.token_urlsafe(48)
    expires_at = utcnow() + timedelta(hours=24)

    with connect() as conn:
        with conn.cursor() as cur:
            cur.execute("""
                UPDATE email_verification_tokens
                SET used_at = now()
                WHERE user_id = %s AND used_at IS NULL
            """, (user_id,))
            cur.execute("""
                INSERT INTO email_verification_tokens (user_id, email, token_hash, expires_at)
                VALUES (%s, %s, %s, %s)
            """, (user_id, email, token_hash(token), expires_at))

    return token, expires_at


def verification_link(token):
    return f"{public_base_url()}/api/email/verify?{urlencode({'token': token})}"


def send_verification_email(to_email, username, link):
    smtp_host = os.environ.get("SMTP_HOST")
    smtp_port = int(os.environ.get("SMTP_PORT", "587"))
    smtp_user = os.environ.get("SMTP_USERNAME")
    smtp_password = os.environ.get("SMTP_PASSWORD")
    smtp_from = os.environ.get("SMTP_FROM", smtp_user or "no-reply@psopeeps.online")
    smtp_tls = os.environ.get("SMTP_TLS", "starttls").lower()

    if not smtp_host:
        if os.environ.get("EMAIL_DEBUG_SHOW_LINK", "").lower() in {"1", "true", "yes"}:
            return {"sent": False, "debug_link": link}
        raise RuntimeError("SMTP is not configured")

    msg = EmailMessage()
    msg["Subject"] = "Verify your PSO Peeps email"
    msg["From"] = smtp_from
    msg["To"] = to_email
    msg.set_content(
        f"Hi {username},\n\n"
        f"Verify your PSO Peeps email here:\n{link}\n\n"
        f"This link expires in 24 hours.\n"
    )

    if smtp_tls == "ssl":
        with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=20) as smtp:
            if smtp_user:
                smtp.login(smtp_user, smtp_password or "")
            smtp.send_message(msg)
    else:
        with smtplib.SMTP(smtp_host, smtp_port, timeout=20) as smtp:
            if smtp_tls == "starttls":
                smtp.starttls()
            if smtp_user:
                smtp.login(smtp_user, smtp_password or "")
            smtp.send_message(msg)

    return {"sent": True}


def require_verified_email(user):
    if user and user.get("email_verified_at"):
        return None
    return jsonify({
        "ok": False,
        "error": "Please verify your email before changing game account settings.",
        "email_required": True,
    }), 403


def json_body():
    data = request.get_json(silent=True)
    return data if isinstance(data, dict) else {}


def token_hash(token):
    return hashlib.sha256(token.encode("utf-8")).hexdigest()


def current_user():
    token = request.cookies.get(COOKIE_NAME)
    if not token:
        return None

    with connect() as conn:
        with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
            cur.execute("""
                SELECT u.id, u.username, u.email, u.email_verified_at
                FROM site_sessions s
                JOIN site_users u ON u.id = s.user_id
                WHERE s.token_hash = %s
                  AND s.expires_at > now()
                LIMIT 1
            """, (token_hash(token),))
            return cur.fetchone()


def issue_session(user_id):
    token = secrets.token_urlsafe(48)
    expires_at = utcnow() + timedelta(days=SESSION_DAYS)

    with connect() as conn:
        with conn.cursor() as cur:
            cur.execute("""
                INSERT INTO site_sessions (user_id, token_hash, expires_at)
                VALUES (%s, %s, %s)
            """, (user_id, token_hash(token), expires_at))

    return token, expires_at


def set_session_cookie(resp, token, expires_at):
    resp.set_cookie(
        COOKIE_NAME,
        token,
        expires=expires_at,
        httponly=True,
        secure=COOKIE_SECURE,
        samesite=COOKIE_SAMESITE,
        path="/",
    )


def clear_session_cookie(resp):
    resp.delete_cookie(COOKIE_NAME, path="/")


def fnv1a32(text):
    h = 0x811C9DC5
    for b in text.encode("utf-8"):
        h ^= b
        h = (h * 0x01000193) & 0xFFFFFFFF
    return h


def bb_account_id(username):
    return fnv1a32(username) & 0x7FFFFFFF


def account_id_str(account_id):
    return f"{int(account_id):010d}"


def validate_bb_password(password):
    if not password:
        return "Blue Burst password is required."
    if len(password) > 16:
        return "Blue Burst password must be 16 characters or fewer."
    return None


def license_text(account_id, username, password):
    u = json.dumps(username)
    p = json.dumps(password)

    return f'''{{
  "BBTeamID": 0x0,
  "FormatVersion": 0x1,
  "AccountID": 0x{account_id:X},
  "LastPlayerName": {u},
  "DCNTELicenses": [],
  "BBLicenses": [
    {{"UserName": {u}, "Password": {p}}}
  ],
  "BanEndTime": 0x0,
  "PCLicenses": [],
  "AutoReplyMessage": "",
  "GCLicenses": [],
  "AutoPatchesEnabled": ["MomokaItemExchangeFix", "DisableIdleDisconnect", "FastTekker", "Palette", "DrawDistance", "EnemyHPBars", "HungryMagSound"],
  "XBLicenses": [],
  "Flags": 0x7FFFFFFF,
  "Ep3TotalMesetaEarned": 0x0,
  "Ep3CurrentMeseta": 0x0,
  "DCLicenses": [],
  "UserFlags": 0x0
}}'''


def account_root(account_id):
    root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync"))
    return root / "canonical" / "accounts" / account_id_str(account_id)


def canonical_license_path(account_id):
    aid = account_id_str(account_id)
    return account_root(account_id) / "system" / "licenses" / f"{aid}.json"


def sha256_file(path):
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def refresh_account_manifest(account_id):
    root = account_root(account_id)
    manifest_path = root / "manifest.json"
    if not root.is_dir():
        return None

    existing = {}
    if manifest_path.exists():
        try:
            existing = json.loads(manifest_path.read_text())
        except Exception:
            existing = {}

    old_files = existing.get("files", {}) if isinstance(existing.get("files"), dict) else {}
    new_files = {}
    now = int(time.time())

    for path in sorted(x for x in root.rglob("*") if x.is_file()):
        rel = path.relative_to(root).as_posix()
        if rel == "manifest.json":
            continue

        st = path.stat()
        digest = sha256_file(path)
        old = old_files.get(rel, {})

        if (
            old.get("sha256") == digest
            and int(old.get("size", -1)) == st.st_size
            and int(old.get("mtime_ns", -1)) == st.st_mtime_ns
        ):
            new_files[rel] = old
        else:
            new_files[rel] = {
                "inbox_path": f"site-generated:{rel}",
                "mtime_ns": st.st_mtime_ns,
                "promoted_at": now,
                "relative_path": rel,
                "sha256": digest,
                "size": st.st_size,
                "source": "site",
            }

    manifest = dict(existing)
    manifest["files"] = new_files
    manifest["updated_at"] = now

    tmp = manifest_path.with_suffix(".json.tmp")
    tmp.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
    tmp.replace(manifest_path)

    return sha256_file(manifest_path)


def canonical_manifest_sha256(account_id):
    manifest = account_root(account_id) / "manifest.json"
    if not manifest.exists():
        return None
    return sha256_file(manifest)


def read_json_file(path):
    try:
        return json.loads(path.read_text())
    except Exception:
        return None


def bb_sync_info(account_id):
    aid = account_id_str(account_id)
    root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync"))
    manifest_sha = canonical_manifest_sha256(account_id)

    regions = {
        "us": {
            "host": "psopeeps_us",
            "status": "pending",
            "applied_at": None,
        },
        "eu": {
            "host": "psopeeps_eu",
            "status": "pending",
            "applied_at": None,
        },
    }

    if not manifest_sha:
        return {
            "status": "pending",
            "manifest_sha256": None,
            "regions": regions,
        }

    for region, info in regions.items():
        state_path = root / "state" / "applied" / f"{info['host']}.site.{aid}.json"
        state = read_json_file(state_path)

        if state and state.get("manifest_sha256") == manifest_sha:
            info["status"] = "current"
            info["applied_at"] = state.get("applied_at")
        elif state:
            info["status"] = "pending"
            info["applied_at"] = state.get("applied_at")
        else:
            info["status"] = "pending"

    overall = "current" if all(x["status"] == "current" for x in regions.values()) else "pending"

    return {
        "status": overall,
        "manifest_sha256": manifest_sha,
        "regions": regions,
    }


def bb_payload(account_id, username):
    sync = bb_sync_info(account_id)
    return {
        "created": True,
        "ready": sync["status"] == "current",
        "sync_status": sync["status"],
        "username": username,
        "account_id": account_id_str(account_id),
        "regions": sync["regions"],
    }



def enqueue_account_sync(account_id, reason):
    root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync"))
    queue = root / "queue" / "apply"
    queue.mkdir(parents=True, exist_ok=True)

    aid = account_id_str(account_id)
    payload = {
        "account": aid,
        "reason": reason,
        "queued_at": int(time.time()),
    }

    final = queue / f"{int(time.time())}.{time.time_ns()}.{aid}.json"
    tmp = queue / f".{final.name}.tmp"

    tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
    tmp.replace(final)

    return final

def write_bb_license(account_id, username, password):
    path = canonical_license_path(account_id)
    path.parent.mkdir(parents=True, exist_ok=True)

    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(license_text(account_id, username, password) + "\n")
    tmp.replace(path)

    refresh_account_manifest(account_id)
    enqueue_account_sync(account_id, "bb_license_updated")

    return path



app = Flask(__name__)
init_db()


@app.after_request
def add_headers(resp):
    resp.headers["Cache-Control"] = "no-store"
    return resp


@app.get("/health")
def health():
    return jsonify({"ok": True})


@app.get("/me")
def me():
    user = current_user()
    if not user:
        return jsonify({"authenticated": False})
    return jsonify({
        "authenticated": True,
        "user": {
            "id": user["id"],
            "username": user["username"],
        },
        "email": user_email_payload(user),
    })



def local_syncer_save_summary(account_id):
    import json
    import os
    from pathlib import Path
    from datetime import datetime, timezone

    account = account_id_str(account_id)
    root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync"))
    applied_dir = root / "state" / "applied"

    paths = {
        "us": applied_dir / f"psopeeps_us.site.{account}.json",
        "eu": applied_dir / f"psopeeps_eu.site.{account}.json",
    }

    def parse_time(value):
        if not value:
            return None
        try:
            return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
        except Exception:
            return None

    regions = {}

    for region, path in paths.items():
        info = {
            "status": "unknown",
            "label": "Not seen",
            "style": "warn",
            "host": None,
            "applied_at": None,
            "manifest_sha256": None,
        }

        if path.exists():
            try:
                data = json.loads(path.read_text())
                info.update({
                    "status": "seen",
                    "label": "Seen",
                    "style": "warn",
                    "host": data.get("host"),
                    "applied_at": data.get("applied_at"),
                    "manifest_sha256": data.get("manifest_sha256"),
                })
            except Exception as e:
                info["error"] = str(e)

        regions[region] = info

    us_hash = regions["us"].get("manifest_sha256")
    eu_hash = regions["eu"].get("manifest_sha256")

    if us_hash and eu_hash and us_hash == eu_hash:
        for region in ("us", "eu"):
            regions[region]["status"] = "current"
            regions[region]["label"] = "Current"
            regions[region]["style"] = "good"

        times = [
            t for t in (
                parse_time(regions["us"].get("applied_at")),
                parse_time(regions["eu"].get("applied_at")),
            )
            if t
        ]
        latest = max(times) if times else None

        return {
            "status": "current",
            "safe": True,
            "regions": regions,
            "last_sync": latest.isoformat() if latest else None,
            "message": "",
        }

    if not us_hash and not eu_hash:
        return {
            "status": "unknown",
            "safe": False,
            "regions": regions,
            "last_sync": None,
            "message": "No mirrored save data has been seen for this account yet.",
        }

    us_time = parse_time(regions["us"].get("applied_at")) or datetime.fromtimestamp(0, tz=timezone.utc)
    eu_time = parse_time(regions["eu"].get("applied_at")) or datetime.fromtimestamp(0, tz=timezone.utc)

    newest = "us" if us_time >= eu_time else "eu"
    older = "eu" if newest == "us" else "us"

    regions[newest]["status"] = "newest"
    regions[newest]["label"] = "Newest"
    regions[newest]["style"] = "good"

    regions[older]["status"] = "pending"
    regions[older]["label"] = "Pending"
    regions[older]["style"] = "warn"

    latest = max(us_time, eu_time)

    return {
        "status": "pending",
        "safe": False,
        "regions": regions,
        "last_sync": latest.isoformat() if latest else None,
        "message": "Save sync pending. If this does not clear soon, contact an admin in Discord.",
    }


@app.get("/account")
def account():
    user = current_user()
    if not user:
        return jsonify({"authenticated": False}), 401

    with connect() as conn:
        with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
            cur.execute("""
                SELECT account_id, username
                FROM bb_accounts
                WHERE user_id = %s
                LIMIT 1
            """, (user["id"],))
            bb = cur.fetchone()

    return jsonify({
        "authenticated": True,
        "user": {
            "id": user["id"],
            "username": user["username"],
        },
        "email": user_email_payload(user),
        "bb": bb_payload(bb["account_id"], bb["username"]) if bb else {
            "created": False,
            "ready": False,
            "sync_status": "missing",
            "username": user["username"],
            "account_id": None,
            "regions": {
                "us": {"host": "psopeeps_us", "status": "missing", "applied_at": None},
                "eu": {"host": "psopeeps_eu", "status": "missing", "applied_at": None},
            },
        },
        "v2_v3_keys": [],
        "save_sync": local_syncer_save_summary(bb["account_id"]) if bb else {
            "status": "missing",
            "message": "Create a Blue Burst account before save sync status is available.",
            "last_sync": None,
            "regions": {
                "us": {"host": "psopeeps_us", "status": "missing", "applied_at": None},
                "eu": {"host": "psopeeps_eu", "status": "missing", "applied_at": None},
            },
        },
    })



@app.post("/email/start")
def email_start():
    user = current_user()
    if not user:
        return jsonify({"ok": False, "error": "Not signed in."}), 401

    data = json_body()
    email = clean_email(data.get("email"))
    if not email:
        return jsonify({"ok": False, "error": "Enter a valid email address."}), 400

    try:
        with connect() as conn:
            with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
                cur.execute("""
                    UPDATE site_users
                    SET email = %s,
                        email_verified_at = NULL,
                        updated_at = now()
                    WHERE id = %s
                    RETURNING id, username, email, email_verified_at
                """, (email, user["id"]))
                updated = cur.fetchone()
    except psycopg.errors.UniqueViolation:
        return jsonify({"ok": False, "error": "That email is already in use."}), 409

    token, expires_at = create_email_verification_token(updated["id"], updated["email"])
    result = send_verification_email(
        updated["email"],
        updated["username"],
        verification_link(token),
    )

    return jsonify({
        "ok": True,
        "email": user_email_payload(updated),
        "verification": result,
    })


@app.post("/email/resend")
def email_resend():
    user = current_user()
    if not user:
        return jsonify({"ok": False, "error": "Not signed in."}), 401

    email = clean_email(user.get("email"))
    if not email:
        return jsonify({"ok": False, "error": "No email address is set."}), 400

    if user.get("email_verified_at"):
        return jsonify({"ok": True, "email": user_email_payload(user), "already_verified": True})

    token, expires_at = create_email_verification_token(user["id"], email)
    result = send_verification_email(
        email,
        user["username"],
        verification_link(token),
    )

    return jsonify({
        "ok": True,
        "email": user_email_payload(user),
        "verification": result,
    })


@app.get("/email/verify")
def email_verify():
    token = request.args.get("token") or ""
    if not token:
        return redirect("/account-unverified.html?error=missing-token")

    th = token_hash(token)

    with connect() as conn:
        with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
            cur.execute("""
                SELECT id, user_id, email
                FROM email_verification_tokens
                WHERE token_hash = %s
                  AND used_at IS NULL
                  AND expires_at > now()
                LIMIT 1
            """, (th,))
            row = cur.fetchone()

            if not row:
                return redirect("/account-unverified.html?error=invalid-token")

            cur.execute("""
                UPDATE site_users
                SET email = %s,
                    email_verified_at = now(),
                    updated_at = now()
                WHERE id = %s
            """, (row["email"], row["user_id"]))

            cur.execute("""
                UPDATE email_verification_tokens
                SET used_at = now()
                WHERE id = %s
            """, (row["id"],))

    return redirect("/account.html?verified=1")


@app.post("/register")
def register():
    data = json_body()
    username = clean_username(data.get("username"))
    email = clean_email(data.get("email"))
    password = data.get("password") or ""

    if not username:
        return jsonify({
            "ok": False,
            "error": "Username must be 3-16 characters: lowercase letters, numbers, underscore, or hyphen.",
        }), 400

    if not email:
        return jsonify({"ok": False, "error": "Enter a valid email address."}), 400

    if len(password) < 8:
        return jsonify({"ok": False, "error": "Password must be at least 8 characters."}), 400

    try:
        with connect() as conn:
            with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
                cur.execute("""
                    INSERT INTO site_users (username, email, password_hash)
                    VALUES (%s, %s, %s)
                    RETURNING id, username, email, email_verified_at
                """, (username, email, generate_password_hash(password)))
                user = cur.fetchone()
    except psycopg.errors.UniqueViolation:
        return jsonify({"ok": False, "error": "That username or email is already taken."}), 409

    verify_token, verify_expires_at = create_email_verification_token(user["id"], user["email"])
    verify_result = send_verification_email(
        user["email"],
        user["username"],
        verification_link(verify_token),
    )

    token, expires_at = issue_session(user["id"])
    resp = make_response(jsonify({
        "ok": True,
        "user": {
            "id": user["id"],
            "username": user["username"],
        },
        "email": user_email_payload(user),
        "verification": verify_result,
    }))
    set_session_cookie(resp, token, expires_at)
    return resp


@app.post("/login")
def login():
    data = json_body()
    username = clean_username(data.get("username"))
    password = data.get("password") or ""

    if not username or not password:
        return jsonify({"ok": False, "error": "Username and password are required."}), 400

    with connect() as conn:
        with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
            cur.execute("""
                SELECT id, username, password_hash, email, email_verified_at
                FROM site_users
                WHERE username = %s
                LIMIT 1
            """, (username,))
            user = cur.fetchone()

    if not user or not check_password_hash(user["password_hash"], password):
        return jsonify({"ok": False, "error": "Invalid username or password."}), 401

    token, expires_at = issue_session(user["id"])
    resp = make_response(jsonify({
        "ok": True,
        "user": {
            "id": user["id"],
            "username": user["username"],
        },
    }))
    set_session_cookie(resp, token, expires_at)
    return resp



@app.post("/password/change")
def password_change():
    user = current_user()
    if not user:
        return jsonify({"ok": False, "error": "Not signed in."}), 401

    data = json_body()
    current_password = data.get("current_password") or data.get("currentPassword") or ""
    new_password = data.get("password") or ""
    confirm = data.get("confirm_password") or data.get("confirmPassword") or ""

    if len(new_password) < 8:
        return jsonify({"ok": False, "error": "Password must be at least 8 characters."}), 400

    if new_password != confirm:
        return jsonify({"ok": False, "error": "Passwords do not match."}), 400

    with connect() as conn:
        with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
            cur.execute("""
                SELECT id, password_hash
                FROM site_users
                WHERE id = %s
                LIMIT 1
            """, (user["id"],))
            row = cur.fetchone()

            if not row or not check_password_hash(row["password_hash"], current_password):
                return jsonify({"ok": False, "error": "Current password is incorrect."}), 401

            cur.execute("""
                UPDATE site_users
                SET password_hash = %s,
                    updated_at = now()
                WHERE id = %s
            """, (generate_password_hash(new_password), user["id"]))

    return jsonify({"ok": True})

@app.post("/logout")
def logout():
    token = request.cookies.get(COOKIE_NAME)
    if token:
        with connect() as conn:
            with conn.cursor() as cur:
                cur.execute("DELETE FROM site_sessions WHERE token_hash = %s", (token_hash(token),))

    resp = make_response(jsonify({"ok": True}))
    clear_session_cookie(resp)
    return resp


@app.post("/bb/create")
def bb_create():
    user = current_user()
    if not user:
        return jsonify({"ok": False, "error": "Not signed in."}), 401

    email_error = require_verified_email(user)
    if email_error:
        return email_error

    data = json_body()
    password = data.get("password") or ""
    confirm = data.get("confirm_password") or data.get("confirmPassword") or ""

    err = validate_bb_password(password)
    if err:
        return jsonify({"ok": False, "error": err}), 400
    if password != confirm:
        return jsonify({"ok": False, "error": "Blue Burst passwords do not match."}), 400

    username = user["username"]
    account_id = bb_account_id(username)

    with connect() as conn:
        with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
            cur.execute("SELECT user_id FROM bb_accounts WHERE user_id = %s", (user["id"],))
            if cur.fetchone():
                return jsonify({"ok": False, "error": "Blue Burst account already exists."}), 409

            write_bb_license(account_id, username, password)

            cur.execute("""
                INSERT INTO bb_accounts (user_id, account_id, username)
                VALUES (%s, %s, %s)
                RETURNING account_id, username
            """, (user["id"], account_id, username))
            bb = cur.fetchone()

    return jsonify({"ok": True, "bb": bb_payload(bb["account_id"], bb["username"])})


@app.post("/bb/change-password")
def bb_change_password():
    user = current_user()
    if not user:
        return jsonify({"ok": False, "error": "Not signed in."}), 401

    email_error = require_verified_email(user)
    if email_error:
        return email_error

    data = json_body()
    password = data.get("password") or ""
    confirm = data.get("confirm_password") or data.get("confirmPassword") or ""

    err = validate_bb_password(password)
    if err:
        return jsonify({"ok": False, "error": err}), 400
    if password != confirm:
        return jsonify({"ok": False, "error": "Blue Burst passwords do not match."}), 400

    with connect() as conn:
        with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
            cur.execute("""
                SELECT account_id, username
                FROM bb_accounts
                WHERE user_id = %s
                LIMIT 1
            """, (user["id"],))
            bb = cur.fetchone()

            if not bb:
                return jsonify({"ok": False, "error": "Blue Burst account does not exist yet."}), 404

            write_bb_license(bb["account_id"], bb["username"], password)

            cur.execute("""
                UPDATE bb_accounts
                SET updated_at = now()
                WHERE user_id = %s
            """, (user["id"],))

    return jsonify({"ok": True, "bb": bb_payload(bb["account_id"], bb["username"])})


# Register V2 key profile API routes.
from key_routes import register_key_routes

register_key_routes(
    app,
    connect=connect,
    current_user=current_user,
    jsonify=jsonify,
    request=request,
    account_id_str=account_id_str,
    bb_account_id=bb_account_id,
    canonical_license_path=canonical_license_path,
    refresh_account_manifest=refresh_account_manifest,
    enqueue_account_sync=enqueue_account_sync,
    bb_sync_info=bb_sync_info,
)

# --- Hardcore stats aggregator -------------------------------------------------
# Combines EU + US local Hardcore stats APIs for the website.
# Source rows are kept conceptually separate, then merged by stable character key.

import urllib.request as _hc_urllib_request
import urllib.error as _hc_urllib_error
from datetime import datetime as _hc_datetime, timezone as _hc_timezone

def _hc_source_urls():
    sources = []
    eu = (os.environ.get("HARDCORE_STATS_EU_URL") or "").strip().rstrip("/")
    us = (os.environ.get("HARDCORE_STATS_US_URL") or "").strip().rstrip("/")
    if eu:
        sources.append(("eu", eu))
    if us:
        sources.append(("us", us))
    return sources

def _hc_fetch_json(url, timeout=30):
    req = _hc_urllib_request.Request(
        url,
        headers={"User-Agent": "psopeeps-site-hardcore-aggregator/1.0"},
    )
    with _hc_urllib_request.urlopen(req, timeout=timeout) as resp:
        raw = resp.read().decode("utf-8", errors="replace")
    return json.loads(raw)

def _hc_get_source_characters():
    rows = []
    errors = []

    for source_name, base_url in _hc_source_urls():
        url = f"{base_url}/api/hardcore/characters"
        try:
            data = _hc_fetch_json(url)
            if isinstance(data, list):
                for row in data:
                    if isinstance(row, dict):
                        r = dict(row)
                        r["_source_ship"] = source_name
                        r["_source_url"] = base_url
                        rows.append(r)
            else:
                errors.append({
                    "source_ship": source_name,
                    "url": url,
                    "error": "response was not a list",
                })
        except Exception as e:
            errors.append({
                "source_ship": source_name,
                "url": url,
                "error": str(e),
            })

    return rows, errors

def _hc_int(value, default=0):
    try:
        if value is None:
            return default
        return int(value)
    except Exception:
        return default

def _hc_bool(value, default=True):
    if isinstance(value, bool):
        return value
    if value is None:
        return default
    if isinstance(value, str):
        return value.strip().lower() not in {"0", "false", "no", "dead"}
    return bool(value)

def _hc_sort_ts(value):
    if not value:
        return ""
    return str(value)

def _hc_character_key(row):
    guild_card = _hc_int(row.get("guild_card"))
    slot = _hc_int(row.get("character_slot"))
    creation_ts = _hc_int(row.get("character_creation_timestamp"))

    # Real creation timestamps are stable across synced saves and should be used.
    if creation_ts > 0:
        return (guild_card, slot, creation_ts)

    # Legacy timestamp-0 rows are kept separate by name so an old dead/test row
    # does not poison the current real character row.
    name = str(row.get("character_name") or "")
    return (guild_card, slot, f"legacy:{name}")

def _hc_pick_display_row(rows):
    return sorted(
        rows,
        key=lambda r: (
            _hc_int(r.get("total_exp")),
            _hc_int(r.get("level")),
            _hc_int(r.get("play_time_seconds")),
            _hc_sort_ts(r.get("updated_at")),
        ),
        reverse=True,
    )[0]

def _hc_merge_character_rows(rows):
    by_key = {}
    for row in rows:
        by_key.setdefault(_hc_character_key(row), []).append(row)

    combined = []

    for key, group in by_key.items():
        display = _hc_pick_display_row(group)

        source_ships = sorted({
            str(r.get("_source_ship") or "")
            for r in group
            if r.get("_source_ship")
        })

        # Kills aggregate across source rows in the same character identity group.
        total_kills = sum(_hc_int(r.get("total_enemies_killed")) for r in group)

        # Higher numeric values win for progression fields.
        level = max(_hc_int(r.get("level"), 1) for r in group)
        total_exp = max(_hc_int(r.get("total_exp")) for r in group)
        play_time_seconds = max(_hc_int(r.get("play_time_seconds")) for r in group)

        # Death anywhere in the same identity group makes the combined row dead.
        alive = all(_hc_bool(r.get("alive"), True) for r in group)

        updated_at = max((str(r.get("updated_at") or "") for r in group), default="")
        last_seen_at = max((str(r.get("last_seen_at") or "") for r in group), default="")
        dead_at_values = [str(r.get("dead_at")) for r in group if r.get("dead_at")]
        dead_at = min(dead_at_values) if dead_at_values else None

        merged = dict(display)
        merged.pop("_source_url", None)

        merged["source_ships"] = source_ships
        merged["source_count"] = len(group)
        merged["guild_card"] = _hc_int(display.get("guild_card"))
        merged["character_slot"] = _hc_int(display.get("character_slot"))
        merged["character_creation_timestamp"] = _hc_int(display.get("character_creation_timestamp"))
        merged["level"] = level
        merged["total_exp"] = total_exp
        merged["play_time_seconds"] = play_time_seconds
        merged["total_enemies_killed"] = total_kills
        merged["alive"] = alive
        merged["dead_at"] = dead_at
        merged["last_seen_at"] = last_seen_at or None
        merged["updated_at"] = updated_at or None

        combined.append(merged)

    return combined

def _hc_combined_payload():
    source_rows, errors = _hc_get_source_characters()
    combined = _hc_merge_character_rows(source_rows)
    return combined, errors

@app.get("/hardcore/sources")
def hardcore_sources():
    statuses = []

    for source_name, base_url in _hc_source_urls():
        status = {
            "source_ship": source_name,
            "base_url": base_url,
            "ok": False,
            "health": None,
            "error": None,
        }
        try:
            status["health"] = _hc_fetch_json(f"{base_url}/health")
            status["ok"] = bool(status["health"].get("ok")) if isinstance(status["health"], dict) else True
        except Exception as e:
            status["error"] = str(e)
        statuses.append(status)

    return jsonify({
        "ok": all(s["ok"] for s in statuses) if statuses else False,
        "sources": statuses,
    })

@app.get("/hardcore/characters")
def hardcore_characters_combined():
    combined, errors = _hc_combined_payload()
    combined.sort(
        key=lambda r: (
            _hc_int(r.get("total_exp")),
            _hc_int(r.get("level")),
            _hc_int(r.get("total_enemies_killed")),
        ),
        reverse=True,
    )
    return jsonify({
        "ok": not errors,
        "errors": errors,
        "count": len(combined),
        "characters": combined,
    })

@app.get("/hardcore/leaderboard/kills")
def hardcore_leaderboard_kills_combined():
    combined, errors = _hc_combined_payload()
    combined.sort(
        key=lambda r: (
            _hc_int(r.get("total_enemies_killed")),
            _hc_int(r.get("total_exp")),
            _hc_int(r.get("level")),
        ),
        reverse=True,
    )
    return jsonify(combined[:100])

@app.get("/hardcore/leaderboard/level")
def hardcore_leaderboard_level_combined():
    combined, errors = _hc_combined_payload()
    combined.sort(
        key=lambda r: (
            _hc_int(r.get("level")),
            _hc_int(r.get("total_exp")),
            _hc_int(r.get("total_enemies_killed")),
        ),
        reverse=True,
    )
    return jsonify(combined[:100])

@app.get("/hardcore/leaderboard/exp")
def hardcore_leaderboard_exp_combined():
    combined, errors = _hc_combined_payload()
    combined.sort(
        key=lambda r: (
            _hc_int(r.get("total_exp")),
            _hc_int(r.get("level")),
            _hc_int(r.get("total_enemies_killed")),
        ),
        reverse=True,
    )
    return jsonify(combined[:100])

def _hc_points_row(row):
    level = _hc_int(row.get("level"))
    total_exp = _hc_int(row.get("total_exp"))
    kills = _hc_int(row.get("total_enemies_killed"))
    play_time = _hc_int(row.get("play_time_seconds"))
    hours_played = play_time / 3600 if play_time > 0 else 0

    level_points = (level * 1000) + (level * level * 10)
    exp_points = int(total_exp / 1000)
    kill_points = int(100 * (kills ** 0.5))
    survival_time_points = min(10000, int(250 * (hours_played ** 0.5)))
    challenge_mode_points = 0
    total_points_raw = level_points + exp_points + kill_points + survival_time_points + challenge_mode_points
    total_points = int(total_points_raw / 100)

    player_name = row.get("character_name") or row.get("name") or ""

    return {
        "PlayerName": player_name,
        "CharacterName": player_name,
        "Points": total_points,
        "TotalPoints": total_points,
        "Class": row.get("character_class") or "",
        "SecID": row.get("section_id") or "",
        "LevelPoints": level_points,
        "EXPPoints": exp_points,
        "KillPoints": kill_points,
        "SurvivalTimePoints": survival_time_points,
        "ChallengeModePoints": challenge_mode_points,
        "Level": level,
        "TotalEXP": total_exp,
        "Kills": kills,
        "TotalKills": kills,
        "PlayTimeSeconds": play_time,
        "Alive": _hc_bool(row.get("alive"), True),
    }

@app.get("/hardcore/leaderboard/points")
def hardcore_leaderboard_points_combined():
    combined, errors = _hc_combined_payload()
    rows = [_hc_points_row(r) for r in combined]
    rows.sort(key=lambda r: r["TotalPoints"], reverse=True)
    return jsonify(rows[:100])

# --- end Hardcore stats aggregator -------------------------------------------
