Загрузка данных


#!/usr/bin/env bash
set -Eeuo pipefail
trap 'echo "ERROR: Script failed at line $LINENO. Command: $BASH_COMMAND"' ERR

PROJECT_DIR="$HOME/dreamcore-server"
BACKEND_DIR="$PROJECT_DIR/backend"
ADMIN_DIR="$PROJECT_DIR/admin"

if [ -f "$PROJECT_DIR/.env" ]; then
  set -a
  . "$PROJECT_DIR/.env"
  set +a
fi

POSTGRES_DB="dreamcore"
POSTGRES_USER="dreamcore"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$(openssl rand -hex 24)}"
MINIO_ROOT_USER="${MINIO_ROOT_USER:-dreamcore}"
MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD:-$(openssl rand -hex 24)}"
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
MANIFEST_SIGNING_KEY="${MANIFEST_SIGNING_KEY:-$(openssl rand -hex 32)}"
ADMIN_TOKEN="${ADMIN_TOKEN:-$(openssl rand -hex 24)}"
SERVER_IP="${DREAMCORE_SERVER_IP:-$(hostname -I | awk '{print $1}')}"

echo "[1/8] Installing packages..."
sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-release openssl git jq build-essential pkg-config python3 python3-pip ufw cmake mingw-w64 osslsigncode

echo "[2/8] Installing Docker..."
sudo install -m 0755 -d /etc/apt/keyrings
if [ ! -f /etc/apt/keyrings/docker.asc ]; then
  curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo tee /etc/apt/keyrings/docker.asc > /dev/null
  sudo chmod a+r /etc/apt/keyrings/docker.asc
fi

UBUNTU_CODENAME="$(. /etc/os-release && echo "${UBUNTU_CODENAME:-${VERSION_CODENAME:-jammy}}")"
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${UBUNTU_CODENAME} stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable docker
sudo systemctl start docker
sudo usermod -aG docker "$USER" || true

echo "[3/8] Creating project structure..."
sudo mkdir -p "$PROJECT_DIR"
sudo chown -R "$USER":"$USER" "$PROJECT_DIR"
sudo chmod o+x "$HOME"
sudo chmod -R o+rx "$PROJECT_DIR"
mkdir -p "$PROJECT_DIR"/infra/nginx/conf.d
mkdir -p "$PROJECT_DIR"/infra/nginx/certs
mkdir -p "$PROJECT_DIR"/infra/postgres-data
mkdir -p "$PROJECT_DIR"/infra/postgres-initdb
mkdir -p "$PROJECT_DIR"/infra/minio
mkdir -p "$PROJECT_DIR"/infra/certs # Ensure certs dir exists
mkdir -p "$PROJECT_DIR"/infra/redis
mkdir -p "$PROJECT_DIR"/storage/release
mkdir -p "$PROJECT_DIR"/storage/nightly
mkdir -p "$PROJECT_DIR"/storage/manifests
mkdir -p "$PROJECT_DIR"/inbox/release
mkdir -p "$PROJECT_DIR"/inbox/nightly
mkdir -p "$PROJECT_DIR"/backups

echo "[*] Generating self-signed signing certificate..."
# Remove old nginx certs to avoid conflicts
sudo rm -f /etc/ssl/certs/nginx-selfsigned.crt /etc/ssl/private/nginx-selfsigned.key 2>/dev/null || true

if [ ! -f "$PROJECT_DIR/infra/certs/server.pfx" ] || [ ! -f "$PROJECT_DIR/infra/certs/server.crt" ]; then
  # Generate certificate valid for 10 years with subject Alt Names
  openssl req -x509 -newkey rsa:4096 -keyout "$PROJECT_DIR/infra/certs/server.key" -out "$PROJECT_DIR/infra/certs/server.crt" -days 3650 -nodes \
    -subj "/CN=$SERVER_IP/O=Dreamcore/C=US" \
    -addext "subjectAltName=IP:$SERVER_IP,IP:127.0.0.1,DNS:localhost"
  openssl pkcs12 -export -out "$PROJECT_DIR/infra/certs/server.pfx" -inkey "$PROJECT_DIR/infra/certs/server.key" -in "$PROJECT_DIR/infra/certs/server.crt" -password pass:password
  sudo chown -R "$USER":"$USER" "$PROJECT_DIR/infra/certs"
  chmod 600 "$PROJECT_DIR/infra/certs/server.key"
  chmod 644 "$PROJECT_DIR/infra/certs/server.crt"
fi

mkdir -p "$BACKEND_DIR/app"
mkdir -p "$BACKEND_DIR/loader-src"
mkdir -p "$ADMIN_DIR"

echo "[4/8] Writing .env..."
[ -d "$PROJECT_DIR/.env" ] && rm -rf "$PROJECT_DIR/.env"
cat > "$PROJECT_DIR/.env" <<EOF
POSTGRES_DB=${POSTGRES_DB}
POSTGRES_USER=${POSTGRES_USER}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ROOT_USER=${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
JWT_SECRET=${JWT_SECRET}
MANIFEST_SIGNING_KEY=${MANIFEST_SIGNING_KEY}
ADMIN_TOKEN=${ADMIN_TOKEN}
DREAMCORE_SERVER_IP=${SERVER_IP}
EOF

echo "[5/8] Writing docker-compose.yml..."
cat > "$PROJECT_DIR/docker-compose.yml" <<'EOF'
services:
  postgres:
    image: postgres:17-alpine
    container_name: dreamcore-postgres
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./infra/postgres-data:/var/lib/postgresql
      - ./infra/postgres-initdb:/docker-entrypoint-initdb.d:ro
    ports:
      - "127.0.0.1:5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10

  redis:
    image: redis:8-alpine
    container_name: dreamcore-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - ./infra/redis:/data
    ports:
      - "127.0.0.1:6379:6379"

  minio:
    image: minio/minio:latest
    container_name: dreamcore-minio
    restart: unless-stopped
    env_file: .env
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
    command: server /data --console-address ":9001"
    volumes:
      - ./infra/minio:/data
    ports:
      - "127.0.0.1:9000:9000"
      - "127.0.0.1:9001:9001"

  backend:
    build: ./backend
    container_name: dreamcore-backend
    restart: unless-stopped
    env_file: .env
    depends_on:
      - postgres
      - redis
      - minio
    volumes:
      - ./backend/app:/app/app
      - ./backend/loader-src:/app/loader-src:ro
      - ./.env:/app/.env:ro
    ports:
      - "127.0.0.1:8000:8000"

  nginx:
    image: nginx:stable-alpine
    container_name: dreamcore-nginx
    restart: unless-stopped
    depends_on:
      - backend
      - minio
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./infra/nginx/conf.d:/etc/nginx/conf.d:ro
      - ./infra/certs:/etc/nginx/certs:ro
      - ./admin:/usr/share/nginx/html:ro

networks:
  default:
    name: dreamcore-net
EOF

cat > "$PROJECT_DIR/infra/nginx/conf.d/default.conf" <<'EOF'
server {
    listen 80;
    server_name _;
    client_max_body_size 100M;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;
    client_max_body_size 100M;

    ssl_certificate /etc/nginx/certs/server.crt;
    ssl_certificate_key /etc/nginx/certs/server.key;
    ssl_protocols TLSv1.2 TLSv1.3;

    location /api/ {
        proxy_pass http://backend:8000/api/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /files/ {
        proxy_pass http://minio:9000/;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        try_files $uri $uri/ /index.html =404;
    }
}
EOF

cat > "$PROJECT_DIR/infra/postgres-initdb/001-init.sql" <<'EOF'
create extension if not exists pgcrypto;

create table if not exists licenses (
    id uuid primary key default gen_random_uuid(),
    license_key text not null unique,
    username text not null,
    hwid text,
    status text not null default 'active',
    expires_at timestamptz,
    is_lifetime boolean not null default false,
    allow_release boolean not null default true,
    allow_nightly boolean not null default false,
    preferred_channel text,
    bound_at timestamptz,
    last_seen_at timestamptz,
    last_region text,
    last_ip text,
    created_by text,
    force_rebuild boolean not null default false,
    compile_status text not null default 'ready',
    allocated_days integer,
    last_bound_version text,
    updated_at timestamptz not null default now(),
    created_at timestamptz not null default now()
);


create table if not exists builds (
    id uuid primary key default gen_random_uuid(),
    channel text not null check (channel in ('release','nightly')),
    version text not null,
    manifest_path text not null,
    required_loader_version text not null default '1.0.0',
    notes text not null default '',
    is_active boolean not null default true,
    updated_at timestamptz not null default now(),
    created_at timestamptz not null default now()
);

create table if not exists channel_announcements (
    channel text primary key check (channel in ('release','nightly')),
    public_version text not null,
    loader_version text not null default '1.0.0',
    notes_json jsonb not null default '[]'::jsonb,
    updated_at timestamptz not null default now(),
    created_at timestamptz not null default now()
);

create table if not exists build_files (
    id uuid primary key default gen_random_uuid(),
    build_id uuid not null references builds(id) on delete cascade,
    file_name text not null,
    object_path text not null,
    sha256 text not null,
    file_size bigint not null,
    created_at timestamptz not null default now()
);

create table if not exists sessions (
    id uuid primary key default gen_random_uuid(),
    session_token text not null unique,
    previous_session_token text,
    previous_token_expires_at timestamptz,
    license_key text not null,
    hwid text not null,
    channel text not null,
    loader_version text,
    region text,
    ip text,
    user_agent text,
    device_snapshot jsonb not null default '{}'::jsonb,
    refresh_count integer not null default 0,
    logout_at timestamptz,
    logout_reason text,
    expires_at timestamptz not null,
    last_seen_at timestamptz not null default now(),
    created_at timestamptz not null default now()
);

create table if not exists admin_users (
    nickname text primary key,
    invite_code text not null unique,
    role text not null check (role in ('owner', 'loader dev', 'emulator dev', 'user')),
    avatar_url text,
    created_at timestamptz not null default now()
);

drop table if exists topics cascade;
create table if not exists topics (
    id bigint primary key,
    title text not null,
    category text,
    author text,
    status text default 'pending',
    votes int default 0,
    msgs jsonb default '[]'::jsonb,
    created_at timestamptz not null default now()
);

create table if not exists audit_log (
    id bigserial primary key,
    actor text not null,
    action text not null,
    details jsonb,
    created_at timestamptz not null default now()
);

create table if not exists session_logs (
    id bigserial primary key,
    license_key text,
    event_type text not null,
    reason text,
    suspected_tamper boolean not null default false,
    region text,
    ip text,
    details jsonb,
    created_at timestamptz not null default now()
);

insert into channel_announcements (channel, public_version, loader_version, notes_json)
values
    ('release', '1.2', '1.0.0', '["Refined settings sheet layout","Stabilized branch switching","Improved launch state UX"]'::jsonb),
    ('nightly', '1.4', '1.0.0', '["Finalized launch service handshake","Added session-bound terminal startup","Cleaned overlay completion flow"]'::jsonb)
on conflict (channel) do nothing;
EOF

cat > "$BACKEND_DIR/requirements.txt" <<'EOF'
fastapi==0.116.1
uvicorn[standard]==0.35.0
psycopg[binary]==3.2.10
python-dotenv==1.1.1
minio==7.2.16
pydantic==2.11.7
python-multipart==0.0.20
cryptography==44.0.1
psutil==6.1.1
cmake==3.28.1
EOF

cat > "$BACKEND_DIR/Dockerfile" <<'EOF'
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends build-essential curl ca-certificates cmake mingw-w64 osslsigncode && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]
EOF

cat > "$BACKEND_DIR/app/main.py" <<'EOF'
import base64
import hashlib
import hmac
import io
import json
import os
import platform
import secrets
import subprocess
import threading
import uuid
from datetime import timedelta
from datetime import datetime, timezone

import psycopg
from dotenv import load_dotenv
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
from minio import Minio
from pydantic import BaseModel

load_dotenv("/app/.env")

POSTGRES_DB = os.environ["POSTGRES_DB"]
POSTGRES_USER = os.environ["POSTGRES_USER"]
POSTGRES_PASSWORD = os.environ["POSTGRES_PASSWORD"]
MINIO_ROOT_USER = os.environ["MINIO_ROOT_USER"]
MINIO_ROOT_PASSWORD = os.environ["MINIO_ROOT_PASSWORD"]
MANIFEST_SIGNING_KEY = os.environ["MANIFEST_SIGNING_KEY"]
SERVER_IP = os.environ["DREAMCORE_SERVER_IP"]
ADMIN_TOKEN = os.environ["ADMIN_TOKEN"]
TLS_CERT_SHA256 = os.environ.get("TLS_CERT_SHA256", "")
CURRENT_LOADER_VERSION = "1.0.0"
SESSION_TTL = timedelta(minutes=30)
PREVIOUS_TOKEN_GRACE = timedelta(hours=6)
BOOTSTRAP_PRIVATE_KEY_PEM = """-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCY1f1iaH1r0UYE
d6QgUQH4to9QAL0Gwrro877J5hbMCGLGGVWEHAnkCXbREYphL9y/Q+Uj399JO+Qc
6jwlY6t0wExSdyPesyqGwOPVyhSSxtDkY4ZPaGmoceP992FXE8pRXkLbPZPhij5a
nwDzeFQmgme/fC0XFZq5hLp5NEkNaF11EkBUuXnGB7rSHTDEXuzIEPBpIoXQumtB
VAY8UHbyopiRIhQfs+EkBxjwGOJLRCeD3hnXgiXGobbVDfnEoIekTfUZIkZI6x5O
GD1ZyeRty/XtIRTo6cgnD2Q/GIfW/Ru/vpFIoFD588pCyF1LRgfUp7pdy/H4mZxx
veMGQBrTAgMBAAECggEADj/OjsO7hHdaRfqvmHxk/ilrJXmEJiK2psG+6nejbXoP
V3Uvi2XKfZ0Xm6Nn2ZV1nnbNNhyJEVII+7HSUhFqVV7DtDaREhIgkg1e0I+1flUU
pGPWYbCUP+pVEthjFhQYG0ukrmhzJqaAbMDWp7HIIApYHjq3a30N/rjuDR8qURpi
d2Xv3ST4dS57xXkF0tGqrVfFE2nqOmynrir+rl+Mde1IXlmxDtTr4gWrISDT6/tA
aA2nM8+IOarRzeIN5F31dipxEhiGbC8xgZWQQJsB6DeU0odD3FUFggRHnZEE5wgT
LQfztfqsi10k9oJPI0eVDDRwl+PLs827Ob62lhPMHQKBgQDLlZ41SPB70czzIH2/
VvHc89CuK/mFnFehSfifbB6Jr0SFfyy048T2ZcgxC20AzlsNbPz1bdZKH5R/jl3c
P/hB7upO8copf6bkdIuMDxvpetSzT794231ItN/FZk1VpfLgYqeCZQrHPu/hD6wA
MR3kpfyqItwmiDD5HlUSSk9eHQKBgQDAL4CWYR233D0kaiIxQReOcU46WsEKW4Xf
pD8JUySFBgM1J7NhZ6ITPUT7SUouVfIZv5c7RPIHE+uLq3+9f+pJ71nyZfvW9Jcs
wJGfUVECaxmg3YXDwpYIJgviKx3P4JHr/dhQTPmPXwH4T6qbK1/XGwaeHal8rMH5
2EvpymLJrwKBgD42Q3lvB6Yez62AQU0GSbTGgP+oioCs7Q5pp2M4TACxIZRV75h7
fgX5xnpOTooPhT2OD6MEZJaUHfH41G/o0Hl9g/aJ5shVrO9lOfub5mCr23HMLevF
zvw34aXWBE3m/1hYbW3oaKnIbs9s1ZfdapAjtRlcu7++oJCQF1kWgjh1AoGADbrX
CudBQkNfstxKOQ6Xaju6BP060Uyckc+gGUBxWXeGfyOo8wp+T7WO2gzwWcMWGrTP
TxKr38mSiFXmOFmNGV8aI/EZPtAWhMH1JVaf3PZdzFpHFicupMJjEvNVm+ZFRoDK
FWKKaa217FF0tMUygaGSpXUlCJ0m9nx3X+pviE0CgYEAi1NBJfAthj37ZUCzkKP6
MaDfwi0r+jj7UXndxhpsG6aqgKepLWvuLAJVTZmX0J78B116GtkXYBG3gu9hF7ii
ViULFXMH5Vv21XN7aYl3bcNGrmkv4k8P59igAywAp0rWzcvLJ+ftBe3LH8UdLiyK
EN+82bfIp2oDZ1fT5VfzgUY=
-----END PRIVATE KEY-----"""

app = FastAPI(title="Dreamcore Backend", version="1.0.0")


def db():
    return psycopg.connect(
        host="postgres",
        dbname=POSTGRES_DB,
        user=POSTGRES_USER,
        password=POSTGRES_PASSWORD,
        row_factory=psycopg.rows.dict_row,
    )


def minio_client():
    client = Minio(
        "minio:9000",
        access_key=MINIO_ROOT_USER,
        secret_key=MINIO_ROOT_PASSWORD,
        secure=False,
    )
    try:
        bucket = "dreamcore-avatars"
        if not client.bucket_exists(bucket):
            client.make_bucket(bucket)
        
        client.set_bucket_policy(bucket, json.dumps({
            "Version": "2012-10-17",
            "Statement": [{
                "Effect": "Allow",
                "Principal": {"AWS": ["*"]},
                "Action": ["s3:GetObject"],
                "Resource": [f"arn:aws:s3:::{bucket}/*"]
            }]
        }))
    except Exception:
        pass
    return client


def require_admin(token: str | None) -> None:
    if token != ADMIN_TOKEN:
        raise HTTPException(status_code=401, detail="invalid admin token")


def real_client_ip(request: Request) -> str | None:
    cf_ip = request.headers.get("cf-connecting-ip")
    if cf_ip:
        return cf_ip.strip()
    forwarded = request.headers.get("x-forwarded-for")
    if forwarded:
        return forwarded.split(",")[0].strip()
    real_ip = request.headers.get("x-real-ip")
    if real_ip:
        return real_ip.strip()
    return request.client.host if request.client else None


def normalize_channel(value: str) -> str:
    lowered = (value or "").strip().lower()
    if lowered == "release":
        return "release"
    if lowered in ("nightly", "nightmare"):
        return "nightly"
    raise HTTPException(status_code=400, detail="invalid channel")


def channel_label(channel: str) -> str:
    return "Nightly" if channel == "nightly" else "Release"


def sign_manifest(version: str, channel: str, manifest_path: str) -> str:
    payload = f"{version}|{channel}|{manifest_path}"
    return hmac.new(MANIFEST_SIGNING_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()


def sign_bootstrap_payload(payload: str) -> str:
    private_key = serialization.load_pem_private_key(BOOTSTRAP_PRIVATE_KEY_PEM.encode("utf-8"), password=None)
    signature = private_key.sign(payload.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())
    return base64.b64encode(signature).decode("ascii")


def build_bootstrap_payload(server_base_url: str, tls_cert_sha256: str, issued_at: str, expires_at: str) -> str:
    return (
        f"server_base_url\n{server_base_url}\n"
        f"tls_cert_sha256\n{tls_cert_sha256}\n"
        f"issued_at\n{issued_at}\n"
        f"expires_at\n{expires_at}\n"
    )


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def generate_license_key() -> str:
    return "-".join(secrets.token_hex(2).upper() for _ in range(4))


def normalize_license_key(value: str) -> str:
    return "".join(ch for ch in value.upper() if ch.isalnum())


def format_license_key(value: str) -> str:
    normalized = normalize_license_key(value)
    if len(normalized) != 16:
        return value.upper()
    return "-".join(normalized[index:index + 4] for index in range(0, 16, 4))


def utc_now() -> datetime:
    return datetime.now(timezone.utc)


def compute_expires_at(days: int | None, lifetime: bool):
    if lifetime:
        return None, True
    if days is None or days <= 0:
        raise HTTPException(status_code=400, detail="days must be > 0 or lifetime must be true")
    return utc_now() + timedelta(days=days), False


def read_server_stats() -> dict:
    import psutil
    cpu_load = psutil.cpu_percent()
    mem = psutil.virtual_memory()
    try:
        load1 = os.getloadavg()[0]
    except (AttributeError, OSError):
        load1 = 0
        
    return {
        "cpu_load_percent": cpu_load,
        "memory_used_gb": round(mem.used / (1024**3), 1),
        "memory_used_percent": mem.percent,
        "load1": round(load1, 2),
        "system_name": platform.system() + " " + platform.release(),
        "cpu_count": psutil.cpu_count(),
        "server_ip": SERVER_IP
    }


def build_device_snapshot(payload: "LoginRequest") -> dict:
    return {
        "machine_name": payload.machine_name or "",
        "cpu": payload.cpu or "",
        "gpu": payload.gpu or "",
        "ram_gb": payload.ram_gb or "",
        "os_version": payload.os_version or "",
    }


def record_session_log(cur, license_key, event_type, reason, suspected_tamper, region, ip, source, details):
    cur.execute(
        """
        insert into session_logs (license_key, event_type, reason, suspected_tamper, region, ip, details)
        values (%s, %s, %s, %s, %s, %s, %s::jsonb)
        """,
        (
            license_key,
            event_type,
            reason,
            suspected_tamper,
            region,
            ip,
            json.dumps({"source": source, **(details or {})}),
        ),
    )


def get_channel_announcement(cur, channel: str):
    cur.execute(
        """
        select channel, public_version, loader_version, notes_json, updated_at
        from channel_announcements
        where channel = %s
        """,
        (channel,),
    )
    return cur.fetchone()


def upsert_channel_announcement(cur, channel: str, public_version: str, loader_version: str, notes: list[str]):
    clean_notes = [str(item).strip() for item in notes if str(item).strip()]
    cur.execute(
        """
        insert into channel_announcements (channel, public_version, loader_version, notes_json, updated_at)
        values (%s, %s, %s, %s::jsonb, now())
        on conflict (channel) do update
        set public_version = excluded.public_version,
            loader_version = excluded.loader_version,
            notes_json = excluded.notes_json,
            updated_at = now()
        """,
        (channel, public_version, loader_version, json.dumps(clean_notes)),
    )


def build_manifest_bundle(channel: str, version: str, required_loader_version: str, file_pairs: list[tuple[str, bytes]]):
    build_id = str(uuid.uuid4())
    object_prefix = f"{channel}/{version}/{build_id}"
    manifest_name = f"{channel}-{version}-{build_id}.json"
    files = []
    for file_name, data in file_pairs:
        object_path = f"{object_prefix}/{file_name}"
        files.append(
            {
                "file_name": file_name,
                "object_path": object_path,
                "sha256": sha256_bytes(data),
                "file_size": len(data),
                "url": f"https://{SERVER_IP}/files/dreamcore-{channel}/{object_path}",
            }
        )

    manifest = {
        "version": version,
        "channel": channel,
        "build_id": build_id,
        "required_loader_version": required_loader_version,
        "generated_at": utc_now().isoformat(),
        "files": files,
    }
    manifest["signature"] = sign_manifest(version, channel, manifest_name)
    return manifest_name, files, json.dumps(manifest, indent=2, ensure_ascii=False).encode("utf-8")


def xor_crypt(data: bytes, key: str) -> bytes:
    if not key:
        return data
    key_bytes = key.encode()
    key_len = len(key_bytes)
    return bytes(data[i] ^ key_bytes[i % key_len] for i in range(len(data)))


def upload_bundle(channel: str, version: str, required_loader_version: str, notes: str, file_pairs: list[tuple[str, bytes]]) -> dict:
    manifest_name, files, manifest_bytes = build_manifest_bundle(channel, version, required_loader_version, file_pairs)
    client = minio_client()

    for file_name, data in file_pairs:
        bucket = f"dreamcore-{channel}"
        if not client.bucket_exists(bucket):
            client.make_bucket(bucket)
        
        entry = next(item for item in files if item["file_name"] == file_name)
        
        client.put_object(
            bucket,
            entry["object_path"],
            io.BytesIO(data),
            len(data),
            content_type="application/octet-stream",
        )

    if not client.bucket_exists("dreamcore-manifests"):
        client.make_bucket("dreamcore-manifests")
        
    client.put_object(
        "dreamcore-manifests",
        manifest_name,
        io.BytesIO(manifest_bytes),
        len(manifest_bytes),
        content_type="application/json",
    )

    with db() as conn, conn.cursor() as cur:
        # Check for existing build (Silent Update)
        cur.execute(
            "select id, manifest_path from builds where channel = %s and version = %s",
            (channel, version),
        )
        existing = cur.fetchone()

        cur.execute("update builds set is_active = false where channel = %s", (channel,))
        note_lines = [line.strip() for line in notes.splitlines() if line.strip()]
        # Preserve existing announcement notes if upload notes are empty
        if not note_lines:
            existing_announcement = get_channel_announcement(cur, channel)
            if existing_announcement and existing_announcement["notes_json"]:
                note_lines = existing_announcement["notes_json"]
        upsert_channel_announcement(cur, channel, version, required_loader_version, note_lines)

        if existing:
            build_id = existing["id"]
            # Cleanup old manifest if it's different
            if existing["manifest_path"] != manifest_name:
                try:
                    client.remove_object("dreamcore-manifests", existing["manifest_path"])
                except:
                    pass
            
            cur.execute(
                """
                update builds 
                set manifest_path = %s, 
                    required_loader_version = %s, 
                    notes = %s, 
                    is_active = true, 
                    updated_at = now() 
                where id = %s
                """,
                (manifest_name, required_loader_version, notes, build_id),
            )
            # Replace files
            cur.execute("delete from build_files where build_id = %s", (build_id,))
        else:
            cur.execute(
                """
                insert into builds (id, channel, version, manifest_path, required_loader_version, notes, is_active, updated_at, created_at)
                values (gen_random_uuid(), %s, %s, %s, %s, %s, true, now(), now())
                returning id
                """,
                (channel, version, manifest_name, required_loader_version, notes),
            )
            build_id = cur.fetchone()["id"]

        for entry in files:
            cur.execute(
                """
                insert into build_files (id, build_id, file_name, object_path, sha256, file_size)
                values (gen_random_uuid(), %s, %s, %s, %s, %s)
                """,
                (build_id, entry["file_name"], entry["object_path"], entry["sha256"], entry["file_size"]),
            )
        
        action = "build.update" if existing else "build.upload"
        cur.execute(
            "insert into audit_log (actor, action, details) values (%s, %s, %s::jsonb)",
            ("admin", action, json.dumps({"channel": channel, "version": version, "required_loader_version": required_loader_version, "notes": notes})),
        )
        conn.commit()

    return {
        "channel": channel,
        "version": version,
        "manifest_path": manifest_name,
        "required_loader_version": required_loader_version,
        "file_count": len(files),
        "silent_update": existing is not None
    }


def get_session_by_token(cur, session_token: str):
    cur.execute(
        """
        select s.*, l.username, l.status, l.expires_at as license_expires_at, l.allow_release, l.allow_nightly
        from sessions s
        join licenses l on l.license_key = s.license_key
        where s.logout_at is null
          and (
                s.session_token = %s
             or (s.previous_session_token = %s and s.previous_token_expires_at > now())
          )
        order by case when s.session_token = %s then 0 else 1 end
        limit 1
        """,
        (session_token, session_token, session_token),
    )
    return cur.fetchone()


class CreateLicense(BaseModel):
    username: str
    days: int | None = None
    lifetime: bool = False
    allow_nightly: bool = False


class BlockLicense(BaseModel):
    license_key: str
    blocked: bool


class LoginRequest(BaseModel):
    license_key: str
    hwid: str
    channel: str
    loader_version: str | None = None
    region: str | None = None
    machine_name: str | None = None
    cpu: str | None = None
    gpu: str | None = None
    ram_gb: str | None = None
    os_version: str | None = None


class ManifestRequest(BaseModel):
    session_token: str
    channel: str
    loader_version: str | None = None
    region: str | None = None


class SessionTokenRequest(BaseModel):
    session_token: str
    channel: str | None = None
    source: str | None = None
    details: str | None = None


class SessionEventRequest(BaseModel):
    session_token: str
    source: str | None = None
    event_type: str
    reason: str | None = None
    suspected_tamper: bool = False
    details: str | None = None
    region: str | None = None
    severity: str | None = None
    artifact_name: str | None = None
    indicator_type: str | None = None
    indicator_value: str | None = None


class SessionLogoutRequest(BaseModel):
    session_token: str
    source: str | None = None
    reason: str | None = None


class PublishRequest(BaseModel):
    version: str
    channel: str
    manifest_path: str
    required_loader_version: str = CURRENT_LOADER_VERSION
    notes: str = ""


class CleanupBuildRequest(BaseModel):
    keep_per_channel: int = 3


class BuilderSubmission(BaseModel):
    license_key: str
    hwid: int
    loader_version: str
    region: str = "en"
    machine_name: str = "Unknown"
    os_version: str = "Unknown"


class UpdateChannelStateRequest(BaseModel):
    channel: str
    public_version: str
    loader_version: str = CURRENT_LOADER_VERSION
    notes: list[str] = []


class DeleteLogsRequest(BaseModel):
    ids: list[int] = []


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


@app.get("/api/bootstrap/trust")
def bootstrap_trust(request: Request):
    if not TLS_CERT_SHA256:
        raise HTTPException(status_code=503, detail="tls pin unavailable")

    issued_at = utc_now().isoformat()
    expires_at = (utc_now() + timedelta(days=30)).isoformat()
    forwarded_host = (request.headers.get("x-forwarded-host") or "").strip()
    host_header = forwarded_host or (request.headers.get("host") or "").strip()
    hostname = host_header.split(":", 1)[0].strip()
    if not hostname:
        hostname = SERVER_IP
    server_base_url = f"https://{hostname}"
    payload = build_bootstrap_payload(server_base_url, TLS_CERT_SHA256, issued_at, expires_at)
    return {
        "server_base_url": server_base_url,
        "tls_cert_sha256": TLS_CERT_SHA256,
        "issued_at": issued_at,
        "expires_at": expires_at,
        "signature": sign_bootstrap_payload(payload),
    }


@app.post("/api/client/updates/current")
def client_updates_current(
    payload: ManifestRequest, 
    request: Request,
    x_dream_challenge: str | None = Header(default=None),
    x_dream_timestamp: str | None = Header(default=None)
):
    channel = normalize_channel(payload.channel)
    with db() as conn, conn.cursor() as cur:
        row = get_session_by_token(cur, payload.session_token)
        if row is None:
            raise HTTPException(status_code=401, detail="invalid session token")
        
        verify_anti_emulation(row, x_dream_challenge, x_dream_timestamp, payload.region or row["region"], real_client_ip(request), "DreamcoreLoader", cur)

        announcement = get_channel_announcement(cur, channel)
        if announcement is None:
            raise HTTPException(status_code=404, detail="channel announcement missing")
        cur.execute(
            "update sessions set last_seen_at = now(), region = %s, ip = %s where id = %s",
            (payload.region or row["region"], real_client_ip(request) or row["ip"], row["id"]),
        )
        conn.commit()
        return {
            "channel": channel,
            "public_version": announcement["public_version"],
            "loader_version": announcement["loader_version"],
            "updated_at": announcement["updated_at"].isoformat() if announcement["updated_at"] else "",
            "notes": announcement["notes_json"] or [],
        }


def require_staff(token: str | None, cur):
    if token == ADMIN_TOKEN:
        cur.execute("select * from admin_users where invite_code = %s", (token,))
        u = cur.fetchone()
        if u: return u
        return {"nickname": "Owner", "role": "owner", "avatar_url": None}
    if not token: raise HTTPException(status_code=401)
    cur.execute("select * from admin_users where invite_code = %s", (token,))
    u = cur.fetchone()
    if not u or u["role"] not in ["owner", "emulator dev", "loader dev"]: raise HTTPException(status_code=403)
    return u



def require_admin(token: str | None) -> None:
    if token != ADMIN_TOKEN:
        # Check if it's a valid admin token from DB
        with db() as conn, conn.cursor() as cur:
            cur.execute("select * from admin_users where invite_code = %s", (token,))
            u = cur.fetchone()
            if not u or u["role"] not in ["owner", "emulator dev", "loader dev"]:
                raise HTTPException(status_code=401, detail="invalid admin token")
    return

@app.get("/api/admin/overview")
def admin_overview(x_admin_token: str | None = Header(default=None)):
    with db() as conn, conn.cursor() as cur:
        require_staff(x_admin_token, cur)
        cur.execute("select count(*) as c from licenses")
        total_keys = cur.fetchone()["c"]
        cur.execute("select count(*) as c from licenses where hwid is not null")
        activated_keys = cur.fetchone()["c"]
        cur.execute("select count(*) as c from licenses where status = 'blocked'")
        blocked_keys = cur.fetchone()["c"]
        cur.execute("select count(*) as c from sessions where last_seen_at >= now() - interval '5 minutes'")
        active_sessions = cur.fetchone()["c"]
        cur.execute("select count(*) as c from builds where is_active = true")
        active_builds = cur.fetchone()["c"]
        cur.execute("select count(*) as c from session_logs where suspected_tamper = true")
        tamper_events = cur.fetchone()["c"]
    return {
        "total_keys": total_keys,
        "activated_keys": activated_keys,
        "blocked_keys": blocked_keys,
        "active_builds": active_builds,
        "online_users": active_sessions,
        "active_sessions": active_sessions,
        "tamper_events": tamper_events,
        "loader_version": CURRENT_LOADER_VERSION,
    }

def run_user_build(license_key: str, hwid: str):
    """
    Actually compiles the loader with the given HWID burned in.
    Spawns builder.py as a subprocess to handle compilation.
    """
    try:
        src_dir = "/app/loader-src"
        if not os.path.exists(src_dir):
            with db() as conn, conn.cursor() as cur:
                cur.execute("update licenses set compile_status = 'failed' where license_key = %s", (license_key,))
                conn.commit()
            return

        output_dir = f"/app/personal-builds/{license_key}"
        os.makedirs(output_dir, exist_ok=True)

        # Spawn builder.py subprocess
        import subprocess
        import os as os_module

        env = os_module.environ.copy()
        env["DREAMCORE_PFX_PATH"] = "/app/certs/server.pfx"
        env["DREAMCORE_PFX_PASS"] = os_module.environ.get("PFX_PASSWORD", "password")
        env["DREAMCORE_SERVER_IP"] = os_module.environ.get("DREAMCORE_SERVER_IP", "127.0.0.1")

        subprocess.Popen([
            "python3", "/app/app/builder.py",
            "--license", license_key,
            "--hwid", str(hwid),
            "--source", src_dir,
            "--output", output_dir
        ], env=env)

    except Exception as e:
        print(f"[!] Build spawn error for {license_key}: {str(e)}")
        with db() as conn, conn.cursor() as cur:
            cur.execute("update licenses set compile_status = 'failed' where license_key = %s", (license_key,))
            conn.commit()

@app.get("/api/admin/server/stats")
def admin_server_stats(x_admin_token: str | None = Header(default=None)):
    require_admin(x_admin_token)
    return read_server_stats()

@app.get("/api/admin/licenses")
def admin_licenses(x_admin_token: str | None = Header(default=None)):
    with db() as conn, conn.cursor() as cur:
        require_staff(x_admin_token, cur)
        cur.execute("""
            select license_key as key, username, hwid as hw, status, expires_at, is_lifetime,
                   allow_release, allow_nightly, last_region as region, created_by, created_at
            from licenses
            order by created_at desc
            limit 300
        """)
        res = []
        for r in cur.fetchall():
            r["expires"] = "Lifetime" if r["is_lifetime"] else (r["expires_at"].strftime('%Y-%m-%d') if r["expires_at"] else "Inactive")
            res.append(r)
        return {"items": res}




@app.get("/api/admin/users")
def admin_users(x_admin_token: str | None = Header(default=None)):
    require_admin(x_admin_token)
    with db() as conn, conn.cursor() as cur:
        cur.execute("""
            select l.username,
                   l.license_key,
                   l.hwid,
                   l.preferred_channel,
                   l.bound_at,
                   l.last_seen_at,
                   l.last_region,
                   l.last_ip,
                   l.status,
                   last_session.logout_reason,
                   last_session.logout_at
            from licenses l
            left join lateral (
                select logout_reason, logout_at
                from sessions s
                where s.license_key = l.license_key and s.logout_at is not null
                order by s.logout_at desc nulls last, s.created_at desc
                limit 1
            ) as last_session on true
            where l.hwid is not null
            order by l.last_seen_at desc nulls last, l.bound_at desc nulls last
            limit 300
        """)
        items = []
        for row in cur.fetchall():
            items.append({
                "username": row["username"],
                "hwid": row["hwid"] or "",
                "branch": row["preferred_channel"] or "release",
                "region": row["last_region"] or "",
                "pulse": row["last_seen_at"].strftime("%H:%M:%S") if row["last_seen_at"] else "—",
                "logout_reason": row["logout_reason"] or "",
            })
        return {"items": items}


@app.get("/api/admin/builds")
def admin_builds(x_admin_token: str | None = Header(default=None)):
    require_admin(x_admin_token)
    with db() as conn, conn.cursor() as cur:
        cur.execute("""
            select b.channel, b.version, b.manifest_path, b.required_loader_version, b.notes, b.is_active, b.updated_at, b.created_at, count(f.id) as file_count
            from builds b
            left join build_files f on f.build_id = b.id
            group by b.id
            order by b.updated_at desc, b.created_at desc
            limit 100
        """)
        items = []
        for row in cur.fetchall():
            items.append({
                "channel": channel_label(row["channel"]),
                "version": row["version"],
                "loader": row["required_loader_version"],
                "notes": row["notes"] or "",
                "date": row["created_at"].strftime("%Y-%m-%d %H:%M") if row["created_at"] else "",
            })
        return {"items": items}


@app.get("/api/admin/updates/state")
def admin_updates_state(x_admin_token: str | None = Header(default=None)):
    require_admin(x_admin_token)
    with db() as conn, conn.cursor() as cur:
        cur.execute(
            """
            select channel, public_version, loader_version, notes_json, updated_at
            from channel_announcements
            order by channel asc
            """
        )
        return {"items": cur.fetchall()}


@app.post("/api/admin/updates/state")
def admin_updates_state_save(payload: UpdateChannelStateRequest, x_admin_token: str | None = Header(default=None)):
    with db() as conn, conn.cursor() as cur:
        u = require_staff(x_admin_token, cur)
        channel = normalize_channel(payload.channel)
        upsert_channel_announcement(
            cur,
            channel,
            payload.public_version.strip(),
            payload.loader_version.strip() or CURRENT_LOADER_VERSION,
            payload.notes,
        )
        cur.execute(
            "insert into audit_log (actor, action, details) values (%s, %s, %s::jsonb)",
            (u["nickname"], "updates.state", json.dumps({
                "channel": channel,
                "version": payload.public_version.strip(),
            })),
        )
        conn.commit()
        return {"ok": True}


@app.post("/api/admin/license/block")
async def admin_license_block(request: Request, x_admin_token: str | None = Header(None)):
    payload = await request.json()
    key = payload.get("license_key")
    should_block = payload.get("blocked", True)
    status = "blocked" if should_block else "active"
    with db() as conn, conn.cursor() as cur:
        u = require_staff(x_admin_token, cur)
        cur.execute("update licenses set status = %s where license_key = %s", (status, key))
        cur.execute(
            "insert into audit_log (actor, action, details) values (%s, %s, %s::jsonb)",
            (u["nickname"], f"license.{status}", json.dumps({"license_key": key})),
        )
        conn.commit()
    return {"ok": True}

@app.post("/api/admin/license/revoke")
async def admin_license_revoke(request: Request, x_admin_token: str | None = Header(None)):
    payload = await request.json()
    key = payload.get("license_key")
    with db() as conn, conn.cursor() as cur:
        u = require_staff(x_admin_token, cur)
        cur.execute("delete from licenses where license_key = %s", (key,))
        cur.execute(
            "insert into audit_log (actor, action, details) values (%s, %s, %s::jsonb)",
            (u["nickname"], "license.revoke", json.dumps({"license_key": key})),
        )
        conn.commit()
    return {"ok": True}

@app.post("/api/auth/admin/login")
async def admin_login(request: Request):
    payload = await request.json()
    nick = payload.get("nickname")
    code = payload.get("invite_code")
    with db() as conn, conn.cursor() as cur:
        if code == ADMIN_TOKEN:
            cur.execute("select * from admin_users where nickname = %s", (nick,))
            if not cur.fetchone() and (nick == "Dreamcore" or nick == "Admin"):
                return {
                    "token": ADMIN_TOKEN,
                    "user": {"nick": nick, "role": "owner", "avatar": None, "lifetime": True, "days": 0}
                }
        
        # Invite claiming logic: if nickname starts with Pending and code matches, update nickname
        cur.execute("select * from admin_users where invite_code = %s", (code,))
        u = cur.fetchone()
        if u and u["nickname"].startswith("Pending_"):
            formatted_nick = nick.strip().capitalize()
            cur.execute("update admin_users set nickname = %s where nickname = %s", (formatted_nick, u["nickname"]))
            conn.commit()
            nick = formatted_nick
            cur.execute("select * from admin_users where nickname = %s", (nick,))
            u = cur.fetchone()

        if not u or u["nickname"] != nick or u["invite_code"] != code:
            raise HTTPException(status_code=401, detail="Invalid credentials or pending claim mismatch")
        
        return {
            "token": u["invite_code"],
            "user": {
                "nick": u["nickname"],
                "role": u["role"],
                "avatar": u["avatar_url"],
                "lifetime": True,
                "days": 0
            }
        }

@app.get("/api/auth/admin/me")
def admin_me(x_admin_token: str | None = Header(None)):
    if not x_admin_token: raise HTTPException(status_code=401)
    with db() as conn, conn.cursor() as cur:
        u = require_staff(x_admin_token, cur)
        return {
            "nick": u["nickname"],
            "role": u["role"],
            "avatar": u["avatar_url"],
            "lifetime": True,
            "days": 0
        }


@app.post("/api/admin/invites/create")
async def admin_create_invite(request: Request, x_admin_token: str | None = Header(None)):
    payload = await request.json()
    role = payload.get("role", "User").lower()
    with db() as conn, conn.cursor() as cur:
        require_admin(x_admin_token)
        code = f"DC-{uuid.uuid4().hex[:8].upper()}"
        cur.execute(
            "insert into admin_users (nickname, role, invite_code) values (%s, %s, %s)",
            (f"Pending_{code[3:]}", role, code)
        )
        conn.commit()
        return {"code": code}

@app.post("/api/users/avatar")
async def upload_avatar(file: UploadFile = File(...), x_admin_token: str | None = Header(None)):
    with db() as conn, conn.cursor() as cur:
        u = require_staff(x_admin_token, cur)
        ext = file.filename.split('.')[-1]
        blob_name = f"avatars/{uuid.uuid4()}.{ext}"
        content = await file.read()
        client = minio_client()
        bucket = "dreamcore-avatars"
        client.put_object(
            bucket, blob_name, io.BytesIO(content), len(content),
            content_type=file.content_type or "image/png"
        )
        url = f"/files/{bucket}/{blob_name}"
        # Only update if it's a persistent DB user (not the hardcoded Owner fallback)
        if u.get("nickname") not in ["Owner", "Admin"]:
            cur.execute("update admin_users set avatar_url = %s where nickname = %s", (url, u["nickname"]))
            conn.commit()
        return {"url": url}

@app.get("/api/admin/logs")
def admin_logs(x_admin_token: str | None = Header(default=None)):
    require_admin(x_admin_token)
    with db() as conn, conn.cursor() as cur:
        cur.execute("""
            (select sl.id,
                   coalesce(l.username, 'System') as username,
                   sl.event_type,
                   coalesce(sl.reason, '') as desc,
                   sl.suspected_tamper,
                   sl.ip,
                   sl.created_at
            from session_logs sl
            left join licenses l on l.license_key = sl.license_key)
            union all
            (select al.id + 10000000,
                   al.actor as username,
                   al.action as event_type,
                   al.details::text as desc,
                   false as suspected_tamper,
                   'Internal' as ip,
                   al.created_at
            from audit_log al)
            order by created_at desc
            limit 400
        """)
        items = []
        for row in cur.fetchall():
            items.append({
                "id": row["id"],
                "username": row["username"],
                "event": row["event_type"],
                "desc": row["desc"],
                "risk": "RISK" if row["suspected_tamper"] else "OK",
                "ip": row["ip"] or "—",
                "time": row["created_at"].strftime("%Y-%m-%d %H:%M") if row["created_at"] else "",
            })
        return {"items": items}


@app.post("/api/admin/logs/delete")
def admin_logs_delete(payload: DeleteLogsRequest, x_admin_token: str | None = Header(default=None)):
    require_admin(x_admin_token)
    if not payload.ids:
        return {"deleted": 0}
    with db() as conn, conn.cursor() as cur:
        cur.execute("delete from session_logs where id = any(%s)", (payload.ids,))
        deleted = cur.rowcount
        cur.execute(
            "insert into audit_log (actor, action, details) values (%s, %s, %s::jsonb)",
            ("admin", "logs.delete", json.dumps({"ids": payload.ids, "deleted": deleted})),
        )
        conn.commit()
        return {"deleted": deleted}


@app.post("/api/admin/license/create")
def admin_create_license(payload: CreateLicense, x_admin_token: str | None = Header(default=None)):
    with db() as conn, conn.cursor() as cur:
        staff = require_staff(x_admin_token, cur)
        if not payload.username.strip():
            raise HTTPException(status_code=400, detail="username is required")
        license_key = format_license_key(generate_license_key())
        created_by = staff["nickname"] if isinstance(staff, dict) else "System"
        expires_at, is_lifetime = compute_expires_at(payload.days, payload.lifetime)
        cur.execute(
            """
            insert into licenses (license_key, username, status, expires_at, is_lifetime, allow_release, allow_nightly, created_by)
            values (%s, %s, 'active', %s, %s, true, %s, %s)
            """,
            (license_key, payload.username.strip(), expires_at, is_lifetime, payload.allow_nightly, created_by),
        )
        cur.execute(
            "insert into audit_log (actor, action, details) values (%s, %s, %s::jsonb)",
            (created_by, "license.create", json.dumps({"license_key": license_key, "username": payload.username.strip(), "days": payload.days, "lifetime": payload.lifetime, "allow_nightly": payload.allow_nightly})),
        )
        conn.commit()
    return {"key": license_key}


@app.post("/api/admin/license/block")
def admin_block_license(payload: BlockLicense, x_admin_token: str | None = Header(default=None)):
    require_admin(x_admin_token)
    normalized_license_key = normalize_license_key(payload.license_key)
    status = "blocked" if payload.blocked else "active"
    with db() as conn, conn.cursor() as cur:
        cur.execute(
            "update licenses set status = %s where replace(upper(license_key), '-', '') = %s",
            (status, normalized_license_key),
        )
        if cur.rowcount == 0:
            raise HTTPException(status_code=404, detail="license not found")
        cur.execute(
            "insert into audit_log (actor, action, details) values (%s, %s, %s::jsonb)",
            ("admin", "license.block", json.dumps({"license_key": format_license_key(normalized_license_key), "status": status})),
        )
        conn.commit()
    return {"ok": True}


class RevokeLicense(BaseModel):
    license_key: str


@app.post("/api/admin/license/revoke")
def admin_revoke_license(payload: RevokeLicense, x_admin_token: str | None = Header(default=None)):
    require_admin(x_admin_token)
    normalized_license_key = normalize_license_key(payload.license_key)
    with db() as conn, conn.cursor() as cur:
        cur.execute(
            "select license_key from licenses where replace(upper(license_key), '-', '') = %s",
            (normalized_license_key,),
        )
        row = cur.fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="license not found")
        real_key = row["license_key"]
        cur.execute("delete from session_logs where license_key = %s", (real_key,))
        cur.execute("delete from sessions where license_key = %s", (real_key,))
        cur.execute("delete from licenses where license_key = %s", (real_key,))
        cur.execute(
            "insert into audit_log (actor, action, details) values (%s, %s, %s::jsonb)",
            ("admin", "license.revoke", json.dumps({"license_key": format_license_key(normalized_license_key)})),
        )
        conn.commit()
    return {"ok": True}


@app.post("/api/admin/build/upload")
async def admin_build_upload(
    x_admin_token: str | None = Header(default=None),
    channel: str = Form(...),
    version: str = Form(...),
    required_loader_version: str = Form(CURRENT_LOADER_VERSION),
    notes: str = Form(""),
    dream_driver: UploadFile = File(...),
    dream_session_watch: UploadFile = File(...),
):
    require_admin(x_admin_token)
    normalized_channel = normalize_channel(channel)
    version = version.strip()
    if not version:
        raise HTTPException(status_code=400, detail="version is required")

    result = upload_bundle(
        normalized_channel,
        version,
        required_loader_version.strip() or CURRENT_LOADER_VERSION,
        notes.strip(),
        [
            ("DreamDriver.exe", await dream_driver.read()),
            ("DreamSessionWatch.exe", await dream_session_watch.read()),
        ],
    )
    return {"ok": True, **result}


@app.post("/api/admin/build/publish")
def admin_publish(payload: PublishRequest, x_admin_token: str | None = Header(default=None)):
    require_admin(x_admin_token)
    channel = normalize_channel(payload.channel)
    with db() as conn, conn.cursor() as cur:
        cur.execute("update builds set is_active = false where channel = %s", (channel,))
        upsert_channel_announcement(
            cur,
            channel,
            payload.version,
            payload.required_loader_version,
            [line.strip() for line in payload.notes.splitlines() if line.strip()],
        )
        cur.execute(
            "insert into builds (channel, version, manifest_path, required_loader_version, notes, is_active, updated_at) values (%s, %s, %s, %s, %s, true, now())",
            (channel, payload.version, payload.manifest_path, payload.required_loader_version, payload.notes),
        )
        cur.execute(
            "insert into audit_log (actor, action, details) values (%s, %s, %s::jsonb)",
            ("admin", "build.publish", json.dumps({"channel": channel, "version": payload.version, "required_loader_version": payload.required_loader_version})),
        )
        conn.commit()
    return {"ok": True}

class DownloadInitRequest(BaseModel):
    identifier: str

class DownloadBindRequest(BaseModel):
    key: str
    hwid: str

@app.post("/api/download/init")
def download_init(payload: DownloadInitRequest):
    iden = payload.identifier.strip()
    with db() as conn, conn.cursor() as cur:
        # 1. Search in licenses
        cur.execute(
            "select * from licenses where replace(upper(license_key), '-', '') = %s or upper(username) = %s",
            (normalize_license_key(iden), iden.upper())
        )
        row = cur.fetchone()
        
        if row:
            # If we found it, verify if we need HWID bind
            if not row["hwid"] or row["last_bound_version"] != CURRENT_LOADER_VERSION:
                return {"key": row["license_key"], "action": "need_hwid"}
        
        # 2. If not found, check if it's an admin requesting their own build
        if not row:
            cur.execute("select nickname, role from admin_users where upper(nickname) = %s", (iden.upper(),))
            admin = cur.fetchone()
            if not admin and iden.upper() in ["DREAMCORE", "ADMIN"]:
                admin = {"nickname": iden.capitalize(), "role": "owner"}
            
            if admin:
                # No longer auto-creating licenses. Admin should create one themselves.
                pass
        
        if not row:
            raise HTTPException(status_code=404, detail="License or User not found")

        key = row["license_key"]
        
        # ACTUALLY START COMPILATION
        cur.execute("update licenses set compile_status = 'compiling', updated_at = now() where license_key = %s", (key,))
        conn.commit()
        
        # Build logic call
        threading.Thread(target=lambda: run_user_build(key, row["hwid"])).start()
        
        return {"key": key, "action": "compiling"}

@app.post("/api/download/bind")
def download_bind(payload: DownloadBindRequest):
    with db() as conn, conn.cursor() as cur:
        cur.execute("update licenses set hwid = %s where license_key = %s", (payload.hwid, payload.key))
        if cur.rowcount == 0:
            raise HTTPException(status_code=404, detail="Key not found")
        
        # When a key is bound, we can automatically trigger the build if compile_status is 'ready'
        # or we just let download_init do it on the next poll.
        conn.commit()
        return {"ok": True}

@app.get("/api/download/status/{key}")
def download_status(key: str):
    with db() as conn, conn.cursor() as cur:
        cur.execute("select compile_status, updated_at from licenses where license_key = %s", (key,))
        row = cur.fetchone()
        if not row:
            raise HTTPException(status_code=404)
        status = row["compile_status"] or "ready"
        if status == 'compiling':
            # Check if the build folder contains the file yet
            # For now, just keep the "compiling" status until ready
            # The background thread will update the DB.
            pass
        return {"status": status}

@app.get("/api/download/final/{key}")
def download_final(key: str):
    return {"url": f"https://{SERVER_IP}/files/dreamcore-personal-builds/{key}/DreamcoreLoader.exe"}


@app.post("/api/admin/build/cleanup")
def admin_build_cleanup(payload: CleanupBuildRequest, x_admin_token: str | None = Header(default=None)):
    require_admin(x_admin_token)
    keep_per_channel = max(payload.keep_per_channel, 1)
    removed = []
    client = minio_client()

    with db() as conn, conn.cursor() as cur:
        for channel in ("release", "nightly"):
            cur.execute(
                """
                select id, version, manifest_path
                from builds
                where channel = %s and is_active = false
                order by updated_at desc, created_at desc
                """,
                (channel,),
            )
            builds = cur.fetchall()
            for build in builds[keep_per_channel:]:
                cur.execute("select object_path from build_files where build_id = %s", (build["id"],))
                for file_row in cur.fetchall():
                    try:
                        client.remove_object(f"dreamcore-{channel}", file_row["object_path"])
                    except Exception:
                        pass
                try:
                    client.remove_object("dreamcore-manifests", build["manifest_path"])
                except Exception:
                    pass
                cur.execute("delete from build_files where build_id = %s", (build["id"],))
                cur.execute("delete from builds where id = %s", (build["id"],))
                removed.append({"channel": channel_label(channel), "version": build["version"], "manifest_path": build["manifest_path"]})

        cur.execute(
            "insert into audit_log (actor, action, details) values (%s, %s, %s::jsonb)",
            ("admin", "build.cleanup", json.dumps({"keep_per_channel": keep_per_channel, "removed": removed})),
        )
        conn.commit()

    return {"ok": True, "removed": removed}


@app.get("/api/admin/topics")
def get_topics(x_admin_token: str | None = Header(default=None)):
    with db() as conn, conn.cursor() as cur:
        require_staff(x_admin_token, cur)
        cur.execute("select id, title, category, author, status, votes, msgs from topics order by id desc")
        return cur.fetchall()


@app.post("/api/admin/topics")
async def save_topics(request: Request, x_admin_token: str | None = Header(default=None)):
    payload = await request.json()
    if not isinstance(payload, list):
        payload = [payload] # fallback
    with db() as conn, conn.cursor() as cur:
        require_staff(x_admin_token, cur)
        for t in payload:
            if not t.get("id"):
                continue
            cur.execute(
                """
                insert into topics (id, title, category, author, status, votes, msgs)
                values (%s, %s, %s, %s, %s, %s, %s::jsonb)
                on conflict (id) do update
                set title = excluded.title,
                    category = excluded.category,
                    author = excluded.author,
                    status = excluded.status,
                    votes = excluded.votes,
                    msgs = excluded.msgs
                """,
                (t.get("id"), t.get("title", ""), t.get("category", "General"), t.get("author", "Unknown"), t.get("status", "pending"), t.get("votes", 0), json.dumps(t.get("msgs", [])))
            )
        conn.commit()
    return {"ok": True}




def validate_license_row(row, channel: str, hwid: str) -> bool:
    if not row:
        return False
    if row["status"] != "active":
        return False
    if row["expires_at"] is not None:
        expires_at = row["expires_at"]
        if expires_at.tzinfo is None:
            expires_at = expires_at.replace(tzinfo=timezone.utc)
        if expires_at < datetime.now(timezone.utc):
            return False
    if channel == "release" and not row["allow_release"]:
        return False
    if channel == "nightly" and not row["allow_nightly"]:
        return False
    if row["hwid"] not in (None, hwid):
        return False
    return True


@app.post("/api/auth/license/login")
def license_login(payload: LoginRequest, request: Request):
    channel = normalize_channel(payload.channel)
    ip = real_client_ip(request)
    user_agent = request.headers.get("user-agent")
    snapshot = build_device_snapshot(payload)
    normalized_license_key = normalize_license_key(payload.license_key)
    with db() as conn, conn.cursor() as cur:
        cur.execute(
            """
            select license_key, hwid, status, expires_at, is_lifetime, allow_release, allow_nightly
            from licenses where replace(upper(license_key), '-', '') = %s
            """,
            (normalized_license_key,),
        )
        row = cur.fetchone()
        # Distinguish between "blocked" (hardware ban trigger) and other rejections
        if row and row["status"] == "blocked":
            record_session_log(cur, format_license_key(normalized_license_key), "login_banned", "license banned", True, payload.region, ip, "DreamcoreLoader", {"channel": channel, "loader_version": payload.loader_version})
            conn.commit()
            raise HTTPException(status_code=403, detail="license banned")
        if not validate_license_row(row, channel, payload.hwid):
            record_session_log(cur, format_license_key(normalized_license_key), "login_rejected", "license rejected", True, payload.region, ip, "DreamcoreLoader", {"channel": channel, "loader_version": payload.loader_version})
            conn.commit()
            raise HTTPException(status_code=403, detail="license rejected")
        if row["hwid"] is None:
            cur.execute("update licenses set hwid = %s, bound_at = now() where license_key = %s", (payload.hwid, row["license_key"]))
        session_token = secrets.token_hex(32)
        expires_at = utc_now() + SESSION_TTL
        cur.execute(
            """
            insert into sessions (session_token, license_key, hwid, channel, loader_version, region, ip, user_agent, device_snapshot, expires_at, last_seen_at)
            values (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, now())
            """,
            (session_token, row["license_key"], payload.hwid, channel, payload.loader_version, payload.region, ip, user_agent, json.dumps(snapshot), expires_at),
        )
        cur.execute(
            """
            update licenses
            set preferred_channel = %s,
                last_seen_at = now(),
                last_region = %s,
                last_ip = %s
            where license_key = %s
            """,
            (channel, payload.region, ip, row["license_key"]),
        )
        record_session_log(cur, row["license_key"], "login_ok", "license accepted", False, payload.region, ip, "DreamcoreLoader", {"channel": channel, "loader_version": payload.loader_version, "snapshot": snapshot, "user_agent": user_agent, "login_time": utc_now().isoformat()})
        conn.commit()
    return {
            "ok": True,
            "status": "active",
            "session_token": session_token,
            "expires_in_seconds": int(SESSION_TTL.total_seconds()),
            "nightly": row["allow_nightly"],
            "lifetime": row["is_lifetime"],
            "valid_until": int(row["expires_at"].astimezone(timezone.utc).timestamp()) if row["expires_at"] is not None else 0,
        }


def verify_anti_emulation(row, x_dream_challenge: str | None, x_dream_timestamp: str | None, region: str | None, ip: str | None, source: str, cur) -> None:
    if not x_dream_challenge or not x_dream_timestamp:
        record_session_log(cur, row["license_key"], "anti_emulation_fail", "missing challenge headers", True, region or row["region"], ip, source, {})
        raise HTTPException(status_code=403, detail="integrity check failed (0)")

    try:
        ts = int(x_dream_timestamp)
        now_ts = int(utc_now().timestamp())
        if abs(now_ts - ts) > 300: # 5 minute window
            raise ValueError("timestamp skew")
        
        # HMAC(key=session_token, message=hwid + "|" + timestamp)
        message = f"{row['hwid']}|{x_dream_timestamp}".encode()
        expected_hmac = hmac.new(row['session_token'].encode(), message, hashlib.sha256).hexdigest().upper()
        
        if not hmac.compare_digest(expected_hmac, x_dream_challenge.upper()):
            raise ValueError("invalid hmac")
    except Exception as e:
        record_session_log(cur, row["license_key"], "anti_emulation_fail", f"challenge verification failed: {str(e)}", True, region or row["region"], ip, source, {"challenge": x_dream_challenge, "ts": x_dream_timestamp})
        raise HTTPException(status_code=403, detail="integrity check failed (1)")


@app.post("/api/auth/session/heartbeat")
def session_heartbeat(
    payload: SessionTokenRequest, 
    request: Request,
    x_dream_challenge: str | None = Header(default=None),
    x_dream_timestamp: str | None = Header(default=None)
):
    with db() as conn, conn.cursor() as cur:
        row = get_session_by_token(cur, payload.session_token)
        if not row:
            raise HTTPException(status_code=403, detail="session rejected")
        
        verify_anti_emulation(row, x_dream_challenge, x_dream_timestamp, row["region"], real_client_ip(request), payload.source or "unknown", cur)

        if row["expires_at"] < utc_now():
            raise HTTPException(status_code=403, detail="session expired")
        cur.execute(
            "update sessions set last_seen_at = now(), ip = %s where id = %s",
            (real_client_ip(request), row["id"]),
        )
        conn.commit()
        return {"ok": True, "expires_in_seconds": max(int((row["expires_at"] - utc_now()).total_seconds()), 0)}


@app.post("/api/auth/session/refresh")
def session_refresh(
    payload: SessionTokenRequest, 
    request: Request,
    x_dream_challenge: str | None = Header(default=None),
    x_dream_timestamp: str | None = Header(default=None)
):
    with db() as conn, conn.cursor() as cur:
        row = get_session_by_token(cur, payload.session_token)
        if not row:
            raise HTTPException(status_code=403, detail="session rejected")
        
        verify_anti_emulation(row, x_dream_challenge, x_dream_timestamp, row["region"], real_client_ip(request), payload.source or "unknown", cur)

        if row["expires_at"] < utc_now():
            raise HTTPException(status_code=403, detail="session expired")

        channel = row["channel"]
        if payload.channel:
            channel = normalize_channel(payload.channel)
            if not validate_license_row(row, channel, row["hwid"]):
                raise HTTPException(status_code=403, detail="license rejected")

        new_token = secrets.token_hex(32)
        new_expires_at = utc_now() + SESSION_TTL
        cur.execute(
            """
            update sessions
            set previous_session_token = session_token,
                previous_token_expires_at = %s,
                session_token = %s,
                expires_at = %s,
                last_seen_at = now(),
                ip = %s,
                channel = %s,
                refresh_count = refresh_count + 1
            where id = %s
            """,
            (utc_now() + PREVIOUS_TOKEN_GRACE, new_token, new_expires_at, real_client_ip(request), channel, row["id"]),
        )
        conn.commit()
        return {"ok": True, "session_token": new_token, "expires_in_seconds": int(SESSION_TTL.total_seconds())}


@app.post("/api/auth/session/logout")
def session_logout(payload: SessionLogoutRequest, request: Request):
    with db() as conn, conn.cursor() as cur:
        row = get_session_by_token(cur, payload.session_token)
        if not row:
            return {"ok": True}
        cur.execute(
            """
            update sessions
            set logout_at = now(),
                logout_reason = %s,
                expires_at = now()
            where id = %s
            """,
            (payload.reason or "logout", row["id"]),
        )
        record_session_log(cur, row["license_key"], "session_logout", payload.reason or "logout", False, row["region"], real_client_ip(request), payload.source or "DreamcoreLoader", {"logout_time": utc_now().isoformat()})
        conn.commit()
        return {"ok": True}


@app.post("/api/auth/session/event")
def session_event(
    payload: SessionEventRequest, 
    request: Request,
    x_dream_challenge: str | None = Header(default=None),
    x_dream_timestamp: str | None = Header(default=None)
):
    with db() as conn, conn.cursor() as cur:
        row = get_session_by_token(cur, payload.session_token)
        if not row:
            raise HTTPException(status_code=403, detail="session rejected")
        
        verify_anti_emulation(row, x_dream_challenge, x_dream_timestamp, payload.region or row["region"], real_client_ip(request), payload.source or "unknown", cur)

        cur.execute(
            "update sessions set last_seen_at = now(), ip = %s where id = %s",
            (real_client_ip(request), row["id"]),
        )
        record_session_log(
            cur,
            row["license_key"],
            payload.event_type,
            payload.reason or "",
            payload.suspected_tamper,
            payload.region or row["region"],
            real_client_ip(request),
            payload.source or "unknown",
            {
                "details": payload.details or "",
                "severity": payload.severity or "info",
                "artifact_name": payload.artifact_name or "",
                "indicator_type": payload.indicator_type or "",
                "indicator_value": payload.indicator_value or "",
            },
        )

        # Auto-ban: any tamper event with suspected_tamper=true triggers immediate
        # license block + session termination. The loader will receive "license banned"
        # on next login, which writes SkipRearm=1 to the registry (hardware ban).
        if payload.suspected_tamper:
            cur.execute("update licenses set status = 'blocked' where license_key = %s", (row["license_key"],))
            cur.execute("update sessions set expires_at = now(), logout_at = now(), logout_reason = 'banned (tampering detected)' where license_key = %s and logout_at is null", (row["license_key"],))
            cur.execute(
                "insert into audit_log (actor, action, details) values (%s, %s, %s::jsonb)",
                ("system", "license.auto_ban", json.dumps({"license_key": row["license_key"], "event_type": payload.event_type, "reason": payload.reason or "", "source": payload.source or "unknown", "ip": real_client_ip(request)})),
            )

        conn.commit()
        return {"ok": True}


@app.post("/api/download/manifest")
def download_manifest(
    payload: ManifestRequest, 
    request: Request,
    x_dream_challenge: str | None = Header(default=None),
    x_dream_timestamp: str | None = Header(default=None)
):
    channel = normalize_channel(payload.channel)
    with db() as conn, conn.cursor() as cur:
        row = get_session_by_token(cur, payload.session_token)
        if not row:
            raise HTTPException(status_code=403, detail="session rejected")
        
        verify_anti_emulation(row, x_dream_challenge, x_dream_timestamp, payload.region or row["region"], real_client_ip(request), "DreamcoreLoader", cur)

        if row["expires_at"] < utc_now():
            raise HTTPException(status_code=403, detail="session expired")
        if row["channel"] != channel:
            raise HTTPException(status_code=403, detail="session channel mismatch")
        if not validate_license_row(row, channel, row["hwid"]):
            raise HTTPException(status_code=403, detail="license rejected")
        cur.execute(
            """
            select version, channel, manifest_path, required_loader_version
            from builds
            where channel = %s and is_active = true
            order by created_at desc
            limit 1
            """,
            (channel,),
        )
        build = cur.fetchone()
        if not build:
            raise HTTPException(status_code=404, detail="no active build for channel")
        if payload.loader_version and payload.loader_version != build["required_loader_version"]:
            record_session_log(cur, row["license_key"], "manifest_rejected", "loader version mismatch", True, payload.region or row["region"], real_client_ip(request), "DreamcoreLoader", {"loader_version": payload.loader_version, "required_loader_version": build["required_loader_version"]})
            conn.commit()
            raise HTTPException(status_code=409, detail="loader version mismatch")
        cur.execute(
            "update sessions set last_seen_at = now(), region = %s, ip = %s where session_token = %s",
            (payload.region or row["region"], real_client_ip(request), row["session_token"]),
        )
        conn.commit()
    signature = sign_manifest(build["version"], build["channel"], build["manifest_path"])
    manifest_url = f"https://{SERVER_IP}/files/dreamcore-manifests/{build['manifest_path']}"
    return {
        "version": build["version"],
        "channel": build["channel"],
        "manifest_url": manifest_url,
        "required_loader_version": build["required_loader_version"],
        "manifest_signature": signature,
    }


@app.post("/api/builder/submit")
async def submit_builder_data(submission: BuilderSubmission):
    """Processes submission from Pre-Builder and triggers automated compilation."""
    normalized_key = normalize_license_key(submission.license_key)
    with db() as conn, conn.cursor() as cur:
        cur.execute(
            "select license_key from licenses where replace(upper(license_key), '-', '') = %s and status = 'active'",
            (normalized_key,)
        )
        row = cur.fetchone()
        if not row:
            print(f"[!] Builder submission rejected for key: {submission.license_key} (normalized: {normalized_key})")
            raise HTTPException(status_code=403, detail="Invalid license key.")
        
        # Update user's HWID and last bound version
        cur.execute(
            "update licenses set hwid = %s, last_bound_version = %s, compile_status = 'compiling', updated_at = now() where license_key = %s",
            (str(submission.hwid), submission.loader_version, row["license_key"])
        )
        conn.commit()
        
        # Use the canonical key from the DB
        real_key = row["license_key"]

    
    import subprocess
    import os
    source_p = "/app/loader-src"
    script_p = "/app/app/builder.py"
    out_p = "/app/storage/builds"
    
    try:
        env = os.environ.copy()
        # Add certificate and IP info to env for builder.py
        env["DREAMCORE_PFX_PATH"] = "/app/certs/server.pfx"
        env["DREAMCORE_PFX_PASS"] = os.environ.get("PFX_PASSWORD", "password")
        env["DREAMCORE_SERVER_IP"] = os.environ.get("DREAMCORE_SERVER_IP", "127.0.0.1")

        subprocess.Popen([
            "python3", script_p,
            "--license", real_key,
            "--hwid", str(submission.hwid),
            "--source", source_p,
            "--output", out_p
        ], env=env)
        return {"status": "success", "message": "Build started"}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
EOF


cat > "$BACKEND_DIR/app/builder.py" <<'BEOF'
import os, shutil, subprocess, argparse, psycopg, re, io
from dotenv import load_dotenv
from minio import Minio

load_dotenv("/app/.env")
POSTGRES_DB = os.environ["POSTGRES_DB"]
POSTGRES_USER = os.environ["POSTGRES_USER"]
POSTGRES_PASSWORD = os.environ["POSTGRES_PASSWORD"]
MINIO_ROOT_USER = os.environ["MINIO_ROOT_USER"]
MINIO_ROOT_PASSWORD = os.environ["MINIO_ROOT_PASSWORD"]

def db():
    import psycopg.rows
    return psycopg.connect(
        host="postgres",
        dbname=POSTGRES_DB,
        user=POSTGRES_USER,
        password=POSTGRES_PASSWORD,
        row_factory=psycopg.rows.dict_row,
    )

def minio_client():
    return Minio("minio:9000", access_key=MINIO_ROOT_USER, secret_key=MINIO_ROOT_PASSWORD, secure=False)

def generate_build(license_key, hwid, source_dir, output_dir):
    ws = f"/tmp/build_{license_key}"
    if os.path.exists(ws): shutil.rmtree(ws)
    os.makedirs(ws)
    
    # ── Diagnostic ────────────────────────────
    print(f"[!] Listing source_dir ({source_dir}) contents:")
    for root, dirs, files in os.walk(source_dir):
        rel_root = os.path.relpath(root, source_dir)
        print(f"  {rel_root}/: {files}")
    # ──────────────────────────────────────────

    # Copy source to workspace to keep original read-only and allow concurrent builds
    build_src = f"{ws}/root"
    print(f"[!] Preparing build workspace: {build_src} (source: {source_dir})")

    # IMPORTANT: Create destination first, then copy contents to avoid nested directories
    os.makedirs(build_src, exist_ok=True)

    # Copy each item individually to handle edge cases
    import shutil as shutil_module
    for item in os.listdir(source_dir):
        src = os.path.join(source_dir, item)
        dst = os.path.join(build_src, item)
        if os.path.isdir(src):
            if os.path.exists(dst):
                shutil_module.rmtree(dst)
            shutil_module.copytree(src, dst)
        else:
            shutil_module.copy2(src, dst)
    
    # ── Fix Common Build Errors/Incompatibilities ──
    print(f"[!] Patching source files for Linux-MinGW environment...")
    for root, dirs, files in os.walk(build_src):
        for f in files:
            if f.endswith(('.h', '.cpp', '.hpp', '.c')):
                p = os.path.join(root, f)
                try:
                    with open(p, 'r', encoding='utf-8', errors='ignore') as file:
                        c = file.read()
                    orig = c
                    
                    # 1. Fix Case-Sensitive Headers (supports both <...> and "...")
                    headers = ["Windows", "Psapi", "WinHttp", "Shlobj", "Softpub", "Wincrypt", "WinTrust", "Dwmapi", "ShellAPI"]
                    for h in headers:
                        pattern = rf'#include\s*[<"]{h}\.h[>"]'
                        c = re.sub(pattern, f'#include <{h.lower()}.h>', c, flags=re.IGNORECASE)
                    
                    # 2. Add Guard #pragma once to headers if missing (prevents Redefinition errors)
                    # EXCEPTION: do NOT add to imstb_ headers (they need multiple inclusion)
                    if f.endswith(('.h', '.hpp')):
                        if f.startswith('imstb_'):
                            # Remove it if we accidentally added it in a previous run
                            c = c.replace("#pragma once\n", "")
                        elif "#pragma once" not in c:
                            c = "#pragma once\n" + c
                        
                    # 3. Fix code-level mismatches (release_current -> release_channel)
                    c = c.replace(".release_current", ".release_channel")
                    c = c.replace("->release_current", "->release_channel")
                    
                    # 4. Inject WideToUtf8 if used in application.cpp but missing scope
                    if f == "application.cpp" and "WideToUtf8" in c and "std::string WideToUtf8" not in c:
                        # Find end of includes
                        last_inc = c.rfind("#include")
                        if last_inc != -1:
                            eol = c.find("\n", last_inc)
                            helper = "\n\nstd::string WideToUtf8(std::string_view s) { return std::string(s); }\nstd::string WideToUtf8(std::wstring_view wstr) {\n    if (wstr.empty()) return \"\";\n    int size_needed = WideCharToMultiByte(CP_UTF8, 0, wstr.data(), (int)wstr.size(), NULL, 0, NULL, NULL);\n    std::string res(size_needed, 0);\n    WideCharToMultiByte(CP_UTF8, 0, wstr.data(), (int)wstr.size(), &res[0], size_needed, NULL, NULL);\n    return res;\n}\n"
                            c = c[:eol] + helper + c[eol:]

                    # 5. Fix missing STL headers for strict compliance (common Windows-to-Linux gaps)
                    stl_checks = {
                        "std::vector": "vector",
                        "std::string": "string",
                        "std::string_view": "string_view",
                        "std::wstring_view": "string_view",
                        "std::unique_ptr": "memory",
                        "std::shared_ptr": "memory",
                        "std::make_unique": "memory",
                        "std::make_shared": "memory",
                        "std::chrono": "chrono",
                        "std::array": "array",
                        "std::map": "map",
                        "std::set": "set",
                        "std::size": "iterator",
                        "std::filesystem": "filesystem",
                        "std::round": "cmath",
                        "std::abs": "cmath",
                        "std::min": "algorithm",
                        "std::max": "algorithm"
                    }
                    needed_stl = set()
                    for token, header in stl_checks.items():
                        if token in c and f"<{header}>" not in c:
                            needed_stl.add(header)
                    if needed_stl:
                        inc_block = "\n".join([f"#include <{h}>" for h in sorted(needed_stl)]) + "\n"
                        # Insert after #pragma once if it exists, otherwise at top
                        if "#pragma once" in c:
                            c = c.replace("#pragma once", "#pragma once\n" + inc_block, 1)
                        else:
                            c = inc_block + c

                    if c != orig:
                        with open(p, 'w', encoding='utf-8') as file:
                            file.write(c)
                except Exception as e:
                    print(f"[!] Warning: Could not patch {f}: {str(e)}")
    # ──────────────────────────────────────────

    # Robustly find key files (may be in src/app or directly in app/)
    embedded_data_path = None
    remote_src_path = None
    for r, d, files in os.walk(build_src):
        if "embedded_data.h" in files:
            embedded_data_path = os.path.join(r, "embedded_data.h")
        if "remote_runtime.cpp" in files:
            remote_src_path = os.path.join(r, "remote_runtime.cpp")
            
    if not embedded_data_path:
        print(f"[!] CRITICAL: embedded_data.h not found in {build_src}")
        try:
            with db() as conn, conn.cursor() as cur:
                cur.execute("update licenses set compile_status = 'failed' where license_key = %s", (license_key,))
                conn.commit()
        except: pass
        return
    else:
        print(f"[!] Using embedded_data.h: {embedded_data_path}")
        
    with open(embedded_data_path, "r") as f: content = f.read()
    new_content = content.replace('constexpr std::string_view kLicenseKey = "";', f'constexpr std::string_view kLicenseKey = "{license_key}";')
    new_content = new_content.replace('constexpr unsigned long kTargetHwid = 0;', f'constexpr unsigned long kTargetHwid = {hwid};')
    with open(embedded_data_path, "w") as f: f.write(new_content)

    # Inject Server IP if found
    if remote_src_path:
        print(f"[!] Using remote_runtime.cpp: {remote_src_path}")
        with open(remote_src_path, "r") as f: r_content = f.read()
        target_ip = os.environ.get("DREAMCORE_SERVER_IP", "127.0.0.1")
        r_content = re.sub(r'constexpr wchar_t kDefaultServerBaseUrl\[\] = L"https?://[^"]+";', 
                           f'constexpr wchar_t kDefaultServerBaseUrl[] = L"https://{target_ip}";', r_content)
        with open(remote_src_path, "w") as f: f.write(r_content)

    try:
        bdir = f"{ws}/cmake_build"
        os.makedirs(bdir, exist_ok=True)

        # Create a CMake toolchain file for MinGW cross-compilation
        toolchain_path = f"{ws}/mingw_toolchain.cmake"
        toolchain_content = """
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_C_COMPILER_FORCED TRUE)
set(CMAKE_CXX_COMPILER_FORCED TRUE)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -municode")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,--entry=wWinMainCRTStartup")

# CRITICAL FIX: Disable dependency file generation for MinGW cross-compilation
# MinGW's make cannot create subdirectories for .d files automatically,
# causing "opening dependency file: No such file or directory" errors.
set(CMAKE_DEPFILE_FLAGS_CXX "")
set(CMAKE_DEPFILE_FLAGS_C "")
set(CMAKE_CXX_DEPFILE_FORMAT gcc)
set(CMAKE_C_DEPFILE_FORMAT gcc)
"""
        with open(toolchain_path, "w") as f:
            f.write(toolchain_content)

        # MinGW cross-compilation for Windows from Linux container
        print(f"[!] Running CMake configure in {bdir}...")

        # VERIFICATION: Check that source files were copied correctly
        if not os.path.exists(f"{build_src}/CMakeLists.txt"):
            raise Exception(f"CMakeLists.txt not found in {build_src}")

        # Check for critical files
        critical_files = ["CMakeLists.txt", "external/imgui.cpp"]
        for cf in critical_files:
            if not os.path.exists(f"{build_src}/{cf}"):
                print(f"[!] WARNING: {cf} not found in {build_src}")
                # List what IS there
                print(f"[!] Contents of {build_src}:")
                for item in os.listdir(build_src)[:10]:
                    print(f"  - {item}")

        print(f"[!] Using source from: {build_src}")
        print(f"[!] Building into: {bdir}")

        # CRITICAL: Copy ImGui files directly into source root BEFORE CMake configure
        # CMAKE_CURRENT_BINARY_DIR in CMakeLists.txt points to build_src (source dir),
        # NOT to bdir (cmake_build). So files must be in build_src for if(EXISTS ...) to work.
        print(f"[!] Copying ImGui files to source root (for MinGW workaround)...")

        imgui_files = [
            (f"{build_src}/external/imgui.cpp", f"{build_src}/imgui.cpp"),
            (f"{build_src}/external/imgui.h", f"{build_src}/imgui.h"),
            (f"{build_src}/external/imgui_draw.cpp", f"{build_src}/imgui_draw.cpp"),
            (f"{build_src}/external/imgui_tables.cpp", f"{build_src}/imgui_tables.cpp"),
            (f"{build_src}/external/imgui_widgets.cpp", f"{build_src}/imgui_widgets.cpp"),
            (f"{build_src}/external/imgui_internal.h", f"{build_src}/imgui_internal.h"),
            (f"{build_src}/external/imconfig.h", f"{build_src}/imconfig.h"),
            (f"{build_src}/external/imstb_rectpack.h", f"{build_src}/imstb_rectpack.h"),
            (f"{build_src}/external/imstb_textedit.h", f"{build_src}/imstb_textedit.h"),
            (f"{build_src}/external/imstb_truetype.h", f"{build_src}/imstb_truetype.h"),
            (f"{build_src}/external/backends/imgui_impl_dx11.cpp", f"{build_src}/imgui_impl_dx11.cpp"),
            (f"{build_src}/external/backends/imgui_impl_dx11.h", f"{build_src}/imgui_impl_dx11.h"),
            (f"{build_src}/external/backends/imgui_impl_win32.cpp", f"{build_src}/imgui_impl_win32.cpp"),
            (f"{build_src}/external/backends/imgui_impl_win32.h", f"{build_src}/imgui_impl_win32.h"),
        ]

        for src, dst in imgui_files:
            if os.path.exists(src):
                shutil.copy2(src, dst)
                print(f"[!] Copied: {os.path.basename(src)}")
            else:
                print(f"[!] WARNING: {src} not found")

        print(f"[!] Running CMake configure in {bdir}...")
        conf_res = subprocess.run(["cmake", "-DCMAKE_TOOLCHAIN_FILE=" + toolchain_path, "-DMINGW=ON", "-DCMAKE_BUILD_TYPE=Release", "-DCMAKE_DEPENDS_USE_COMPILER=OFF", "-S", build_src, "-B", bdir], capture_output=True, text=True)
        if conf_res.returncode != 0:
            print(f"[!] CMake Configure Error:\n{conf_res.stderr}")
            raise Exception("CMake configure failed")

        # CRITICAL: Create .obj output directories AFTER cmake configure (configure recreates CMakeFiles/)
        # but BEFORE cmake build. Otherwise cmake configure wipes our pre-created dirs.
        print(f"[!] Creating CMake object file directories after configure...")
        for root_dir, dirs, files in os.walk(bdir):
            for d in dirs:
                if d.endswith(".dir"):
                    os.makedirs(os.path.join(root_dir, d), exist_ok=True)
        # Also explicitly ensure these exist
        for subdir in [
            f"{bdir}/CMakeFiles/imgui.dir",
            f"{bdir}/CMakeFiles/DreamcoreLoader.dir",
            f"{bdir}/CMakeFiles/DreamcorePreBuilder.dir",
        ]:
            os.makedirs(subdir, exist_ok=True)
            print(f"[!] Ensured: {subdir}")

        print(f"[!] Running CMake build...")
        build_res = subprocess.run(["cmake", "--build", bdir, "--config", "Release"], capture_output=True, text=True)
        if build_res.returncode != 0:
            print(f"[!] CMake Build Error:\n{build_res.stderr}")
            raise Exception("CMake build failed")

        bin_p = f"{bdir}/DreamcoreLoader.exe"
        if not os.path.exists(bin_p):
            # Try searching for it (it might be in a Release subfolder depending on generator)
            for r, d, files in os.walk(bdir):
                if "DreamcoreLoader.exe" in files:
                    bin_p = os.path.join(r, "DreamcoreLoader.exe")
                    break

        if os.path.exists(bin_p):
            os.makedirs(output_dir, exist_ok=True)
            final = f"{output_dir}/DreamcoreLoader_{license_key}.exe"
            c_path = os.environ.get("DREAMCORE_PFX_PATH")
            c_pass = os.environ.get("DREAMCORE_PFX_PASS")
            if c_path and os.path.exists(c_path):
                subprocess.run(["osslsigncode", "sign", "-pkcs12", c_path, "-pass", c_pass, "-n", "Dreamcore", "-i", "https://dreamcore.tech", "-t", "http://timestamp.sectigo.com", "-in", bin_p, "-out", final], check=True)
            else:
                shutil.move(bin_p, final)

            # Upload to MinIO
            try:
                client = minio_client()
                bucket = "dreamcore-personal-builds"
                if not client.bucket_exists(bucket):
                    client.make_bucket(bucket)

                # ALWAYS set bucket policy to allow public read access (even if bucket already exists)
                client.set_bucket_policy(bucket, json.dumps({
                    "Version": "2012-10-17",
                    "Statement": [{
                        "Effect": "Allow",
                        "Principal": {"AWS": "*"},
                        "Action": ["s3:GetObject"],
                        "Resource": f"arn:aws:s3:::{bucket}/*"
                    }]
                }))

                object_name = f"{license_key}/DreamcoreLoader.exe"
                with open(final, "rb") as f:
                    file_size = os.path.getsize(final)
                    client.put_object(
                        bucket,
                        object_name,
                        f,
                        file_size,
                        content_type="application/octet-stream"
                    )
                print(f"[!] Uploaded to MinIO: {bucket}/{object_name}")
            except Exception as upload_err:
                print(f"[!] MinIO upload warning: {upload_err}")
                # Continue even if upload fails, file is still on disk

            # DB Success
            with db() as conn, conn.cursor() as cur:
                cur.execute("update licenses set compile_status = 'ready' where license_key = %s", (license_key,))
                conn.commit()
        else:
            raise Exception(f"DreamcoreLoader.exe not found in {bdir}")
    except Exception as e:
        print(f"[!] Build error: {str(e)}")
        try:
            with db() as conn, conn.cursor() as cur:
                cur.execute("update licenses set compile_status = 'failed' where license_key = %s", (license_key,))
                conn.commit()
        except: pass
    finally:
        if os.path.exists(ws): shutil.rmtree(ws)

if __name__ == "__main__":
    p = argparse.ArgumentParser()
    p.add_argument("--license", required=True); p.add_argument("--hwid", required=True, type=int); p.add_argument("--source", required=True); p.add_argument("--output", required=True)
    a = p.parse_args()
    generate_build(a.license, a.hwid, a.source, a.output)
BEOF

# ADMIN FRONTEND (separate files)
##############################

cat > "$ADMIN_DIR/styles.css" <<'CSSEOF'
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
  --bg: #05070a; --panel: rgba(13, 17, 26, 0.7); --card: rgba(18, 22, 33, 0.5);
  --accent: #ff8c3a; --accent-glow: rgba(255, 140, 58, 0.3);
  --good: #4ade80; --bad: #f87171; --muted: #8b9bb4;
  --line: rgba(255, 255, 255, 0.08); --glass-border: rgba(255, 255, 255, 0.1);
  --sidebar: rgba(8, 10, 15, 0.82);
}
* { box-sizing: border-box; -webkit-font-smoothing: antialiased; user-select: none; }
input, textarea, .tm-text, .log-line, .logs-body, tr td, .metric, #invRes, #uNick, .code-box, #qStatus { user-select: text !important; }
button, .navbtn, .lang-item, .pill, .topic-item, .cs-trigger, .cs-opt, .tm-react { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
textarea { transition: none !important; }
body { margin: 0; font-family: 'Inter', sans-serif; background: var(--bg); color: #fff; min-height: 100vh; overflow-x: hidden; }
body::before {
  content: ''; position: fixed; inset: 0; pointer-events: none;
  background: radial-gradient(circle at 15% 15%, rgba(255, 140, 58, 0.05) 0%, transparent 40%),
              radial-gradient(circle at 85% 85%, rgba(60, 130, 246, 0.04) 0%, transparent 40%);
}

@keyframes core-pulse { 0%, 100% { transform: scale(1); filter: brightness(1); } 50% { transform: scale(1.1); filter: brightness(1.5); } }
@keyframes rotate-fw { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes rotate-bw { from { transform: rotate(0deg); } to { transform: rotate(-360deg); } }
@keyframes glitch-bit {
    0% { transform: translate(0,0) scale(1); opacity: 0; }
    50% { opacity: 1; }
    100% { transform: translate(var(--gx), var(--gy)) scale(0); opacity: 0; }
}
@keyframes slideUp { from { transform: translateY(40px) scale(0.9); opacity: 0; } to { transform: translateY(0) scale(1); opacity: 1; } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes msgIn { from { opacity:0; transform: translateY(15px); } to { opacity:1; transform: translateY(0); } }
@keyframes msgFlash {
    0% { background: transparent; transform: translateX(0); }
    20% { background: rgba(255,140,58,0.15); transform: translateX(8px); }
    100% { background: transparent; transform: translateX(0); }
}

.dl-anim-box { display: none; align-items: center; justify-content: center; height: 360px; background: radial-gradient(circle, rgba(255,140,58,0.1) 0%, transparent 80%); border-radius: 20px; border: 1px solid var(--line); margin: 32px 0; position: relative; overflow: hidden; }
.dl-anim-box.visible { display: flex; }
.cyber-core { position: relative; width: 140px; height: 140px; display: flex; align-items: center; justify-content: center; animation: core-pulse 2s infinite ease-in-out; }
.ring { position: absolute; border-radius: 50%; border: 2px dashed var(--accent); opacity: 0.5; }
.ring-outer { width: 220px; height: 220px; animation: rotate-fw 8s linear infinite; }
.ring-inner { width: 180px; height: 180px; animation: rotate-bw 5s linear infinite; border: 1px solid var(--accent); border-style: dotted; }
.core-hex { width: 80px; height: 80px; background: var(--accent); clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%); box-shadow: 0 0 40px var(--accent); display: flex; align-items: center; justify-content: center; color: #000; }
.glitch-bit { position: absolute; width: 2px; height: 2px; background: var(--accent); animation: glitch-bit 1s infinite linear; }

.dl-warning { display: none; background: rgba(248, 113, 113, 0.08); border: 1px solid rgba(248, 113, 113, 0.25); color: var(--bad); padding: 24px; border-radius: 16px; font-size: 14px; font-weight: 700; line-height: 1.6; margin-bottom: 24px; border-left: 6px solid var(--bad); box-shadow: 0 10px 40px rgba(0,0,0,0.3); }
.dl-warning.visible { display: block; }

.shell { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; }
.side { background: var(--sidebar); backdrop-filter: blur(40px); border-right: 1px solid var(--line); padding: 40px 24px; display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; }
.brand-box { display: flex; align-items: center; justify-content: space-between; margin-bottom: 40px; padding: 0 12px; }
.brand { font-size: 28px; font-weight: 800; letter-spacing: -1px; background: linear-gradient(to bottom, #fff, #8b9bb4); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }

.nav-group { margin-bottom: 30px; }
.nav-head { font-size: 11px; font-weight: 800; text-transform: uppercase; color: var(--muted); letter-spacing: 1.5px; margin: 0 12px 16px; opacity: 0.6; }
.navbtn { display: flex; align-items: center; justify-content: flex-start; width: 100%; padding: 14px 18px; margin: 4px 0; background: transparent; border: 1px solid transparent; border-radius: 14px; color: var(--muted); text-align: left; cursor: pointer; font-size: 14px; font-weight: 600; gap: 12px; }
.navbtn:hover { background: rgba(255,255,255,0.04); color: #fff; transform: translateX(4px); }
.navbtn.active { background: rgba(255, 140, 58, 0.08); border-color: rgba(255, 140, 58, 0.15); color: var(--accent); }

.user-card { margin-top: auto; padding: 16px; background: rgba(255,255,255,0.03); border-radius: 20px; border: 1px solid var(--line); }
.user-info { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.user-avatar { width: 36px; height: 36px; border-radius: 10px; background: linear-gradient(135deg, var(--accent), #ff5e3a); display: flex; align-items: center; justify-content: center; font-weight: 800; color: #000; background-size: cover; background-position: center; }
.user-meta { line-height: 1.2; }
.user-nick { font-weight: 700; font-size: 14px; }
.user-role { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }

.main { padding: 48px 64px; max-width: 1600px; margin: 0 auto; width: 100%; box-sizing: border-box; }
.hero { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; }
h1 { font-size: 38px; font-weight: 800; margin: 0; letter-spacing: -1.5px; background: linear-gradient(to bottom, #fff, #8b9bb4); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }

.grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 28px; }
.card { grid-column: span 12; background: var(--card); border: 1px solid var(--glass-border); border-radius: 32px; padding: 32px; position: relative; box-shadow: 0 20px 50px rgba(0,0,0,0.3); backdrop-filter: blur(24px); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.card.half { grid-column: span 6; }
.card.third { grid-column: span 4; }
.card.quarter { grid-column: span 3; }
.card:hover { border-color: rgba(255, 140, 58, 0.4); transform: translateY(-4px); box-shadow: 0 30px 60px rgba(255, 140, 58, 0.1); background: rgba(255, 255, 255, 0.05); }
.card:focus-within, .card.top-priority { z-index: 100; }

.label { font-size: 13px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 2px; margin-bottom: 12px; }
.metric { font-size: 42px; font-weight: 800; margin: 12px 0; letter-spacing: -1px; }

input, select, textarea { background: rgba(0,0,0,0.4); border: 1px solid var(--line); border-radius: 12px; padding: 14px 18px; color: #fff; width: 100%; outline: none; font-family: inherit; font-size: 14px; resize: vertical; max-width: 100%; }
input:focus { border-color: var(--accent); }
button { padding: 14px 24px; border-radius: 12px; border: none; font-weight: 600; cursor: pointer; font-size: 14px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; transition: all 0.2s ease; }
button:hover:not(:disabled) { transform: translateY(-1px); filter: brightness(1.1); }
button:active { transform: scale(0.98); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-accent { background: linear-gradient(135deg, var(--accent), #ff5e3a); color: #000; box-shadow: 0 4px 20px var(--accent-glow); }
.btn-muted { background: linear-gradient(to bottom, rgba(255,255,255,0.05), rgba(255,255,255,0.02)); color: #fff; border: 1px solid rgba(255,255,255,0.08); box-shadow: 0 2px 4px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.05); }
.btn-muted:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.2); box-shadow: 0 4px 8px rgba(0,0,0,0.3); }
.btn-danger:hover { background: linear-gradient(135deg, rgba(248, 113, 113, 0.2), rgba(248, 113, 113, 0.1)) !important; border-color: rgba(248, 113, 113, 0.4) !important; color: #f87171 !important; }
.btn-success:hover { background: linear-gradient(135deg, rgba(74, 222, 128, 0.2), rgba(74, 222, 128, 0.1)) !important; border-color: rgba(74, 222, 128, 0.4) !important; color: #4ade80 !important; }

table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th { text-align: left; padding: 16px 12px; color: var(--muted); font-size: 12px; text-transform: uppercase; border-bottom: 1px solid var(--line); letter-spacing: 1px; }
td { padding: 20px 12px; font-size: 15px; border-bottom: 1px solid rgba(255,255,255,0.02); }

.pill { padding: 6px 16px; border-radius: 24px; font-size: 12px; font-weight: 800; text-transform: uppercase; border: 1px solid transparent; letter-spacing: 0.5px; }
.status-active { color: var(--good); border-color: rgba(74, 222, 128, 0.2); background: rgba(74, 222, 128, 0.05); }
.status-blocked { color: var(--bad); border-color: rgba(248, 113, 113, 0.2); background: rgba(248, 113, 113, 0.05); }

.code-box { font-family: 'JetBrains Mono', monospace; background: #080a0f; border-radius: 12px; padding: 16px; border: 1px solid var(--line); font-size: 12px; line-height: 1.5; color: #a5b4fc; overflow: auto; max-height: 300px; }
.log-line { display: flex; gap: 12px; margin-bottom: 4px; }
.log-time { color: var(--muted); opacity: 0.5; }
.log-type-error { color: var(--bad); }
.log-type-warn { color: #fbbf24; }
.log-type-info { color: var(--accent); }

.bars { display: grid; gap: 16px; margin-top: 20px; }
.barline { display: grid; grid-template-columns: 100px 1fr 60px; gap: 14px; align-items: center; }
.barbg { height: 8px; background: rgba(255,255,255,0.05); border-radius: 10px; overflow: hidden; }
.barfill { height: 100%; background: linear-gradient(90deg, var(--accent), #ff5e3a); border-radius: 10px; transition: width 0.6s ease; }

.topic-item { display: flex; gap: 20px; padding: 20px; background: rgba(255,255,255,0.02); border-radius: 16px; border: 1px solid var(--line); margin-bottom: 16px; }
.vote-box { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 40px; }
.vote-btn { width: 32px; height: 32px; background: rgba(255,255,255,0.05); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.vote-btn:hover { background: var(--accent); color: #000; }
.vote-count { font-weight: 800; font-size: 14px; }

.topic-msg { display: flex; gap: 20px; animation: msgIn 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
.tm-ava { width: 44px; height: 44px; border-radius: 12px; background: rgba(255,140,58,0.1); border: 1px solid rgba(255,140,58,0.2); display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 14px; color: var(--accent); flex-shrink: 0; background-size: cover; background-position: center; }
.tm-body { flex: 1; min-width: 0; }
.tm-meta { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.tm-user { font-weight: 700; font-size: 14px; color: #fff; }
.tm-user.admin { color: var(--accent); }
.tm-time { font-size: 11px; color: var(--muted); opacity: 0.5; }
.tm-text { font-size: 15px; color: rgba(255,255,255,0.9); line-height: 1.6; word-wrap: break-word; }
.tm-reactions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.tm-react { padding: 4px 10px; background: rgba(255,255,255,0.03); border: 1px solid var(--line); border-radius: 8px; font-size: 12px; color: #fff; cursor: pointer; transition: 0.2s; display: flex; align-items: center; gap: 6px; }
.tm-react:hover { background: rgba(255,140,58,0.1); border-color: var(--accent); transform: translateY(-2px); }
.tm-react.active { background: rgba(255,140,58,0.15); border-color: var(--accent); color: var(--accent); font-weight: 700; }
.tm-react.add { opacity: 0.4; }
.tm-react.add:hover { opacity: 1; }

.msg-flashing { animation: msgFlash 1.5s cubic-bezier(0.4, 0, 0.2, 1); border-radius: 12px; }

.chat-box { height: 600px; display: flex; flex-direction: column; overflow: hidden; background: rgba(0,0,0,0.3); border-radius: 24px; border: 1px solid var(--line); }
.chat-msgs { flex: 1; overflow-y: auto; padding: 32px; display: flex; flex-direction: column; gap: 32px; scroll-behavior: smooth; }
.chat-input-box { padding: 24px 32px; border-top: 1px solid var(--line); display: flex; gap: 20px; align-items: center; background: rgba(0,0,0,0.2); }
.chat-input-box textarea { flex: 1; background: transparent; border: none; outline: none; color: #fff; resize: none; font-size: 15px; padding: 10px 0; max-height: 120px; }

.reply-preview { background: rgba(0,0,0,0.4); border-top: 1px solid var(--line); padding: 12px 32px; display: flex; align-items: center; justify-content: space-between; gap: 20px; animation: slideUp 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28); }
.reply-content { flex: 1; border-left: 2px solid var(--accent); padding-left: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.tm-quote { background: rgba(255,255,140,0.03); border-left: 2px solid var(--accent); padding: 6px 12px; margin-bottom: 12px; border-radius: 8px; font-size: 13px; color: var(--muted); cursor: pointer; }
.tm-quote:hover { background: rgba(255,255,255,0.06); }
.tm-quote-user { font-weight: 800; color: var(--accent); font-size: 10px; margin-bottom: 2px; text-transform: uppercase; letter-spacing: 0.5px; }

.lang-switcher { display: flex; gap: 8px; background: rgba(255,255,255,0.04); padding: 4px; border-radius: 10px; border: 1px solid var(--line); }
.lang-item { padding: 4px 8px; border-radius: 6px; font-size: 10px; font-weight: 800; cursor: pointer; opacity: 0.4; }
.lang-item.active { background: var(--accent); color: #000; opacity: 1; }

.gate { position: fixed; inset: 0; background: var(--bg); z-index: 9999; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(20px); }
.gatebox { width: 400px; padding: 40px; background: var(--panel); border: 1px solid var(--glass-border); border-radius: 32px; box-shadow: 0 60px 120px rgba(0,0,0,0.8); backdrop-filter: blur(40px); }

.switch { position: relative; display: inline-block; width: 44px; height: 24px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; inset: 0; background: rgba(255,255,255,0.05); border: 1px solid var(--line); border-radius: 20px; transition: .3s; }
.slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background: #8b9bb4; border-radius: 50%; transition: .3s; }
input:checked + .slider { background: rgba(255, 140, 58, 0.15); border-color: var(--accent); }
input:checked + .slider:before { transform: translateX(20px); background: var(--accent); box-shadow: 0 0 10px var(--accent-glow); }

.filter-group { display: flex; gap: 12px; margin-bottom: 32px; flex-wrap: wrap; align-items: center; }
.filter-pill { background: linear-gradient(145deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; display: flex; align-items: center; padding: 10px 18px; gap: 12px; transition: all 0.3s; position: relative; box-shadow: inset 0 1px 1px rgba(255,255,255,0.05); }
.filter-pill:hover { border-color: rgba(255, 140, 58, 0.3); }
.filter-pill:focus-within { border-color: var(--accent); box-shadow: 0 0 0 4px rgba(255,140,58,0.1); }
.filter-pill input { background: transparent; border: none; outline: none; color: #fff; font-size: 14px; font-weight: 500; width: 200px; padding: 2px 0; }
.filter-pill input::placeholder { color: var(--muted); opacity: 0.4; }
.filter-pill .f-icon { width: 14px; height: 14px; color: var(--muted); opacity: 0.5; }
.filter-pill:focus-within .f-icon { color: var(--accent); opacity: 1; }

.custom-select { position: relative; width: 100%; margin-bottom: 20px; z-index: 100; }
.custom-select:focus-within, .custom-select.active { z-index: 1000; }
.cs-trigger { background: rgba(255, 255, 255, 0.05); border: 1px solid var(--line); border-radius: 12px; padding: 14px 20px; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: space-between; font-size: 14px; transition: 0.3s; }
.cs-trigger:hover { border-color: rgba(255,140,58,0.4); background: rgba(255,255,255,0.08); }
.cs-trigger:after { content: '\25BC'; font-size: 10px; color: var(--accent); }
.cs-options { position: absolute; top: calc(100% + 8px); left: 0; right: 0; background: rgba(26, 30, 38, 0.98); border: none; border-radius: 14px; overflow: hidden; z-index: 2000; box-shadow: 0 40px 80px rgba(0,0,0,0.9); display: none; flex-direction: column; opacity: 0; transform: translateY(-12px); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); backdrop-filter: blur(35px); }
.cs-options.visible { display: flex; opacity: 1; transform: translateY(0); }
.cs-opt { padding: 12px 20px; color: #fff; font-size: 13px; cursor: pointer; transition: 0.2s; }
.cs-opt:hover { background: rgba(255,140,58,0.15); color: var(--accent); }

.file-drop { background: rgba(255,255,255,0.02); border: 1px dashed var(--line); border-radius: 12px; padding: 14px; text-align: center; cursor: pointer; transition: 0.3s; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; min-height: 80px; }
.file-drop:hover { border-color: var(--accent); background: rgba(255,140,58,0.05); }
.file-drop.ready { border-style: solid; border-color: var(--accent); background: rgba(255,140,58,0.1); }
.file-drop.ready div { color: var(--accent) !important; opacity: 1 !important; font-weight: 800; }

.inline-prog { width: 100%; height: 4px; background: rgba(255,255,255,0.05); border-radius: 4px; overflow: hidden; margin: 10px 0; display: none; }
.inline-prog-fill { height: 100%; width: 0%; background: var(--accent); box-shadow: 0 0 10px var(--accent-glow); transition: width 0.3s ease; }
.inline-prog-txt { font-size: 10px; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; display: none; }

.ip-link { color: var(--accent); cursor: pointer; text-decoration: underline dotted rgba(255,140,58,0.3); transition: 0.2s; }
.ip-link:hover { text-decoration-color: var(--accent); filter: brightness(1.2); }

.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(12px); display: none; align-items: center; justify-content: center; z-index: 4000; animation: fadeIn 0.3s ease; }
.modal-overlay.visible { display: flex; }
.modal-box { background: radial-gradient(circle at top left, #1c212b, #13171e); border: 1px solid var(--line); border-radius: 32px; width: 480px; padding: 48px; box-shadow: 0 50px 150px rgba(0,0,0,0.9); animation: slideUp 0.4s cubic-bezier(0.18, 0.89, 0.32, 1.28); position: relative; }

.emoji-picker { position: fixed; background: #1a1e26; border: 1px solid var(--accent); border-radius: 40px; padding: 8px 12px; display: flex; gap: 8px; box-shadow: 0 15px 40px rgba(0,0,0,0.6); z-index: 2000; opacity: 0; pointer-events: none; transform: scale(0.8) translateY(10px); transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28); transform-origin: bottom center; }
.emoji-picker.visible { opacity: 1; pointer-events: all; transform: scale(1) translateY(0); }
.emoji-item { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; font-size: 22px; cursor: pointer; transition: 0.2s; border-radius: 50%; }
.emoji-item:hover { transform: scale(1.4); background: rgba(255,255,255,0.05); }

.ctx-menu { position: fixed; background: #1a1e26; border: 1px solid var(--accent); border-radius: 14px; padding: 8px; box-shadow: 0 15px 40px rgba(0,0,0,0.8); z-index: 5000; display: none; flex-direction: column; min-width: 160px; backdrop-filter: blur(20px); }
.ctx-item { padding: 10px 14px; color: #fff; font-size: 13px; cursor: pointer; border-radius: 8px; display: flex; align-items: center; justify-content: space-between; transition: 0.2s; }
.ctx-item:hover { background: rgba(255,140,58,0.15); color: var(--accent); }
.ctx-sep { height: 1px; background: var(--line); margin: 6px 4px; }

.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,0.9); z-index: 3000; display: none; align-items: center; justify-content: center; backdrop-filter: blur(12px); }
.lightbox.visible { display: flex; }
.lb-content { position: relative; width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.lb-img { max-width: 90%; max-height: 90%; border-radius: 12px; box-shadow: 0 40px 150px rgba(0,0,0,1); cursor: grab; user-select: none; }
.lb-img:active { cursor: grabbing; transition: none; }
.lb-tools { position: absolute; top: 40px; right: 40px; display: flex; gap: 15px; z-index: 100; }

@keyframes msgIn { from { opacity: 0; transform: translateY(20px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes msgFlash { 0% { background: rgba(255,140,58,0.3); } 100% { background: transparent; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(15px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.lb-btn { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff; width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: 0.3s; }
.lb-btn:hover { background: var(--accent); color: #000; border-color: var(--accent); transform: scale(1.1); }

.hidden { display: none !important; }

.cal-popup { position: absolute; top: calc(100% + 10px); left: 0; background: #0f1218; border: 1px solid var(--accent); border-radius: 16px; padding: 20px; z-index: 1000; box-shadow: 0 20px 50px rgba(0,0,0,0.5); width: 280px; }
.cal-popup.hidden { opacity: 0; transform: scale(0.95); pointer-events: none; }
.cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.cal-header span { font-weight: 700; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; color: var(--accent); }
.cal-btn { background: rgba(255,255,255,0.05); border: 1px solid var(--line); width: 32px; height: 32px; border-radius: 8px; color: #fff; cursor: pointer; }
.cal-btn:hover { background: var(--accent); color: #000; }
.cal-days-head { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 8px; }
.cal-day-label { font-size: 10px; font-weight: 800; color: var(--muted); text-align: center; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.cal-day { padding: 8px; font-size: 12px; text-align: center; border-radius: 8px; cursor: pointer; color: #fff; transition: 0.2s; border: 1px solid transparent; opacity: 0.8; }
.cal-day:hover:not(.empty) { background: rgba(255,140,58,0.1); border-color: rgba(255,140,58,0.2); }
.cal-day.active { background: var(--accent); color: #000; font-weight: 800; box-shadow: 0 4px 10px var(--accent-glow); }
.cal-day.empty { cursor: default; opacity: 0; }
.cal-day.disabled { opacity: 0.15; cursor: not-allowed; }

::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.05); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.05) transparent; }

.tg-file { display: flex; align-items: center; gap: 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--line); border-radius: 16px; padding: 12px 20px; margin-top: 12px; max-width: 320px; cursor: pointer; transition: 0.2s; }
.tg-file:hover { background: rgba(255,255,255,0.06); border-color: var(--accent); transform: translateX(4px); }
.tg-icon { width: 48px; height: 48px; min-width: 48px; border-radius: 50%; background: #2481cc; display: flex; align-items: center; justify-content: center; color: #fff; }
.tg-icon svg { width: 20px; height: 20px; }
.tg-info { display: flex; flex-direction: column; gap: 2px; }
.tg-name { font-size: 14px; font-weight: 700; color: #fff; word-break: break-all; }
.tg-meta { font-size: 11px; color: var(--muted); opacity: 0.6; }

.tm-img { max-width: 400px; border-radius: 16px; margin-top: 12px; border: 1px solid var(--line); box-shadow: 0 10px 40px rgba(0,0,0,0.4); display: block; }
.attach-preview { width: 40px; height: 40px; border-radius: 8px; border: 1px dashed var(--line); display: flex; align-items: center; justify-content: center; overflow: hidden; cursor: pointer; position: relative; }
.attach-preview img { width: 100%; height: 100%; object-fit: cover; }
.attach-del { position: absolute; top: 0; right: 0; background: var(--bad); color: #fff; font-size: 8px; padding: 2px 4px; border-radius: 2px; }

.code-attachment { background: #0d1117; border: 1px solid var(--line); border-radius: 12px; margin-top: 12px; overflow: hidden; font-family: 'JetBrains Mono', monospace; font-size: 12px; display: flex; max-height: 250px; }
.code-lines { background: rgba(255,255,255,0.03); color: var(--muted); border-right: 1px solid var(--line); padding: 12px 6px; text-align: right; min-width: 34px; user-select: none; opacity: 0.5; }
.code-content { padding: 12px; overflow-x: auto; color: #e6edf3; flex: 1; white-space: pre; }
.code-content span.keyword { color: #ff7b72; }
.code-content span.string { color: #a5d6ff; }
.code-content span.comment { color: #8b949e; font-style: italic; }
CSSEOF

cat > "$ADMIN_DIR/app.js" <<'JSEOF'
'use strict';
let CUR_LANG = 'EN';
let CUR_USER = null;
let MOCK_TOPICS = [];
let curTopicId = null;
let ACTIVE_BUILD_KEY = null;
let COMP_INT = null;
let currentReply = null;
let attachedFile = null;
var activeReactMsg = -1;
let activeCtxMsgIdx = -1;
let curCalDate = new Date();
let selectedDate = null;
let lbScale = 1, lbPosX = 0, lbPosY = 0, isLbDragging = false, startX, startY;

/* ── API ── */
async function apiCall(endpoint, method, body) {
    method = method || 'GET';
    const token = localStorage.getItem('DC_TOKEN');
    const opts = { method, headers: {} };
    if (token) opts.headers['X-Admin-Token'] = token;
    if (body && !(body instanceof FormData)) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
    else if (body) { opts.body = body; }
    const res = await fetch(endpoint, opts);
    if (!res.ok) { if (res.status === 401 || res.status === 403) logout(); throw new Error(await res.text()); }
    return res.json();
}

/* ── AUTH ── */
async function auth() {
    const nick = document.getElementById('loginNick').value.trim();
    const token = document.getElementById('loginToken').value.trim();
    if (!nick || !token) { document.getElementById('gateStatus').textContent = "Empty credentials"; return; }
    try {
        const data = await apiCall('/api/auth/admin/login', 'POST', { nickname: nick, invite_code: token });
        localStorage.setItem('DC_TOKEN', data.token);
        localStorage.setItem('DC_USER', JSON.stringify(data.user));
        CUR_USER = data.user;
        initApp();
    } catch (e) { document.getElementById('gateStatus').textContent = "Invalid Credentials or Token"; }
}

function logout() { localStorage.removeItem('DC_TOKEN'); localStorage.removeItem('DC_USER'); location.reload(); }

/* ── INIT ── */
function initApp() {
    if (!CUR_USER) return;
    const gate = document.getElementById('gate');
    const shell = document.getElementById('mainShell');
    if (gate) gate.classList.add('hidden');
    if (shell) shell.classList.remove('hidden');

    const nickEl = document.getElementById('uNick');
    const roleEl = document.getElementById('uRole');
    const daysEl = document.getElementById('uDaysTxt');
    const avaEl = document.getElementById('uAvatar');
    if (!nickEl || !roleEl) return;

    nickEl.textContent = CUR_USER.nick;
    let rDisp = CUR_USER.role.toUpperCase();
    if (CUR_USER.role === 'loader dev') rDisp = 'LOADER DEV';
    if (CUR_USER.role === 'emulator dev') rDisp = 'EMULATOR DEV';
    roleEl.textContent = rDisp;

    const isStaff = CUR_USER.lifetime;
    const isOwner = CUR_USER.role === 'owner';
    const role = CUR_USER.role;

    if (isStaff) {
        if (daysEl) daysEl.textContent = "LIFETIME";
        roleEl.style.color = 'var(--bad)';
        if (avaEl) { avaEl.style.cursor = 'pointer'; avaEl.title = "Click to change avatar"; }
    } else {
        if (daysEl) daysEl.textContent = 'Days: ' + (CUR_USER.days || 30);
    }

    if (CUR_USER.avatar && avaEl) { avaEl.style.backgroundImage = 'url(' + CUR_USER.avatar + ')'; avaEl.textContent = ''; }
    else if (avaEl) { avaEl.textContent = CUR_USER.nick.substring(0, 2).toUpperCase(); }

    /* Navigation visibility */
    var headDash = document.getElementById('nav-head-dash');
    if (headDash) headDash.style.display = isStaff ? 'block' : 'none';

    ['nav-overview', 'nav-license', 'nav-session', 'nav-logs'].forEach(function(id) {
        var el = document.getElementById(id); if (el) el.style.display = isStaff ? 'flex' : 'none';
    });

    var wsEl = document.getElementById('nav-workspace');
    if (wsEl) wsEl.style.display = isStaff ? 'block' : 'none';

    var navDiscussions = document.getElementById('nav-discussions');
    if (navDiscussions) navDiscussions.style.display = isStaff ? 'flex' : 'none';

    var navEmu = document.getElementById('nav-emu');
    if (navEmu) navEmu.style.display = (isOwner || role === 'emulator dev') ? 'flex' : 'none';

    var navLdr = document.getElementById('nav-ldr');
    if (navLdr) navLdr.style.display = (isOwner || role === 'loader dev') ? 'flex' : 'none';

    var navInfra = document.getElementById('nav-infra');
    if (navInfra) navInfra.style.display = isOwner ? 'flex' : 'none';

    var navDl = document.getElementById('nav-download');
    if (navDl) navDl.style.display = 'flex';

    setLang(CUR_LANG);
    showTab(isStaff ? 'overview' : 'download', document.getElementById(isStaff ? 'nav-overview' : 'nav-download'));
    startPolling();
}

/* ── LANG ── */
// TRANSLATIONS moved to i18n.js

function setLang(l) {
    CUR_LANG = l; localStorage.setItem('DC_LANG', l);
    document.querySelectorAll('.lang-item').forEach(function(x) { x.classList.toggle('active', x.textContent === l); });
    var uLang = document.getElementById('uLangTxt'); if (uLang) uLang.textContent = l;
    var dict = TRANSLATIONS[l]; if (!dict) return;
    
    document.querySelectorAll('.navbtn, .label, button, th, span, div.info-sub, input').forEach(function(el) {
        var baseText = el.dataset.i18n || (el.tagName === 'INPUT' ? el.placeholder : el.textContent);
        if (!baseText) return;
        baseText = baseText.trim();
        if (dict[baseText]) {
            if (!el.dataset.i18n) el.dataset.i18n = baseText;
            if (el.tagName === 'INPUT') el.placeholder = dict[baseText];
            else el.textContent = dict[baseText];
        }
    });
}

/* ── NAV ── */
function showTab(name, el) {
    document.querySelectorAll('section[id^="view-"]').forEach(function(x) { x.classList.add('hidden'); });
    var target = document.getElementById('view-' + name);
    if (target) target.classList.remove('hidden');
    document.querySelectorAll('.navbtn').forEach(function(x) { x.classList.remove('active'); });
    if (el) el.classList.add('active');
    
    // Auto-resume Personal Distribution if key is saved
    if (name === 'download') {
        const savedKey = localStorage.getItem('DC_DOWNLOAD_KEY');
        if (savedKey) {
            const inp = document.getElementById('buildKeyInp');
            if (inp && !inp.value) {
                inp.value = savedKey;
                checkDLKey(savedKey);
                startPreBuild();
            }
        }
    }

    var titleEl = document.getElementById('viewTitle');
    if (titleEl) {
        titleEl.dataset.base = name;
        var titles = { overview:'Overview', keys:'Licenses', users:'Sessions', logs:'Security Logs', application:'Applications', emu:'Emulator Dev', ldr:'Loader Dev', updates:'Infrastructure', download:'Personal Distribution' };
        titleEl.textContent = titles[name] || name;
    }
    setLang(CUR_LANG);
    refreshData();
}

/* ── POLLING ── */
function startPolling() {
    loadServerStats().catch(function(){});
    loadOverview().catch(function(){});
    setInterval(function() { loadServerStats().catch(function(){}); }, 3000);
    setInterval(function() { loadOverview().catch(function(){}); loadLogs().catch(function(){}); }, 10000);
}

/* ── DATA LOADERS ── */
async function loadServerStats() {
    if (!CUR_USER || !CUR_USER.lifetime) return;
    var d = await apiCall('/api/admin/server/stats');
    var cpuBar = document.getElementById('cpuBar');
    var cpuText = document.getElementById('cpuText');
    var memBar = document.getElementById('memBar');
    var memText = document.getElementById('memText');
    var loadBar = document.getElementById('loadBar');
    var loadText = document.getElementById('loadText');
    var serverMeta = document.getElementById('serverMeta');
    if (cpuBar) cpuBar.style.width = d.cpu_load_percent + '%';
    if (cpuText) cpuText.textContent = d.cpu_load_percent + '%';
    if (memBar) memBar.style.width = d.memory_used_percent + '%';
    if (memText) memText.textContent = d.memory_used_gb + 'GB';
    if (loadBar) loadBar.style.width = Math.min(d.load1 * 10, 100) + '%';
    if (loadText) loadText.textContent = d.load1 + 'ms';
    if (serverMeta) serverMeta.textContent = 'Dreamcore-Cloud-01 \u2022 ' + d.system_name + ' \u2022 ' + d.cpu_count + ' CPU \u2022 ' + d.server_ip;
}

async function loadOverview() {
    if (!CUR_USER || !CUR_USER.lifetime) return;
    try {
        var ov = await apiCall('/api/admin/overview');
        var el;
        el = document.getElementById('mTotal'); if (el) el.textContent = ov.total_keys;
        el = document.getElementById('mOnline'); if (el) el.textContent = ov.online_users;
        el = document.getElementById('mTamper'); if (el) el.textContent = ov.tamper_events;
        el = document.getElementById('mSessions'); if (el) el.textContent = ov.active_sessions;
    } catch(e) {}
}

async function refreshData() {
    if (!CUR_USER || !CUR_USER.lifetime) return;
    try {
        await loadOverview();
        await loadServerStats();
        await loadKeys();
        await loadUsers();
        await loadLogs();
        await loadBuilds();
        await loadTopics();
    } catch (e) { console.error(e); }
}

async function loadKeys() {
    if (!CUR_USER || !CUR_USER.lifetime) return;
    var lics = await apiCall('/api/admin/licenses');
    var search = (document.getElementById('keyFilter') ? document.getElementById('keyFilter').value : '').toLowerCase();
    var filtered = (lics.items || []).filter(function(x) { return x.username.toLowerCase().indexOf(search) > -1 || x.key.toLowerCase().indexOf(search) > -1 || (x.hw || '').toLowerCase().indexOf(search) > -1; });
    var body = document.getElementById('keysBody');
    if (body) body.innerHTML = filtered.map(function(x) {
        return '<tr><td style="font-weight:700">' + x.username + '</td><td style="color:var(--accent); font-family:monospace">' + x.key + '</td><td><span class="pill ' + (x.status === 'active' ? 'status-active' : 'status-blocked') + '">' + x.status + '</span></td><td style="font-size:12px; opacity:0.7">' + (x.created_by || '\u2014') + '</td><td style="font-size:13px; opacity:0.6">' + (x.hw || '\u2014') + '</td><td>' + x.expires + '</td><td>' + (x.region || '\u2014') + '</td><td><div style="display:flex; gap:12px"><button class="btn-muted ' + (x.status === 'active' ? 'btn-danger' : 'btn-success') + '" style="padding:8px 14px; font-size:12px" onclick="setLicenseStatus(\'' + x.key + '\', ' + (x.status === 'active') + ')">' + (x.status === 'active' ? 'Block' : 'Unblock') + '</button><button class="btn-muted btn-danger" style="padding:8px 14px; font-size:12px; color:var(--bad)" onclick="revokeLicense(\'' + x.key + '\')">Revoke</button></div></td></tr>';
    }).join('');
}

async function loadUsers() {
    if (!CUR_USER || !CUR_USER.lifetime) return;
    var users = await apiCall('/api/admin/users');
    var body = document.getElementById('usersBody');
    if (body) body.innerHTML = (users.items || []).map(function(x) {
        return '<tr><td>' + x.username + '</td><td style="font-family:monospace; font-size:12px">' + (x.hwid || '\u2014') + '</td><td><span class="pill" style="color:var(--accent); border:1px solid var(--line)">' + x.branch + '</span></td><td>' + (x.region || '\u2014') + '</td><td style="color:var(--good)">' + x.pulse + '</td><td style="font-size:12px; opacity:0.8">' + (x.logout_reason || '\u2014') + '</td></tr>';
    }).join('');
}

async function loadLogs() {
    if (!CUR_USER || !CUR_USER.lifetime) return;
    var logs = await apiCall('/api/admin/logs');
    var logs = await apiCall('/api/admin/logs');
    var body = document.getElementById('logsBody');
    if (body) {
        body.innerHTML = (logs.items || []).map(function(x) {
            var d = x.desc;
            var ev = x.event;
            var ipDisp = x.ip;
            var risk = x.risk;

            try {
                var j = JSON.parse(x.desc);
                if (ev === 'license.create') {
                    ev = 'KEY GENERATED';
                    d = 'Created ' + j.license_key + ' for ' + (j.lifetime ? 'Lifetime' : j.days);
                    ipDisp = ''; risk = '';
                } else if (ev === 'license.active') {
                    ev = 'KEY UNBLOCKED';
                    d = 'Unblocked ' + j.license_key;
                    ipDisp = ''; risk = '';
                } else if (ev === 'license.blocked') {
                    ev = 'KEY BLOCKED';
                    d = 'Blocked ' + j.license_key;
                    ipDisp = ''; risk = '';
                } else if (ev === 'license.revoke') {
                    ev = 'KEY REVOKED';
                    d = 'Revoked ' + j.license_key;
                    ipDisp = ''; risk = '';
                } else if (j.license_key) {
                    d = 'License ' + j.license_key.substring(0,8) + '...';
                }
            } catch(e) {}

            var riskHtml = risk ? '<span style="color:' + (risk === 'RISK' ? 'var(--bad)' : 'var(--good)') + '">' + risk + '</span>' : '';
            var ipHtml = '';
            if (ipDisp && ipDisp !== 'Internal' && ipDisp !== 'Local') {
                ipHtml = '<span class="ip-link" onclick="openIpMod(\'' + ipDisp + '\')">' + ipDisp + '</span>';
            }

            return '<tr><td style="font-weight:700">' + x.username + '</td><td><span style="font-size:10px; opacity:0.8; font-weight:800">' + ev + '</span></td><td style="font-size:12px; opacity:0.8">' + d + '</td><td>' + riskHtml + '</td><td>' + ipHtml + '</td><td>' + x.time + '</td></tr>';
        }).join('');
    }
}

async function loadBuilds() {
    if (!CUR_USER || !CUR_USER.lifetime) return;
    var builds = await apiCall('/api/admin/builds');
    var body = document.getElementById('infraBody');
    if (body) body.innerHTML = (builds.items || []).map(function(b) {
        return '<tr><td><span class="pill" style="border-color:' + (b.channel === 'Nightly' ? 'var(--bad)' : 'var(--accent)') + '; color:' + (b.channel === 'Nightly' ? 'var(--bad)' : 'var(--accent)') + '; font-size:10px">' + b.channel.toUpperCase() + '</span></td><td style="font-weight:800; color:#fff">' + b.version + '</td><td style="opacity:0.6; font-size:12px">' + b.loader + '</td><td style="font-size:12px; opacity:0.9">' + b.notes + '</td><td style="font-size:11px; color:var(--muted)">' + b.date + '</td></tr>';
    }).join('');
}

async function loadTopics() {
    if (!CUR_USER || !CUR_USER.lifetime) return;
    try {
        var topics = await apiCall('/api/admin/topics');
        MOCK_TOPICS = topics || [];
        renderTopicsList();
    } catch(e) {}
}

/* ── ACTIONS ── */
async function setLicenseStatus(key, block) {
    try { await apiCall('/api/admin/license/block', 'POST', { license_key: key, blocked: block }); refreshData(); } catch (e) { alert(e.message); }
}
async function revokeLicense(key) {
    if (!confirm('Permanently revoke this key?')) return;
    try { await apiCall('/api/admin/license/revoke', 'POST', { license_key: key }); refreshData(); } catch (e) { alert(e.message); }
}
async function qCreate() {
    try {
        const u = document.getElementById('qUser').value || 'NewUser';
        const dRaw = document.getElementById('qDays').value || '30';
        const isLife = dRaw.toLowerCase() === 'lifetime';
        const res = await apiCall('/api/admin/license/create', 'POST', {
            username: u,
            days: isLife ? null : parseInt(dRaw),
            lifetime: isLife,
            allow_nightly: document.getElementById('qNightly').checked
        });
        document.getElementById('qStatus').textContent = "Key: " + res.key;
        refreshData();
    } catch (e) { document.getElementById('qStatus').textContent = "Error creating key"; }
}
async function genInvite() {
    try {
        var res = await apiCall('/api/admin/invites/create', 'POST', { role: document.getElementById('invRole').value });
        document.getElementById('invRes').textContent = res.code;
    } catch (e) { document.getElementById('invRes').textContent = "Error / Forbidden"; }
}

/* ── AVATAR ── */
function promptAvatar() { if (CUR_USER && CUR_USER.lifetime) document.getElementById('avaInp').click(); }
async function uploadAvatar(e) {
    if (!e.target.files[0] || !CUR_USER.lifetime) return;
    var fd = new FormData(); fd.append('file', e.target.files[0]);
    try {
        var res = await apiCall('/api/users/avatar', 'POST', fd);
        CUR_USER.avatar = res.url; localStorage.setItem('DC_USER', JSON.stringify(CUR_USER));
        var ava = document.getElementById('uAvatar'); ava.style.backgroundImage = 'url(' + res.url + ')'; ava.textContent = '';
    } catch (e) { alert("Upload failed"); }
}

/* ── CUSTOM SELECT ── */
function toggleCS(id) {
    document.querySelectorAll('.cs-options').forEach(function(x) { if (x.parentNode.id !== id) x.classList.remove('visible'); });
    var el = document.getElementById(id); if (el) el.querySelector('.cs-options').classList.toggle('visible');
}
function setCS(id, val) {
    var el = document.getElementById(id);
    el.querySelector('.cs-trigger').textContent = val;
    el.querySelector('input').value = val;
    document.querySelectorAll('.cs-options').forEach(function(x) { x.classList.remove('visible'); });
}

/* ── DEPLOY ── */
function handleBin(type, e) {
    var f = e.target.files[0];
    if (f) { document.getElementById(type + 'Area').classList.add('ready'); document.getElementById(type + 'Name').textContent = f.name; }
}
async function deployBuild() {
    var v = document.getElementById('depVer').value;
    var c = document.getElementById('depChan').value;
    var l = document.getElementById('depLdr').value;
    var n = document.getElementById('depLogs').value;
    var fE = document.getElementById('depExe').files[0];
    var fD = document.getElementById('depDll').files[0];
    if (!v || !l || !fE || !fD) { alert("Version, Loader, and both Binaries required"); return; }
    var fd = new FormData();
    fd.append("version", v); fd.append("channel", c); fd.append("required_loader_version", l); fd.append("notes", n);
    fd.append("dream_driver", fE); fd.append("dream_session_watch", fD);
    var btn = document.getElementById('deployBtn');
    btn.disabled = true; btn.textContent = "Uploading...";
    var il = document.getElementById('inlineLabel');
    var ip = document.getElementById('inlineProg');
    var ifl = document.getElementById('inlineFill');
    if (il) il.style.display = 'block'; if (ip) ip.style.display = 'block'; if (ifl) ifl.style.width = '50%';
    try {
        var token = localStorage.getItem('DC_TOKEN');
        var res = await fetch('/api/admin/build/upload', { method: 'POST', headers: { 'X-Admin-Token': token }, body: fd });
        if (!res.ok) throw new Error(await res.text());
        if (ifl) ifl.style.width = '100%';
        setTimeout(function(){ if (il) il.style.display = 'none'; if (ip) ip.style.display = 'none'; }, 600);
        alert("Success! Global force-rebuild flag set."); refreshData();
    } catch (e) { alert("Upload error: " + e.message); }
    btn.disabled = false; btn.textContent = "Seal & Upload Bundle";
}

/* ── TOPICS ── */
async function createTopic() {
    var title = document.getElementById('qTopicTitle').value.trim();
    var text = document.getElementById('qTopicMsg').value.trim();
    if (!title && (!attachedTopicFiles || attachedTopicFiles.length === 0)) return;
    
    var firstMsg = { user: CUR_USER.nick, text: text || 'Attached files.', time: new Date().toLocaleTimeString() };
    if (attachedTopicFiles && attachedTopicFiles.length > 0) { 
        firstMsg.img = attachedTopicFiles.filter(f => f.isImg).map(f => f.data); 
        firstMsg.files = attachedTopicFiles; 
    }
    
    MOCK_TOPICS.unshift({ id: Date.now(), title: title || 'New Topic', category: document.getElementById('qTopicArea').value, author: CUR_USER.nick, msgs: [firstMsg] });
    await apiCall('/api/admin/topics', 'POST', MOCK_TOPICS);
    
    document.getElementById('qTopicTitle').value = ''; document.getElementById('qTopicMsg').value = '';
    clearTopicAttach();
    renderTopicsList();
}

function renderTopicsList() {
    var el = document.getElementById('topicsList'); if (!el) return;
    el.innerHTML = (MOCK_TOPICS || []).map(function(t) {
        var s = (t.status || 'pending').toLowerCase();
        var statusClass = s === 'declined' ? 'status-blocked' : (s === 'completed' ? 'status-active' : (s === 'in progress' ? 'pill' : 'status-active'));
        if (s === 'pending') statusClass = 'pill'; 
        
        var userVote = 0;
        if (t.userVotes && t.userVotes[CUR_USER.nick]) userVote = t.userVotes[CUR_USER.nick];

        return '<div class="topic-item" style="cursor:pointer" onclick="enterTopic(' + t.id + ')">' +
            '<div class="vote-box">' +
            '<div class="vote-btn ' + (userVote === 1 ? 'active' : '') + '" onclick="voteTopic(event, ' + t.id + ', 1)">&#x25B2;</div>' + 
            '<div class="vote-count" style="color:var(--accent)">' + (t.votes || 0) + '</div>' + 
            '<div class="vote-btn ' + (userVote === -1 ? 'active' : '') + '" style="transform:rotate(180deg)" onclick="voteTopic(event, ' + t.id + ', -1)">&#x25B2;</div>' +
            '</div><div style="flex:1">' +
            '<div style="display:flex; justify-content:space-between; margin-bottom:10px">' +
            '<span style="font-weight:700; font-size:16px; color:#fff">' + t.title + '</span>' +
            '<div style="display:flex; gap:8px">' +
            '<span class="pill ' + statusClass + '" style="font-size:10px; padding:4px 10px">' + s.toUpperCase() + '</span>' +
            '<span class="pill" style="font-size:10px; border-color:var(--line); padding:4px 10px">' + t.category.toUpperCase() + '</span>' +
            '</div></div>' +
            '<div style="font-size:13px; color:var(--muted); line-height:1.4">' + (t.msgs[0] ? t.msgs[0].text.substring(0, 100).replace(/<[^>]*>/g, '') + '...' : '') + '</div>' +
            '<div style="display:flex; justify-content:space-between; align-items:center; margin-top:14px; opacity:0.6; font-size:11px">' +
            '<span>by <b>' + t.author + '</b> \u2022 ' + (t.msgs ? t.msgs.length - 1 : 0) + ' replies</span>' +
            '<span style="color:var(--accent); font-weight:700; cursor:pointer">Open Chat &#x2192;</span>' +
            '</div></div></div>';
    }).join('');
}
async function voteTopic(e, id, diff) {
    if (e) e.stopPropagation();
    var t = MOCK_TOPICS.find(function(x) { return x.id === id; });
    if (!t) return;
    if (!t.userVotes) t.userVotes = {};
    if (!t.votes) t.votes = 0;
    
    var current = t.userVotes[CUR_USER.nick] || 0;
    if (current === diff) {
        // Toggle off
        t.votes -= current;
        t.userVotes[CUR_USER.nick] = 0;
    } else {
        // Change vote
        t.votes -= current;
        t.votes += diff;
        t.userVotes[CUR_USER.nick] = diff;
    }
    await apiCall('/api/admin/topics', 'POST', MOCK_TOPICS);
    renderTopicsList();
}


async function setTopicStatus(status) {
    if (!curTopicId) return;
    var t = MOCK_TOPICS.find(function(x) { return x.id === curTopicId; });
    if (t) { t.status = status; await apiCall('/api/admin/topics', 'POST', MOCK_TOPICS); enterTopic(curTopicId); }
}
function enterTopic(id) {
    curTopicId = id;
    var t = MOCK_TOPICS.find(function(x) { return x.id === id; });
    if (!t) return;
    document.getElementById('topicsListView').classList.add('hidden');
    document.getElementById('topicDetailView').classList.remove('hidden');
    document.getElementById('detArea').textContent = t.category.toUpperCase();
    document.getElementById('detTitle').textContent = t.title;

    var btn = document.getElementById('detStatusBtn');
    var canManageStatus = CUR_USER.role === 'owner' || CUR_USER.nick === t.author;
    if (canManageStatus && btn) {
        btn.style.display = 'flex';
        var s = (t.status || 'pending').toLowerCase();
        var disp = s.toUpperCase();
        if (s === 'pending') disp = 'PENDING';
        if (s === 'in progress') disp = 'IN PROCESS';
        if (s === 'completed') disp = 'DONE';
        if (s === 'declined') disp = 'REJECTED';
        btn.innerHTML = '<span>' + disp + '</span> <span style="font-size:8px; opacity:0.5">&#x25BC;</span>';
        
        // Match screenshot colors
        btn.className = 'pill';
        btn.style.background = 'rgba(255,255,255,0.05)';
        btn.style.borderColor = 'rgba(255,255,255,0.1)';
        btn.style.color = '#fff';
        
        if (s === 'pending') { btn.style.color = 'var(--good)'; btn.style.borderColor = 'rgba(74, 222, 128, 0.2)'; btn.style.background = 'rgba(74, 222, 128, 0.05)'; }
        if (s === 'in progress') { btn.style.background = 'rgba(255, 140, 58, 0.2)'; btn.style.borderColor = 'var(--accent)'; }
        if (s === 'declined') { btn.style.color = 'var(--bad)'; btn.style.borderColor = 'rgba(248, 113, 113, 0.2)'; }
    } else if (btn) {
        btn.style.display = 'none';
    }
    renderMsgs();
}


function backToTopics() {
    document.getElementById('topicDetailView').classList.add('hidden');
    document.getElementById('topicsListView').classList.remove('hidden');
    curTopicId = null; clearAttach(); renderTopicsList();
}
async function sendMsg() {
    var text = document.getElementById('chatInput').value.trim();
    if (!text && !attachedFile) return;
    if (!curTopicId) return;
    var t = MOCK_TOPICS.find(function(x) { return x.id === curTopicId; });
    var msg = { 
        user: CUR_USER.nick, 
        text: text, 
        time: new Date().toLocaleTimeString(),
        avatar: CUR_USER.avatar || null 
    };
    if (attachedFile) { msg.img = [attachedFile.data]; msg.files = [attachedFile]; }
    if (currentReply) { msg.replyTo = currentReply; }
    t.msgs.push(msg);
    await apiCall('/api/admin/topics', 'POST', MOCK_TOPICS);
    document.getElementById('chatInput').value = ''; cancelReply(); clearAttach(); renderMsgs();
}
function renderMsgs() {
    if (!curTopicId) return;
    var t = MOCK_TOPICS.find(function(x) { return x.id === curTopicId; });
    var c = document.getElementById('chatMsgs');
    c.innerHTML = (t.msgs || []).map(function(m, idx) {
        var avaStyle = '';
        if (m.avatar) avaStyle = 'background-image:url(' + m.avatar + ')';
        var initials = m.user.substring(0, 2).toUpperCase();
        var replyHtml = '';
        if (m.replyTo) { replyHtml = '<div class="tm-quote" onclick="scrollToMsg(' + m.replyTo.idx + ')"><div class="tm-quote-user">' + m.replyTo.user + '</div><div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap">' + m.replyTo.text + '</div></div>'; }
        
        var reactHtml = '';
        if (m.reactions) {
            for (var emoji in m.reactions) {
                var users = m.reactions[emoji];
                if (users.length > 0) {
                    var active = users.indexOf(CUR_USER.nick) > -1;
                    reactHtml += '<div class="tm-react ' + (active ? 'active' : '') + '" onclick="toggleReaction(' + idx + ', \'' + emoji + '\')">' + emoji + ' ' + users.length + '</div>';
                }
            }
        }
        reactHtml += '<div class="tm-react add" onclick="addReaction(event, ' + idx + ')">+</div>';
        
        var attachmentsHtml = '';
        if (m.files && m.files.length > 0) {
            attachmentsHtml = m.files.map(function(f, i) {
                if (f.isImg) {
                    return '<img src="' + f.data + '" class="tm-img" onclick="openLB(\'' + f.data + '\')">';
                } else {
                    var codeHtml = '';
                    if (f.isCode && f.textContent) {
                        var safeText = f.textContent.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
                        safeText = safeText.replace(/(\/\/.+)/g, '<span style="color:#6a9955">$1</span>');
                        safeText = safeText.replace(/(".*?")/g, '<span style="color:#ce9178">$1</span>');
                        safeText = safeText.replace(/\b(int|void|char|class|struct|if|else|for|while|return|public|private|protected|static|using|namespace|include|string|float|double|bool)\b/g, '<span style="color:#569cd6">$1</span>');
                        codeHtml = '<div style="background:#1e1e1e; border:1px solid #333; border-radius:8px; margin-top:12px; padding:12px; font-family:monospace; max-height:200px; overflow:auto; font-size:12px; position:relative">' +
                                   '<div style="position:absolute; top:4px; right:8px; font-size:9px; color:var(--accent); font-weight:800; opacity:0.6">' + f.name.toUpperCase() + '</div>' +
                                   '<pre style="margin:0; white-space:pre">' + safeText + '</pre></div>';
                    }
                    return codeHtml + '<div class="tg-file" onclick="downloadFile(\'' + f.data + '\', \'' + f.name + '\')" style="display:flex; align-items:center; gap:12px; background:rgba(255,255,255,0.05); padding:12px; border-radius:8px; border:1px solid rgba(255,255,255,0.1); margin-top:8px; cursor:pointer"><div style="color:var(--accent)"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg></div><div><div style="font-weight:700; font-size:13px; color:#fff">' + f.name + '</div><div style="font-size:11px; color:var(--muted)">' + f.size + ' \u2022 Click to download</div></div></div>';
                }
            }).join('');
        }
        
        var dt = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
        return '<div class="topic-msg" id="msg-' + idx + '"><div class="tm-ava" style="' + avaStyle + '">' + (m.avatar ? '' : initials) + '</div><div class="tm-body"><div class="tm-meta"><span class="tm-user">' + m.user + '</span><span class="tm-time">' + m.time + '</span><div style="flex:1"></div><div class="tm-react add" onclick="initReply(' + idx + ')" style="padding:4px 8px; font-weight:700">Reply</div></div>' + replyHtml + '<div class="tm-text" style="font-weight:500">' + m.text + '</div>' + attachmentsHtml + '<div class="tm-reactions">' + reactHtml + '</div></div></div>';
    }).join('');
    c.scrollTop = c.scrollHeight;
}



function downloadFile(data, name) {
    var a = document.createElement('a'); a.href = data; a.download = name; a.click();
}


/* ── REACTIONS ── */
async function toggleReaction(idx, emoji) {
    if (!curTopicId) return;
    var t = MOCK_TOPICS.find(function(x) { return x.id === curTopicId; });
    var m = t.msgs[idx];
    if (!m.reactions) m.reactions = {};
    if (!m.reactions[emoji]) m.reactions[emoji] = [];
    var uIdx = m.reactions[emoji].indexOf(CUR_USER.nick);
    if (uIdx > -1) m.reactions[emoji].splice(uIdx, 1);
    else m.reactions[emoji].push(CUR_USER.nick);
    await apiCall('/api/admin/topics', 'POST', MOCK_TOPICS);
    renderMsgs();
}
function addReaction(e, idx) {
    if (e) e.stopPropagation();
    activeReactMsg = idx;
    var p = document.getElementById('emojiPicker');
    if (!p) return;
    var rect = e.target.getBoundingClientRect();
    p.style.left = (rect.left - 40) + 'px';
    p.style.top = (rect.top - 60) + 'px';
    p.classList.add('visible');
}
function pickEmoji(emoji) {
    if (activeReactMsg > -1) toggleReaction(activeReactMsg, emoji);
    var p = document.getElementById('emojiPicker');
    if (p) p.classList.remove('visible');
    activeReactMsg = -1;
}


/* ── REPLY ── */
function initReply(idx) {
    var t = MOCK_TOPICS.find(function(x) { return x.id === curTopicId; });
    var m = t.msgs[idx];
    var selectedText = window.getSelection().toString().trim();
    currentReply = { idx: idx, user: m.user, text: selectedText || (m.text.substring(0, 80) + (m.text.length > 80 ? '...' : '')) };
    
    // Simulate ctx menu style
    document.getElementById('replyUser').textContent = 'Replying to ' + m.user;
    document.getElementById('replyText').textContent = currentReply.text;
    document.getElementById('replyBar').classList.remove('hidden');
    document.getElementById('chatInput').focus();
}
function cancelReply() { currentReply = null; var rb = document.getElementById('replyBar'); if (rb) rb.classList.add('hidden'); }
function scrollToMsg(idx) {
    var el = document.getElementById('msg-' + idx);
    if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.remove('msg-flashing'); void el.offsetWidth; el.classList.add('msg-flashing'); }
}

/* ── ATTACHMENTS ── */
function handlePaste(e) {
    var item = Array.from(e.clipboardData.items).find(function(x) { return x.kind === 'file'; });
    if (item) {
        var file = item.getAsFile(); var reader = new FileReader();
        reader.onload = function(ev) { attachedFile = { name: file.name || 'Pasted_Image.png', size: (file.size / 1024).toFixed(1) + ' KB', data: ev.target.result }; showAttach(ev.target.result); };
        reader.readAsDataURL(file);
    }
}
function handleFile(e) {
    var file = e.target.files[0];
    if (file && file.size <= 5 * 1024 * 1024) {
        var reader = new FileReader();
        reader.onload = function(ev) { attachedFile = { name: file.name, size: (file.size / 1024).toFixed(1) + ' KB', data: ev.target.result }; showAttach(ev.target.result); };
        reader.readAsDataURL(file);
    }
}
var attachedTopicFiles = [];
function handleTopicFile(e) {
    if (!e.target.files) return;
    var files = Array.from(e.target.files);
    var currentSize = attachedTopicFiles.reduce((acc, f) => acc + f.rawSize, 0);
    
    files.forEach(file => {
        var isImg = file.type.startsWith('image/');
        var imgCount = attachedTopicFiles.filter(f => f.isImg).length;
        if (attachedTopicFiles.length >= 5) return;
        if (isImg && imgCount >= 3) return;
        if (currentSize + file.size > 5 * 1024 * 1024) { alert("Max total size is 5MB"); return; }
        
        currentSize += file.size;
        var ext = file.name.split('.').pop().toLowerCase();
        var codeExts = ['cpp','h','hpp','c','js','py','json','css','txt','sh','bat','ps1'];
        var isCode = codeExts.indexOf(ext) > -1;
        
        var reader = new FileReader();
        reader.onload = function(ev) { 
            var fileObj = { name: file.name, size: (file.size / 1024).toFixed(1) + ' KB', rawSize: file.size, data: ev.target.result, isImg: isImg, isCode: isCode };
            if (isCode) {
                var textReader = new FileReader();
                textReader.onload = function(tev) {
                    fileObj.textContent = tev.target.result;
                    attachedTopicFiles.push(fileObj);
                    renderTopicAttach();
                };
                textReader.readAsText(file);
            } else {
                attachedTopicFiles.push(fileObj);
                renderTopicAttach();
            }
        };
        reader.readAsDataURL(file);
    });
}

function renderTopicAttach() {
    var cont = document.getElementById('tAttachList');
    if (!cont) return;
    cont.innerHTML = attachedTopicFiles.map((f, idx) => {
        return '<div style="width:40px;height:40px;border-radius:8px;border:1px dashed var(--line);position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden">' + 
               (f.isImg ? '<img src="' + f.data + '" style="width:100%;height:100%;object-fit:cover">' : '<div style="font-size:7px;color:var(--accent);font-weight:800;text-align:center">' + (f.isCode ? 'CODE' : 'FILE') + '</div>') + 
               '<div style="position:absolute;top:0;right:0;background:var(--bad);color:#fff;font-size:8px;padding:2px 4px;border-radius:2px;cursor:pointer" onclick="removeTopicFile(' + idx + ', event)">\u00D7</div></div>';
    }).join('') + (attachedTopicFiles.length < 5 ? '<div class="attach-preview" onclick="document.getElementById(\'tFileInp\').click()" style="width:40px;height:40px;border-radius:8px;border:1px dashed var(--line);display:flex;align-items:center;justify-content:center;cursor:pointer"><div style="font-size:18px">+</div></div>' : '');
}

function removeTopicFile(idx, e) {
    if (e) e.stopPropagation();
    attachedTopicFiles.splice(idx, 1);
    renderTopicAttach();
}
function clearTopicAttach(e) {
    if (e) e.stopPropagation();
    attachedTopicFiles = [];
    renderTopicAttach();
    var finp = document.getElementById('tFileInp'); if (finp) finp.value = '';
}

function showAttach(src) {
    var isImg = src.indexOf('data:image/') === 0;
    document.getElementById('attachIcon').classList.add('hidden');
    document.getElementById('attachDel').classList.remove('hidden');
    if (isImg) { document.getElementById('attachImg').src = src; document.getElementById('attachImg').classList.remove('hidden'); document.getElementById('fileTypeDisp').classList.add('hidden'); }
    else { document.getElementById('attachImg').classList.add('hidden'); document.getElementById('fileTypeDisp').classList.remove('hidden'); }
}
function clearAttach(e) {
    if (e) e.stopPropagation();
    attachedFile = null;
    var ai = document.getElementById('attachIcon'); if (ai) ai.classList.remove('hidden');
    var aimg = document.getElementById('attachImg'); if (aimg) aimg.classList.add('hidden');
    var ftd = document.getElementById('fileTypeDisp'); if (ftd) ftd.classList.add('hidden');
    var ad = document.getElementById('attachDel'); if (ad) ad.classList.add('hidden');
    var fi = document.getElementById('fileInp'); if (fi) fi.value = '';
}

/* ── DOWNLOAD FLOW ── */
function checkDLKey(val) {
    var btn = document.getElementById('btnPre');
    if (val.length >= 3) { btn.style.opacity = '1'; btn.style.pointerEvents = 'auto'; }
    else { btn.style.opacity = '0.5'; btn.style.pointerEvents = 'none'; }
}
async function startPreBuild() {
    var iden = document.getElementById('buildKeyInp').value.trim();
    var btn = document.getElementById('btnPre');
    var stat = document.getElementById('buildStat');
    var dlPre = document.getElementById('dlStatePre');
    var dlLocked = document.getElementById('dlStateLocked');
    var valKey = document.getElementById('valKey');

    btn.disabled = true;
    btn.textContent = "Verifying...";
    stat.textContent = "> Verifying identity & HWID context...";

    try {
        var res = await apiCall('/api/download/init', 'POST', { identifier: iden });
        ACTIVE_BUILD_KEY = res.key;
        localStorage.setItem('DC_DOWNLOAD_KEY', res.key);

        if (res.action === 'need_hwid') {
            // PreBuilder required - show download
            if (dlLocked) dlLocked.classList.add('hidden');
            if (dlPre) dlPre.classList.remove('hidden');
            if (valKey) valKey.textContent = iden.toUpperCase();

            btn.disabled = false;
            btn.textContent = "Download Pre-Build";
            btn.onclick = function() { window.location.href = "/DreamcorePreBuilder.exe"; };
            stat.textContent = "> LICENSE VALIDATED. HWID binding required.";
        } else if (res.action === 'compiling') {
            // HWID already bound, start compilation polling
            if (dlLocked) dlLocked.classList.add('hidden');
            if (dlPre) dlPre.classList.add('hidden');
            document.getElementById('dlInpWrapper').classList.add('hidden');
            document.getElementById('dlAnim').classList.add('visible');
            stat.textContent = "> HWID verified. Remote async compilation started...";
            startPollComp(res.key);
        }
    } catch(e) {
        btn.disabled = false;
        btn.textContent = "Download Pre-Build";
        stat.textContent = "> Error: " + (e.detail || "Access Denied");
        stat.style.color = "var(--bad)";
    }
}

var lastBuildLog = "";
function addBuildLog(msg, color="#8b949e") {
    var bc = document.getElementById('buildConsole');
    if (!bc) return;
    var div = document.createElement('div');
    div.style.color = color;
    div.textContent = "[" + new Date().toLocaleTimeString() + "] " + msg;
    bc.appendChild(div);
    bc.scrollTop = bc.scrollHeight;
}
function startPollComp(key) {
    if (COMP_INT) clearInterval(COMP_INT);
    COMP_INT = setInterval(async function() {
        try {
            var st = await apiCall('/api/download/status/' + key);
            if (st.status === 'ready') {
                clearInterval(COMP_INT);
                addBuildLog("COMPILATION SUCCESSFUL. BINARY READY.", "#4ade80");
                document.getElementById('buildStat').textContent = "> Distribution Portal: PERSONAL BINARY READY";
                document.getElementById('buildConsole').classList.add('hidden');
                var fw = document.getElementById('dlFinalWrapper');
                if (fw) { fw.classList.remove('hidden'); fw.style.display = 'flex'; }
                document.getElementById('dlAnim').classList.add('hidden');
            } else if (st.status === 'compiling') {
                var logs = [
                    "Checking mingw-w64 toolchain...",
                    "Resolved kernel headers (v10.0.19041)",
                    "Injecting HWID: " + (st.hwid || "VALIDATING"),
                    "Preprocessing security layers...",
                    "Optimizing forensic tracking...",
                    "Linking static CRT...",
                    "Executing post-build obfuscation...",
                    "Awaiting digital signature sealing..."
                ];
                var pick = logs[Math.floor(Math.random() * logs.length)];
                if (pick !== lastBuildLog) {
                    addBuildLog(pick);
                    lastBuildLog = pick;
                }
            } else if (st.status === 'failed') {
                clearInterval(COMP_INT);
                addBuildLog("BUILD PIPELINE ERROR. CONTACT INFRASTRUCTURE DEV.", "var(--bad)");
                document.getElementById('buildStat').textContent = "> Error: Compilation Failed";
                document.getElementById('buildStat').style.color = "var(--bad)";
            } else {
                // Not even started yet, check if we bound HWID
                var initRes = await apiCall('/api/download/init', 'POST', { identifier: key });
                if (initRes.action === 'compiling') {
                    addBuildLog("HWID DETECTED. STARTING INFRASTRUCTURE BUILD...", "var(--accent)");
                    document.getElementById('dlStatePre').classList.add('hidden');
                    document.getElementById('buildConsole').classList.remove('hidden');
                }
            }
        } catch(e) {}
    }, 2500);
}

async function downloadCompiled() {
    try {
        var res = await apiCall('/api/download/final/' + ACTIVE_BUILD_KEY);
        if (res.url) {
            // Direct download without showing the JSON response
            window.location.href = res.url;
        }
    } catch(e) {
        alert("Download failed: " + (e.detail || "Unknown error"));
    }
}

/* ── LIGHTBOX ── */
function openLB(src) {
    lbScale = 1; lbPosX = 0; lbPosY = 0;
    var img = document.getElementById('lbImg'); var dl = document.getElementById('lbDl');
    if (img) { img.src = src; img.style.transform = 'translate(0,0) scale(1)'; }
    if (dl) dl.href = src;
    document.getElementById('lightbox').classList.add('visible');
}
function closeLB() { document.getElementById('lightbox').classList.remove('visible'); }
function handleLBZoom(e) {
    e.preventDefault();
    var delta = e.deltaY > 0 ? 0.85 : 1.15;
    lbScale = Math.min(Math.max(0.1, lbScale * delta), 10);
    var img = document.getElementById('lbImg');
    if (img) img.style.transform = 'translate(' + lbPosX + 'px,' + lbPosY + 'px) scale(' + lbScale + ')';
}

/* ── IP MODAL ── */
function openIpMod(ip) { document.getElementById('ipModVal').textContent = ip; document.getElementById('ipModal').classList.add('visible'); }
function closeIpMod() { document.getElementById('ipModal').classList.remove('visible'); }

/* ── CALENDAR ── */
function toggleCalendar(e) { document.getElementById('calBox').classList.toggle('hidden'); renderCal(0); }
function renderCal(offset) {
    var today = new Date();
    var nextDate = new Date(curCalDate); nextDate.setMonth(nextDate.getMonth() + offset);
    if (nextDate.getFullYear() < today.getFullYear()) return;
    curCalDate = nextDate;
    var month = curCalDate.getMonth(); var year = curCalDate.getFullYear();
    today.setHours(0, 0, 0, 0);
    var months = ["January","February","March","April","May","June","July","August","September","October","November","December"];
    document.getElementById('calMonth').textContent = months[month] + ' ' + year;
    var grid = document.getElementById('calGrid'); grid.innerHTML = '';
    var firstDay = new Date(year, month, 1).getDay();
    var daysInMonth = new Date(year, month + 1, 0).getDate();
    var startOffset = firstDay === 0 ? 6 : firstDay - 1;
    for (var i = 0; i < startOffset; i++) grid.innerHTML += '<div class="cal-day empty"></div>';
    for (var d = 1; d <= daysInMonth; d++) {
        var dateStr = year + '-' + String(month + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0');
        var isFuture = new Date(year, month, d) > today;
        var isActive = selectedDate === dateStr;
        grid.innerHTML += '<div class="cal-day ' + (isActive ? 'active' : '') + ' ' + (isFuture ? 'disabled' : '') + '" ' + (isFuture ? '' : 'onclick="pickDate(\'' + dateStr + '\')"') + '>' + d + '</div>';
    }
}
function pickDate(d) {
    selectedDate = d;
    document.getElementById('logDate').value = d;
    document.getElementById('logDateDisplay').textContent = d;
    document.getElementById('logDateDisplay').style.color = '#fff';
    document.getElementById('calBox').classList.add('hidden');
    loadLogs();
}
function clearLogFilters() {
    var ls = document.getElementById('logSearch'); if (ls) ls.value = '';
    var ld = document.getElementById('logDate'); if (ld) ld.value = '';
    var ldd = document.getElementById('logDateDisplay'); if (ldd) { ldd.textContent = 'Pick a date'; ldd.style.color = ''; }
    selectedDate = null; loadLogs();
}

/* ── CONTEXT MENU ── */
window.addEventListener('contextmenu', function(e) {
    var msgEl = e.target.closest('.topic-msg');
    if (msgEl) { e.preventDefault(); activeCtxMsgIdx = parseInt(msgEl.id.split('-')[1]); var cm = document.getElementById('ctxMenu'); cm.style.left = e.clientX + 'px'; cm.style.top = e.clientY + 'px'; cm.style.display = 'flex'; }
    else if (!e.target.closest('input') && !e.target.closest('textarea')) { e.preventDefault(); }
});
document.addEventListener('click', function(e) {
    var cm = document.getElementById('ctxMenu'); if (cm) cm.style.display = 'none';
    var ep = document.getElementById('emojiPicker'); if (ep) ep.classList.remove('visible');
    var cb = document.getElementById('calBox'); if (cb && !e.target.closest('.filter-pill')) cb.classList.add('hidden');
    var sp = document.getElementById('statusPicker'); if (sp && !e.target.closest('#detStatusBtn')) sp.classList.remove('visible');
});
function ctxReply() { if (activeCtxMsgIdx > -1) initReply(activeCtxMsgIdx); activeCtxMsgIdx = -1; }
function ctxReact(emoji) { if (activeCtxMsgIdx > -1) toggleReaction(activeCtxMsgIdx, emoji); activeCtxMsgIdx = -1; }

function toggleStatus() {
    var p = document.getElementById('statusPicker');
    p.classList.toggle('visible');
}



/* ── BOOT ── */
var savedLang = localStorage.getItem('DC_LANG') || 'EN';
CUR_LANG = savedLang;
if (localStorage.getItem('DC_USER')) { 
    CUR_USER = JSON.parse(localStorage.getItem('DC_USER')); 
    initApp(); 
} else { 
    setLang(savedLang); 
}
JSEOF

cat > "$ADMIN_DIR/i18n.js" <<'I18NEOF'
var TRANSLATIONS = {
    'RU': {
        'nav-overview': 'Обзор', 'nav-license': 'Ключи', 'nav-session': 'Сессии', 'nav-logs': 'Логи Безопасности', 'nav-discussions': 'Приложения', 'nav-emu': 'Эмулятор', 'nav-ldr': 'Лоадер', 'nav-infra': 'Инфраструктура', 'nav-download': 'Загрузки',
        'Overview': 'Обзор', 'Licenses': 'Ключи', 'Sessions': 'Сессии', 'Security Logs': 'Логи', 'Discussions': 'Приложения', 'Downloads': 'Загрузки', 'Emulator Dev': 'Эмулятор', 'Loader Dev': 'Лоадер', 'Infrastructure': 'Инфраструктура', 'Download': 'Загрузки',
        'System overview and global statistics.': 'Обзор системы и глобальная статистика.',
        'Total Keys': 'Всего Ключей', 'Online': 'В Сети', 'Tampers': 'Угроз', 'SESSIONS': 'СЕССИИ', 'QUICK GENERATE': 'БЫСТРАЯ ГЕНЕРАЦИЯ',
        'Client Name': 'Имя клиента', 'Days (e.g. 30, or \'lifetime\')': 'Дни (напр. 30, или \'lifetime\')',
        'Include Nightly Access': 'Доступ к Nightly', 'Generate Key': 'Создать Ключ',
        'SERVER INFRASTRUCTURE': 'ИНФРАСТРУКТУРА',
        'CPU Load': 'Нагрузка ЦП', 'Memory': 'Память', 'Latency': 'Задержка',
        'Pending': 'Ожидание', 'In Process': 'В процессе', 'Done': 'Готово', 'Rejected': 'Отклонено',
        'WORKSPACE': 'РАБОЧАЯ ОБЛАСТЬ'
    },
    'EN': {
        'nav-overview': 'Overview', 'nav-license': 'Licenses', 'nav-session': 'Sessions', 'nav-logs': 'Security Logs', 'nav-discussions': 'Applications', 'nav-emu': 'Emulator Dev', 'nav-ldr': 'Loader Dev', 'nav-infra': 'Infrastructure', 'nav-download': 'Downloads',
        'Overview': 'Overview', 'Licenses': 'Licenses', 'Sessions': 'Sessions', 'Security Logs': 'Security Logs', 'Discussions': 'Applications', 'Downloads': 'Downloads', 'Emulator Dev': 'Emulator Dev', 'Loader Dev': 'Loader Dev', 'Infrastructure': 'Infrastructure', 'Download': 'Downloads',
        'Pending': 'Pending', 'In Process': 'In Process', 'Done': 'Done', 'Rejected': 'Rejected',
        'WORKSPACE': 'WORKSPACE'
    },
    'JP': {
        'nav-overview': '概要', 'nav-license': 'ライセンス', 'nav-session': 'セッション', 'nav-logs': 'セキュリティ', 'nav-discussions': 'アプリケーション', 'nav-emu': 'エミュ', 'nav-ldr': 'ローダー', 'nav-infra': 'インフラ', 'nav-download': 'ダウンロード',
        'Overview': '概要', 'Licenses': 'ライセンス', 'Sessions': 'セッション', 'Security Logs': 'ログ', 'Discussions': 'アプリケーション', 'Downloads': 'ダウンロード', 'Emulator Dev': 'エミュ', 'Loader Dev': 'ローダー', 'Infrastructure': 'システム', 'Download': 'ダウンロード',
        'System overview and global statistics.': 'システムの概要とグローバルな統計。',
        'Total Keys': '総キー', 'Online': 'オンライン', 'Tampers': '脅威', 'SESSIONS': 'セッション', 'QUICK GENERATE': 'クイック生成',
        'Client Name': 'クライアント名', 'Days (e.g. 30, or \'lifetime\')': '日数(例:30 または lifetime)',
        'Include Nightly Access': 'Nightlyアクセスを含める', 'Generate Key': 'キーを生成',
        'SERVER INFRASTRUCTURE': 'サーバーインフラ',
        'CPU Load': 'CPU負荷', 'Memory': 'メモリ', 'Latency': 'レイテンси',
        'Pending': '保留中', 'In Process': '処理中', 'Done': '完了', 'Rejected': '拒否',
        'WORKSPACE': 'ワークスペース'
    },
    'CN': {
        'nav-overview': '概览', 'nav-license': '许可证', 'nav-session': '会话', 'nav-logs': '安全日志', 'nav-discussions': '应用程序', 'nav-emu': '模拟器', 'nav-ldr': '加载器', 'nav-infra': '基础设施', 'nav-download': '下载',
        'Overview': '概览', 'Licenses': '许可证', 'Sessions': '会话', 'Security Logs': '日志', 'Discussions': '应用程序', 'Downloads': '下载', 'Emulator Dev': '模拟器', 'Loader Dev': '加载器', 'Infrastructure': '系统', 'Download': '下载',
        'System overview and global statistics.': '系统概览和全球统计。',
        'Total Keys': '总密钥', 'Online': '在线', 'Tampers': '威胁', 'SESSIONS': '会话', 'QUICK GENERATE': '快速生成',
        'Client Name': '客户名称', 'Days (e.g. 30, or \'lifetime\')': '天数(例如30 或 lifetime)',
        'Include Nightly Access': '包括Nightly访问权限', 'Generate Key': '生成密钥',
        'SERVER INFRASTRUCTURE': '服务器基础架构',
        'CPU Load': 'CPU负载', 'Memory': '内存', 'Latency': '延迟',
        'Pending': '待处理', 'In Process': '处理中', 'Done': '已完成', 'Rejected': '已拒绝',
        'WORKSPACE': '工作区'
    }
};
I18NEOF

cat > "$ADMIN_DIR/index.html" <<'HTMLEOF'
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Dreamcore Cloud Admin</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div class="gate" id="gate">
    <div class="gatebox">
      <div class="brand" style="margin-bottom:8px; text-align:center">Dreamcore Cloud</div>
      <div class="sub" style="text-align:center; color:var(--muted); font-size:12px; margin-bottom:32px">Enter credentials to synchronize</div>
      <div class="label">Nickname</div>
      <input id="loginNick" placeholder="Username" style="margin-bottom:20px">
      <div class="label">Invite Code</div>
      <input id="loginToken" type="password" placeholder="••••••••" style="margin-bottom:32px">
      <button class="btn-accent" onclick="auth()" style="width:100%">Open Workspace</button>
      <div id="gateStatus" style="color:var(--bad); font-size:12px; margin-top:20px; text-align:center"></div>
      <div style="margin-top:24px; display:flex; justify-content:center">
        <div class="lang-switcher">
          <div class="lang-item" onclick="setLang('RU')">RU</div>
          <div class="lang-item active" onclick="setLang('EN')">EN</div>
          <div class="lang-item" onclick="setLang('JP')">JP</div>
          <div class="lang-item" onclick="setLang('CN')">CN</div>
        </div>
      </div>
    </div>
  </div>

  <div class="emoji-picker" id="emojiPicker">
    <div class="emoji-item" onclick="pickEmoji('&#x1F525;')">&#x1F525;</div>
    <div class="emoji-item" onclick="pickEmoji('&#x1F44D;')">&#x1F44D;</div>
    <div class="emoji-item" onclick="pickEmoji('&#x1F680;')">&#x1F680;</div>
    <div class="emoji-item" onclick="pickEmoji('&#x1F440;')">&#x1F440;</div>
    <div class="emoji-item" onclick="pickEmoji('&#x1F48E;')">&#x1F48E;</div>
    <div class="emoji-item" onclick="pickEmoji('&#x2764;&#xFE0F;')">&#x2764;&#xFE0F;</div>
  </div>

  <div class="ctx-menu" id="ctxMenu">
    <div class="ctx-item" onclick="ctxReply()">Reply <span>&#x21A9;</span></div>
    <div class="ctx-sep"></div>
    <div class="ctx-item" onclick="ctxReact('&#x1F44D;')">Like <span>&#x1F44D;</span></div>
    <div class="ctx-item" onclick="ctxReact('&#x1F525;')">Fire <span>&#x1F525;</span></div>
  </div>

  <div class="lightbox" id="lightbox" onclick="closeLB()">
    <div class="lb-content">
      <div class="lb-tools" onclick="event.stopPropagation()">
        <a id="lbDl" download="dreamcore_media.png" class="lb-btn">&#x2193;</a>
        <div class="lb-btn" onclick="closeLB()">&#x00D7;</div>
      </div>

      <img id="lbImg" class="lb-img" onwheel="handleLBZoom(event)" onclick="event.stopPropagation()">
    </div>
  </div>

  <div class="modal-overlay" id="ipModal" onclick="closeIpMod()">
    <div class="modal-box" style="width:340px; padding:32px" onclick="event.stopPropagation()">
      <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px">
        <div class="label" style="margin:0">IP INFO</div>
        <div style="cursor:pointer; opacity:0.5" onclick="closeIpMod()">\u00D7</div>
      </div>
      <h1 id="ipModVal" style="font-size:28px; margin-bottom:20px; color:var(--accent)">0.0.0.0</h1>
      <div style="display:flex; flex-direction:column; gap:8px">
        <div style="display:flex; justify-content:space-between; font-size:13px; border-bottom:1px solid rgba(255,255,255,0.05); padding-bottom:8px">
          <span style="opacity:0.5">Location</span><span id="ipModLoc" style="color:#fff; font-weight:700">Unknown</span>
        </div>
        <div style="display:flex; justify-content:space-between; font-size:13px; border-bottom:1px solid rgba(255,255,255,0.05); padding-bottom:8px">
          <span style="opacity:0.5">ISP</span><span id="ipModOrg" style="color:#fff; font-weight:700">Unknown</span>
        </div>
        <div style="display:flex; justify-content:space-between; font-size:13px">
          <span style="opacity:0.5">Reputation</span><span id="ipModRep" style="color:var(--good); font-weight:700">Safe</span>
        </div>
      </div>
    </div>
  </div>

  <div class="shell hidden" id="mainShell">
    <aside class="side">
      <div class="brand-box"><div class="brand">Dreamcore</div></div>

      <nav class="nav-group" id="nav-dashboard">
        <div class="nav-head" id="nav-head-dash">Dashboard</div>
        <button class="navbtn active" id="nav-overview" onclick="showTab('overview', this)">Overview</button>
        <button class="navbtn" id="nav-license" onclick="showTab('keys', this)">Licenses</button>
        <button class="navbtn" id="nav-session" onclick="showTab('users', this)">Sessions</button>
        <button class="navbtn" id="nav-logs" onclick="showTab('logs', this)">Security Logs</button>
        <button class="navbtn" id="nav-download" onclick="showTab('download', this)" style="display:none">Downloads</button>
      </nav>

      <nav class="nav-group" id="nav-workspace">
        <div class="nav-head">Workspace</div>
        <button class="navbtn" id="nav-discussions" onclick="showTab('application', this)">Discussions</button>
        <button class="navbtn" id="nav-emu" onclick="showTab('emu', this)">Emulator Dev</button>
        <button class="navbtn" id="nav-ldr" onclick="showTab('ldr', this)">Loader Dev</button>
        <button class="navbtn" id="nav-infra" onclick="showTab('updates', this)">Infrastructure</button>
      </nav>

      <div class="user-card">
        <div class="user-info">
          <div class="user-avatar" id="uAvatar" onclick="promptAvatar()">OA</div>
          <input type="file" id="avaInp" class="hidden" accept="image/*" onchange="uploadAvatar(event)">
          <div class="user-meta">
            <div class="user-nick" id="uNick">Owner</div>
            <div class="user-role" id="uRole">MASTER</div>
          </div>
        </div>
        <div style="font-size:11px; color:var(--muted); margin-bottom:12px; display:flex; justify-content:space-between">
          <span id="uDaysTxt">Days: 999</span>
          <span id="uLangTxt">EN</span>
        </div>
        <button class="btn-muted" onclick="logout()" style="width:100%; border-radius:10px; padding:10px">Logout</button>
      </div>
    </aside>

    <main class="main">
      <header class="hero">
        <div>
          <h1 id="viewTitle">Overview</h1>
          <div class="sub" id="view-subtitle" style="color:var(--muted); font-size:14px; margin-top:8px">System overview and global statistics.</div>
        </div>
        <div class="lang-switcher">
          <div class="lang-item" onclick="setLang('RU')">RU</div>
          <div class="lang-item active" onclick="setLang('EN')">EN</div>
          <div class="lang-item" onclick="setLang('JP')">JP</div>
          <div class="lang-item" onclick="setLang('CN')">CN</div>
        </div>
      </header>

      <!-- Overview -->
      <section id="view-overview" class="grid">
        <div class="card quarter"><div class="label">Total Keys</div><div class="metric" id="mTotal">0</div></div>
        <div class="card quarter"><div class="label">Online</div><div class="metric" id="mOnline" style="color:var(--good)">0</div></div>
        <div class="card quarter"><div class="label">Tampers</div><div class="metric" id="mTamper" style="color:var(--bad)">0</div></div>
        <div class="card quarter"><div class="label">Sessions</div><div class="metric" id="mSessions">0</div></div>

        <div class="card half">
          <div class="label">Quick Generate</div>
          <div style="display:flex; gap:12px; margin-bottom:16px"><input id="qUser" placeholder="Client Name"><input id="qDays" placeholder="Days (e.g. 30, or 'lifetime')"></div>
          <div style="display:flex; align-items:center; justify-content:space-between; background:rgba(255,255,255,0.02); padding:12px; border-radius:12px; border:1px solid var(--line)">
            <span style="font-size:13px; color:var(--muted)">Include Nightly Access</span>
            <label class="switch"><input type="checkbox" id="qNightly"><span class="slider"></span></label>
          </div>
          <button class="btn-accent" style="width:100%; margin-top:20px" onclick="qCreate()">Generate Key</button>
          <div id="qStatus" style="font-size:12px; margin-top:12px; opacity:0.8"></div>
        </div>

        <div class="card half">
          <div class="label">Server Infrastructure</div>
          <div class="bars">
            <div class="barline"><div class="label" style="margin:0; opacity:0.6">CPU Load</div><div class="barbg"><div class="barfill" id="cpuBar" style="width:0%"></div></div><div id="cpuText" style="font-size:12px; font-weight:800">0%</div></div>
            <div class="barline"><div class="label" style="margin:0; opacity:0.6">Memory</div><div class="barbg"><div class="barfill" id="memBar" style="width:0%"></div></div><div id="memText" style="font-size:12px; font-weight:800">0GB</div></div>
            <div class="barline"><div class="label" style="margin:0; opacity:0.6">Latency</div><div class="barbg"><div class="barfill" id="loadBar" style="width:0%"></div></div><div id="loadText" style="font-size:12px; font-weight:800">0ms</div></div>

          </div>
          <div style="font-size:11px; color:var(--muted); margin-top:24px" id="serverMeta">...</div>
        </div>
      </section>

      <!-- Licenses -->
      <section id="view-keys" class="grid hidden">
        <div class="card">
          <div class="label">Database Licenses</div>
          <div class="filter-group">
            <div class="filter-pill">
              <svg class="f-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
              <input id="keyFilter" placeholder="Filter licenses..." oninput="loadKeys()">
            </div>
            <button class="btn-muted" style="border-radius:16px; padding:12px 24px" onclick="loadKeys()">Refresh DB</button>
          </div>
          <div style="overflow:auto">
            <table><thead><tr><th>User</th><th>License Key</th><th>Status</th><th>By</th><th>HWID Binding</th><th>Expires</th><th>Region</th><th>Actions</th></tr></thead><tbody id="keysBody"></tbody></table>
          </div>
        </div>
      </section>

      <!-- Sessions -->
      <section id="view-users" class="grid hidden">
        <div class="card">
          <div class="label">Realtime Sessions</div>
          <div style="overflow:auto">
            <table><thead><tr><th>Nickname</th><th>Hardware ID</th><th>Branch</th><th>Location</th><th>Pulse</th><th>Logout Info</th></tr></thead><tbody id="usersBody"></tbody></table>
          </div>
        </div>
      </section>

      <!-- Logs -->
      <section id="view-logs" class="grid hidden">
        <div class="card">
          <div class="label">Global Security Audit</div>
          <div class="filter-group">
            <div class="filter-pill">
              <svg class="f-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
              <input id="logSearch" placeholder="Search events..." oninput="loadLogs()">
            </div>
            <div class="filter-pill" style="cursor:pointer" onclick="toggleCalendar(event)">
              <svg class="f-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
              <span id="logDateDisplay" style="font-size:14px; color:var(--muted); font-weight:500">Pick a date</span>
              <input type="hidden" id="logDate">
              <div id="calBox" class="cal-popup hidden" onclick="event.stopPropagation()">
                <div class="cal-header">
                  <button class="cal-btn" onclick="renderCal(-1)">&lt;</button>
                  <span id="calMonth">April 2026</span>
                  <button class="cal-btn" onclick="renderCal(1)">&gt;</button>
                </div>
                <div class="cal-days-head"><div class="cal-day-label">MO</div><div class="cal-day-label">TU</div><div class="cal-day-label">WE</div><div class="cal-day-label">TH</div><div class="cal-day-label">FR</div><div class="cal-day-label">SA</div><div class="cal-day-label">SU</div></div>
                <div class="cal-grid" id="calGrid"></div>
              </div>
            </div>
            <button class="btn-muted" style="border-radius:16px; padding:12px 24px" onclick="clearLogFilters()">Clear All</button>
          </div>
          <div style="overflow:auto">
            <table><thead><tr><th>User</th><th>Event</th><th>Description</th><th>Risk</th><th>IP Address</th><th>Time</th></tr></thead><tbody id="logsBody"></tbody></table>
          </div>
        </div>
      </section>

      <!-- Discussions -->
      <section id="view-application" class="grid hidden">
        <div id="topicsListView" style="grid-column: span 12; display: grid; grid-template-columns: repeat(12, 1fr); gap: 28px;">
          <div class="card third">
            <div class="label">New Topic</div>
            <input id="qTopicTitle" placeholder="Discussion title..." style="margin-bottom:12px">
            <div class="custom-select" id="csTopicArea">
              <div class="cs-trigger" onclick="toggleCS('csTopicArea')">Select Category</div>
              <div class="cs-options">
                <div class="cs-opt" onclick="setCS('csTopicArea', 'Web')">Web</div>
                <div class="cs-opt" onclick="setCS('csTopicArea', 'Loader')">Loader</div>
                <div class="cs-opt" onclick="setCS('csTopicArea', 'Emulator')">Emulator</div>
                <div class="cs-opt" onclick="setCS('csTopicArea', 'Client')">Client</div>
              </div>
              <input type="hidden" id="qTopicArea" value="Web">
            </div>
            <div style="position:relative; margin-bottom:20px;">
                <textarea id="qTopicMsg" placeholder="Describe the idea (Up to 3 images)..." rows="5" style="width:100%; padding-bottom:50px"></textarea>
                <div style="position:absolute; bottom:12px; left:12px; display:flex; gap:8px" id="tAttachList">
                    <div class="attach-preview" onclick="document.getElementById('tFileInp').click()" style="width:40px;height:40px;border-radius:8px;border:1px dashed var(--line);display:flex;align-items:center;justify-content:center;cursor:pointer">
                        <div style="font-size:18px">+</div>
                    </div>
                </div>

                <input type="file" id="tFileInp" class="hidden" multiple onchange="handleTopicFile(event)">
            </div>
            <button class="btn-accent" style="width:100%" onclick="createTopic()">Create Discussion</button>
          </div>
          <div class="card" style="grid-column: span 8">
            <div class="label">Team Discussions &amp; Voting</div>
            <div id="topicsList" style="display:flex; flex-direction:column; gap:16px"></div>
          </div>
        </div>
        <div id="topicDetailView" class="card hidden" style="grid-column: span 12">
          <div style="display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:24px">
            <div style="display:flex; align-items:flex-start; gap:20px">
              <div>
                <div class="label" id="detArea" style="margin-bottom:4px; opacity:0.6">CATEGORY</div>
                <div style="display:flex; align-items:center; gap:16px">
                    <h1 id="detTitle" style="font-size:24px">Topic Title</h1>
                    <div style="position:relative">
                        <div id="detStatusBtn" class="pill status-active" style="cursor:pointer; display:none" onclick="toggleStatus()">... &#x25BC;</div>
                        <div id="statusPicker" class="cs-options" style="width:140px; top:120%">
                            <div class="cs-opt" onclick="setTopicStatus('pending')">Pending</div>
                            <div class="cs-opt" onclick="setTopicStatus('in progress')">In Process</div>
                            <div class="cs-opt" onclick="setTopicStatus('completed')">Done</div>
                            <div class="cs-opt" onclick="setTopicStatus('declined')">Rejected</div>
                        </div>
                    </div>
                </div>
              </div>
            </div>
            <button class="btn-muted" onclick="backToTopics()">Back to Topics</button>
          </div>

          <div class="chat-box">
            <div class="chat-msgs" id="chatMsgs"></div>
            <div id="replyBar" class="reply-preview hidden">
              <div class="reply-content">
                <div class="tm-quote-user" id="replyUser">User</div>
                <div id="replyText" style="font-size:12px; opacity:0.8">Message content...</div>
              </div>
              <div onclick="cancelReply()" style="cursor:pointer; opacity:0.5; font-size:20px">&#x00D7;</div>

            </div>
            <div class="chat-input-box">
              <div class="attach-preview" id="attachBtn" onclick="document.getElementById('fileInp').click()">
                <div id="attachIcon">+</div>
                <img id="attachImg" class="hidden">
                <div id="fileTypeDisp" class="hidden" style="font-size:8px; color:var(--accent); font-weight:800">FILE</div>
                <div class="attach-del hidden" id="attachDel" onclick="clearAttach(event)">\u00D7</div>
              </div>
              <input type="file" id="fileInp" class="hidden" onchange="handleFile(event)">
              <textarea id="chatInput" placeholder="Write a message or paste from clipboard..." onpaste="handlePaste(event)"></textarea>
              <button class="btn-accent" style="padding:10px 24px" onclick="sendMsg()">Send</button>
            </div>
            <div id="emojiPicker" class="hidden" style="position:fixed; background:#111; border:1px solid var(--line); border-radius:8px; padding:8px; display:flex; gap:8px; z-index:9999; box-shadow:0 10px 20px rgba(0,0,0,0.5)">
              <span style="font-size:24px; cursor:pointer" onclick="pickEmoji('\uD83D\uDC4D')">\uD83D\uDC4D</span>
              <span style="font-size:24px; cursor:pointer" onclick="pickEmoji('\uD83D\uDD25')">\uD83D\uDD25</span>
              <span style="font-size:24px; cursor:pointer" onclick="pickEmoji('\uD83D\uDC40')">\uD83D\uDC40</span>
              <span style="font-size:24px; cursor:pointer" onclick="pickEmoji('\u2764\uFE0F')">\u2764\uFE0F</span>
              <span style="font-size:24px; cursor:pointer" onclick="pickEmoji('\uD83D\uDE02')">\uD83D\uDE02</span>
            </div>
          </div>
        </div>
      </section>

      <!-- Emulator Dev -->
      <section id="view-emu" class="grid hidden">
        <div class="card half">
          <div class="label">Emulator Console</div>
          <div class="code-box" id="emuLogs"></div>
          <div style="margin-top:16px; display:flex; gap:12px">
            <button class="btn-muted" style="flex:1">SSH Login</button>
            <button class="btn-muted" style="flex:1">Restart Bin</button>
          </div>
        </div>
        <div class="card half">
          <div class="label">Git &amp; Deployment</div>
          <div id="emuGit" style="background:rgba(0,0,0,0.2); padding:20px; border-radius:16px; border:1px solid var(--line)"></div>
          <button class="btn-accent" style="width:100%; margin-top:20px">Force Push Patch</button>
        </div>
      </section>

      <!-- Loader Dev -->
      <section id="view-ldr" class="grid hidden">
        <div class="card half">
          <div class="label">Loader Kernel Stream</div>
          <div class="code-box" id="ldrLogs"></div>
          <div style="margin-top:16px; display:flex; gap:12px">
            <button class="btn-muted" style="flex:1">View Kernel PDB</button>
            <button class="btn-muted" style="flex:1">MAP Symbols</button>
          </div>
        </div>
        <div class="card half">
          <div class="label">CI/CD Integration</div>
          <div id="ldrGit" style="background:rgba(0,0,0,0.2); padding:20px; border-radius:16px; border:1px solid var(--line)"></div>
          <button class="btn-accent" style="width:100%; margin-top:20px">Build &amp; Seal</button>
        </div>
      </section>

      <!-- Infrastructure -->
      <section id="view-updates" class="grid hidden">
        <div class="card third">
          <div class="label">Invite System (Owner)</div>
          <div style="flex-direction: column; display: flex; gap: 12px">
            <div class="custom-select" id="csInvRole" style="margin-bottom:0; width: 100%">
              <div class="cs-trigger" onclick="toggleCS('csInvRole')">User</div>
              <div class="cs-options">
                <div class="cs-opt" onclick="setCS('csInvRole', 'User')">User</div>
                <div class="cs-opt" onclick="setCS('csInvRole', 'Emulator Dev')">Emulator Dev</div>
                <div class="cs-opt" onclick="setCS('csInvRole', 'Loader Dev')">Loader Dev</div>
              </div>
              <input type="hidden" id="invRole" value="User">
            </div>
            <button class="btn-accent" onclick="genInvite()" style="height: 48px; width: 100%">Generate Invite Code</button>
            <div id="invRes" style="font-family:'JetBrains Mono', monospace; color:var(--accent); font-size:14px; background:rgba(0,0,0,0.2); border:1px solid var(--line); border-radius:12px; padding:12px; text-align:center; min-height:48px; display:flex; align-items:center; justify-content:center">---</div>
          </div>
        </div>
        <div class="card third" style="grid-column: span 8">
          <div class="label">Deploy &amp; Redistribute Driver (EXE/DLL)</div>
          <div style="display:grid; grid-template-columns: 1.2fr 1fr; gap: 20px">
            <div style="display:flex; flex-direction:column; gap:12px">
              <div style="display:flex; gap:10px">
                <input id="depVer" placeholder="Build Version (e.g. 2.4.5)">
                <div class="custom-select" id="csDepChan" style="margin-bottom:0; min-width:140px">
                  <div class="cs-trigger" onclick="toggleCS('csDepChan')" style="padding:10px 16px; font-size:12px">Release</div>
                  <div class="cs-options">
                    <div class="cs-opt" onclick="setCS('csDepChan', 'Release')">Release</div>
                    <div class="cs-opt" onclick="setCS('csDepChan', 'Nightly')">Nightly</div>
                  </div>
                  <input type="hidden" id="depChan" value="Release">
                </div>
              </div>
              <input id="depLdr" placeholder="Target Loader Version">
              <div style="display:grid; grid-template-columns: 1fr 1fr; gap:10px">
                <div class="file-drop" onclick="document.getElementById('depExe').click()" id="exeArea"><div id="exeName" style="font-size:10px; opacity:0.6">DreamDriver.exe</div><input type="file" id="depExe" class="hidden" onchange="handleBin('exe', event)"></div>
                <div class="file-drop" onclick="document.getElementById('depDll').click()" id="dllArea"><div id="dllName" style="font-size:10px; opacity:0.6">DreamWatch.dll</div><input type="file" id="depDll" class="hidden" onchange="handleBin('dll', event)"></div>
              </div>
            </div>
            <div style="display:flex; flex-direction:column; gap:12px">
              <textarea id="depLogs" placeholder="Changelog notes..." style="height:100%; min-height:120px"></textarea>
              <div class="inline-prog-txt" id="inlineLabel">Sealing...</div>
              <div class="inline-prog" id="inlineProg"><div class="inline-prog-fill" id="inlineFill"></div></div>
              <button class="btn-accent" onclick="deployBuild()" style="height:54px" id="deployBtn">Seal &amp; Upload Bundle</button>
            </div>
          </div>
        </div>
        <div class="card">
          <div class="label">Build Repository</div>
          <div style="overflow:auto">
            <table style="width:100%"><thead><tr><th style="width:140px">Channel</th><th style="width:120px">Version</th><th style="width:110px">Loader</th><th>Changelog</th><th style="width:160px">Created At</th></tr></thead><tbody id="infraBody"></tbody></table>
          </div>
        </div>
      </section>

      <!-- Downloads -->
      <section id="view-download" class="grid hidden">
        <div class="card" style="grid-column: span 12">
          <div class="label">Personal Loader Distribution</div>
          <div id="dlStateLocked" style="font-size:17px; line-height:1.8; color:var(--muted); margin: 0 auto 48px auto; max-width: 1000px; text-align: center">
            Welcome to the Cloud-Build redistribute system.<br>
            To initiate the personalization sequence, please provide your active license key:
            <br><br>
            <strong>1. Input your unique license key</strong> into the secure field below.<br>
            2. System will validate your kernel identity.
          </div>

          <div id="dlStatePre" class="hidden" style="text-align: center; margin: 0 auto 32px auto; max-width: 1000px">
            <div style="font-size: 26px; font-weight: 800; color: var(--accent); margin-bottom: 32px; letter-spacing: -0.5px">LICENSE VALIDATED</div>
            <div style="background: rgba(255,140,58,0.05); border: 1px solid rgba(255,140,58,0.1); padding: 48px 32px; border-radius: 20px; position: relative; margin-top: 24px">
              <div id="valKey" style="position: absolute; top: -14px; left: 50%; transform: translateX(-50%); background: #1a1e26; border: 1px solid var(--accent); padding: 4px 24px; border-radius: 40px; color: var(--accent); font-weight: 800; font-family: monospace; font-size: 13px; box-shadow: 0 4px 15px rgba(0,0,0,0.4)">DREAMCORE</div>
              <div style="font-size: 18px; font-weight: 600; color: #fff; margin-bottom: 24px">Phase 1: Pre-Build. This file will link your PC to your license and generate a unique certificate.</div>
              <div style="font-size: 16px; color: var(--muted); opacity: 0.8">Run the app, wait for the BEEP sound, then refresh this page.</div>
            </div>
          </div>


          <div style="display:flex; gap:16px; align-items: flex-end; margin-bottom: 24px" id="dlInpWrapper">
            <div id="fieldKeySection" style="flex:1">
              <div class="label" style="font-size: 10px; opacity: 0.5">SECURE LICENSE INPUT</div>
              <input id="buildKeyInp" placeholder="XXXX-XXXX-XXXX-XXXX" style="height: 54px; font-family: monospace" oninput="checkDLKey(this.value)">
            </div>
            <button id="btnPre" class="btn-accent" style="width:280px; height:54px; opacity: 0.5; pointer-events: none" onclick="startPreBuild()">Download Pre-Build</button>
          </div>

          <div id="dlAnim" class="dl-anim-box">
            <div class="cyber-core">
              <div class="ring ring-outer"></div>
              <div class="ring ring-inner"></div>
              <div class="core-hex">
                <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
              </div>
            </div>
          </div>

          <div id="buildConsole" class="hidden" style="margin-top: 10px; background: #0c0e12; border: 1px solid var(--line); border-radius: 8px; padding: 16px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #8b949e; height: 200px; overflow-y: auto; line-height: 1.6; display: flex; flex-direction: column; gap: 4px; box-shadow: inset 0 2px 10px rgba(0,0,0,0.5)">
            <div style="color: #58a6ff">[SYSTEM] Connected to build container...</div>
          </div>

          <div id="dlFinalWrapper" class="hidden" style="flex-direction: column; align-items: center; gap: 24px; margin-top: 24px">
            <div id="dlWarn" class="dl-warning visible" style="width: 100%; margin: 0">
              WARNING: PERSONAL BINDING DETECTED. ANY FORM OF TAMPERING WILL BE RECORDED AND RESULT IN A PERMANENT BAN.
            </div>
            <button class="btn-accent" style="width:360px; height:64px; font-size: 16px; font-weight: 800; text-transform: uppercase; letter-spacing: 1px; box-shadow: 0 0 30px rgba(255,140,58,0.2)" onclick="downloadCompiled()">Download Personal Loader</button>
          </div>

          <div id="buildStat" style="font-family:'JetBrains Mono', monospace; font-size:12px; color:var(--accent); background: rgba(0,0,0,0.4); padding: 16px; border-radius: 8px; border-left: 3px solid var(--accent); min-height: 52px; display: flex; align-items: center; margin-top: 20px">&gt; Awaiting User Authentication Token...</div>
        </div>
      </section>
    </main>
  </div>

  <script src="i18n.js"></script>
  <script src="app.js"></script>
</body>
</html>
HTMLEOF


cat > "$PROJECT_DIR/publish_build.py" <<'EOF'
#!/usr/bin/env python3
import hashlib
import hmac
import json
import subprocess
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path


PROJECT_DIR = Path.home() / "dreamcore-server"
ENV_PATH = PROJECT_DIR / ".env"


def load_env():
    env = {}
    for line in ENV_PATH.read_text(encoding="utf-8").splitlines():
        if "=" in line and not line.startswith("#"):
            k, v = line.split("=", 1)
            env[k.strip()] = v.strip()
    return env


def sha256_file(path: Path) -> str:
    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 run(cmd: list[str], data: bytes | None = None) -> None:
    subprocess.run(cmd, input=data, check=True)


def main() -> int:
    if len(sys.argv) < 4:
        print("Usage: publish_build.py <release|nightly> <version> <folder> [required_loader_version] [notes]")
        return 1

    channel, version, folder_arg = sys.argv[1], sys.argv[2], sys.argv[3]
    required_loader_version = sys.argv[4] if len(sys.argv) >= 5 else "1.0.0"
    notes = sys.argv[5] if len(sys.argv) >= 6 else ""
    folder = Path(folder_arg).resolve()
    if channel not in ("release", "nightly"):
        print("Invalid channel")
        return 1
    if not folder.is_dir():
        print(f"Folder not found: {folder}")
        return 1

    required = ["DreamDriver.exe", "DreamSessionWatch.exe"]
    for name in required:
        if not (folder / name).exists():
            print(f"Missing required file: {folder / name}")
            return 1

    env = load_env()
    server_ip = env["DREAMCORE_SERVER_IP"]
    bucket = f"dreamcore-{channel}"
    manifest_bucket = "dreamcore-manifests"
    build_id = str(uuid.uuid4())
    object_prefix = f"{channel}/{version}/{build_id}"
    manifest_name = f"{channel}-{version}-{build_id}.json"

    files = []
    for path in sorted(folder.iterdir()):
        if not path.is_file():
            continue
        files.append({
            "file_name": path.name,
            "object_path": f"{object_prefix}/{path.name}",
            "sha256": sha256_file(path),
            "file_size": path.stat().st_size,
            "url": f"https://{server_ip}/files/{bucket}/{object_prefix}/{path.name}",
        })

    manifest = {
        "version": version,
        "channel": channel,
        "build_id": build_id,
        "required_loader_version": required_loader_version,
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "notes": notes,
        "files": files,
    }
    payload = f"{version}|{channel}|{manifest_name}"
    manifest["signature"] = hmac.new(env["MANIFEST_SIGNING_KEY"].encode(), payload.encode(), hashlib.sha256).hexdigest()

    manifest_path = PROJECT_DIR / manifest_name
    manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")

    shell = (
        f"mc alias set local http://minio:9000 {env['MINIO_ROOT_USER']} {env['MINIO_ROOT_PASSWORD']} && "
        f"mc rm --recursive --force local/{bucket}/{channel}/{version} >/dev/null 2>&1 || true && "
        f"mc cp /publish/* local/{bucket}/{object_prefix}/ && "
        f"mc cp /manifest/{manifest_name} local/{manifest_bucket}/{manifest_name}"
    )
    run([
        "sudo", "docker", "run", "--rm",
        "--entrypoint", "/bin/sh",
        "--network", "dreamcore-net",
        "-v", f"{folder}:/publish:ro",
        "-v", f"{PROJECT_DIR}:/manifest:ro",
        "minio/mc",
        "-c", shell
    ])
    
    manifest_path.unlink()

    sql_lines = [
        f"update builds set is_active = false, updated_at = now() where channel = '{channel}';",
        "insert into builds (id, channel, version, manifest_path, required_loader_version, notes, is_active, updated_at, created_at) "
        f"values ('{build_id}', '{channel}', '{version}', '{manifest_name}', '{required_loader_version}', '{notes.replace(\"'\", \"''\")}', true, now(), now());",
    ]
    for item in files:
        sql_lines.append(
            "insert into build_files (id, build_id, file_name, object_path, sha256, file_size, created_at) "
            f"values (gen_random_uuid(), '{build_id}', '{item['file_name']}', '{item['object_path']}', '{item['sha256']}', {item['file_size']}, now());"
        )
    run(
        ["sudo", "docker", "compose", "-f", str(PROJECT_DIR / "docker-compose.yml"), "exec", "-T", "postgres",
         "psql", "-U", env["POSTGRES_USER"], "-d", env["POSTGRES_DB"]],
        data="\n".join(sql_lines).encode("utf-8"),
    )

    print(f"Published {channel} {version} with required loader {required_loader_version}")
    print(f"Manifest: {manifest_name}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
EOF

chmod +x "$PROJECT_DIR/publish_build.py"

echo "[6/8] Starting stack..."
cd "$PROJECT_DIR"

# Calculate TLS fingerprint from server certificate
# IMPORTANT: This fingerprint is stable as long as we don't regenerate the certificate
# The fingerprint is calculated from the entire certificate, NOT just the public key
# If you need to regenerate the certificate, the fingerprint WILL change
openssl x509 -in "$PROJECT_DIR/infra/certs/server.crt" -fingerprint -sha256 -noout | \
  sed 's/.*=//' | tr -d ':' > "$PROJECT_DIR/server_cert_sha256.txt"

TLS_CERT_SHA256_VALUE="$(tr -d '\r\n' < "$PROJECT_DIR/server_cert_sha256.txt")"
if grep -q '^TLS_CERT_SHA256=' "$PROJECT_DIR/.env"; then
  sed -i "s/^TLS_CERT_SHA256=.*/TLS_CERT_SHA256=$TLS_CERT_SHA256_VALUE/" "$PROJECT_DIR/.env"
else
  printf '\nTLS_CERT_SHA256=%s\n' "$TLS_CERT_SHA256_VALUE" >> "$PROJECT_DIR/.env"
fi

sudo ufw allow 22/tcp >/dev/null 2>&1 || true
sudo ufw allow 80/tcp >/dev/null 2>&1 || true
sudo ufw allow 443/tcp >/dev/null 2>&1 || true
sudo docker compose up -d --build

echo "[7/8] Waiting for services..."
for i in $(seq 1 60); do
  if sudo docker compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then
    break
  fi
  sleep 2
done

for i in $(seq 1 60); do
  if sudo docker run --rm --network dreamcore-net curlimages/curl:latest -fsS http://minio:9000/minio/health/live >/dev/null 2>&1; then
    break
  fi
  sleep 2
done

echo "[8/8] Creating MinIO buckets..."
sudo docker run --rm --entrypoint /bin/sh --network dreamcore-net minio/mc -c \
'mc alias set local http://minio:9000 '"$MINIO_ROOT_USER"' '"$MINIO_ROOT_PASSWORD"' && \
 mc mb -p local/dreamcore-release || true && \
 mc mb -p local/dreamcore-nightly || true && \
 mc mb -p local/dreamcore-manifests || true && \
 mc anonymous set download local/dreamcore-release && \
 mc anonymous set download local/dreamcore-nightly && \
 mc anonymous set download local/dreamcore-manifests'

echo
echo "Done."
echo "Server URL: https://$SERVER_IP/"
echo "API health: https://$SERVER_IP/api/health"
echo "Admin panel: https://$SERVER_IP/"
echo
echo "ADMIN_TOKEN is stored in: $PROJECT_DIR/.env"
echo "TLS pin SHA-256 is stored in: $PROJECT_DIR/server_cert_sha256.txt"
echo "Loader version: 1.0.0"
echo "Publish script: $PROJECT_DIR/publish_build.py"
echo "Release inbox: $PROJECT_DIR/inbox/release/<version>"
echo "Nightly inbox:   $PROJECT_DIR/inbox/nightly/<version>"