from flask import Flask, render_template, redirect, url_for, request, flash, session, jsonify, send_file, abort
from werkzeug.utils import secure_filename
from werkzeug.security import generate_password_hash, check_password_hash
from pathlib import Path
from functools import wraps
from datetime import datetime, timedelta, timezone
import json
import os
import re
import secrets
import threading
import time
import requests

BASE_DIR = Path(__file__).resolve().parent


# =============================================
# === CONFIGURATION / SECRETS ===
# =============================================
# Secrets come from BASE_DIR/.env, never from source.
#
# Why read the file ourselves instead of using Apache's SetEnv: mod_wsgi does
# NOT copy SetEnv values into os.environ — they land in the per-request WSGI
# environ dict, which is not visible at import time when app.config is built.
# Reading the file here means one identical setup works under mod_wsgi,
# Gunicorn, and `python app.py`.

def _load_env_file(path):
    """Load KEY=value lines into os.environ. Real environment variables win."""
    if not path.exists():
        return
    for raw in path.read_text(encoding='utf-8').splitlines():
        line = raw.strip()
        if not line or line.startswith('#') or '=' not in line:
            continue
        key, _, value = line.partition('=')
        os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))


# Checked in order, first match wins. The parent directory is listed because a
# layout like /srv/2rare/html keeps the app in the served tree while .env sits
# one level up at /srv/2rare/.env, where Apache can never reach it even if a
# config rule is missed later.
_ENV_FILE_USED = None
for _candidate in (BASE_DIR / '.env', BASE_DIR.parent / '.env'):
    if _candidate.is_file():
        try:
            _load_env_file(_candidate)
            _ENV_FILE_USED = str(_candidate)
            break
        except PermissionError:
            # Exists but the app user cannot read it — almost always a file
            # created by root and left mode 600. Say so instead of reporting a
            # missing variable, which sends you looking in the wrong place.
            raise RuntimeError(
                f'{_candidate} exists but is not readable by this user. '
                'Fix with: sudo chown tworare:tworare ' + str(_candidate)
            )


def _required_env(name):
    value = os.environ.get(name)
    if not value:
        found = _ENV_FILE_USED or 'none found'
        raise RuntimeError(
            f'{name} is not set.\n'
            f'  .env searched:  {BASE_DIR / ".env"}\n'
            f'                  {BASE_DIR.parent / ".env"}\n'
            f'  .env loaded:    {found}\n'
            f'Add {name}=... to one of those paths, readable by the user the '
            'app runs as. The app refuses to start on a default secret.'
        )
    return value


def _env_flag(name, default=False):
    raw = os.environ.get(name)
    if raw is None:
        return default
    return raw.strip().lower() in ('1', 'true', 'yes', 'on')


app = Flask(__name__)

app.config['SECRET_KEY'] = _required_env('SECRET_KEY')
app.config['UPLOAD_FOLDER'] = 'static/uploads'
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
app.config['ADMIN_USERNAME'] = os.environ.get('ADMIN_USERNAME', '2rareadmin')

# Either a hash (preferred) or a plaintext password. If both are absent the app
# will not start, rather than falling back to a password that is in the repo.
ADMIN_PASSWORD_HASH = os.environ.get('ADMIN_PASSWORD_HASH', '')
ADMIN_PASSWORD_PLAIN = os.environ.get('ADMIN_PASSWORD', '')
if not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD_PLAIN:
    raise RuntimeError(
        'Set ADMIN_PASSWORD_HASH (preferred) or ADMIN_PASSWORD in .env.'
    )

# Cookie hardening. SESSION_COOKIE_SECURE must be off while you are testing over
# plain HTTP or the browser will refuse to send the cookie and login will appear
# to silently fail. Turn it on the moment HTTPS is live.
app.config['SESSION_COOKIE_SECURE'] = _env_flag('SESSION_COOKIE_SECURE', True)
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=14)

# Only needed if Apache proxies to Gunicorn rather than running mod_wsgi. It
# makes Flask trust X-Forwarded-* so url_for builds https:// links. Leave off
# under mod_wsgi: trusting those headers when nothing sets them lets a client
# spoof its own scheme and host.
if _env_flag('USE_PROXY_FIX', False):
    from werkzeug.middleware.proxy_fix import ProxyFix
    app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)


# DATA_DIR can be moved outside the web root — e.g. DATA_DIR=/srv/2rare/data
# when the app itself lives in /srv/2rare/html. Defaults to the old location so
# nothing changes if the variable is unset.
DATA_DIR = Path(os.environ.get('DATA_DIR') or (BASE_DIR / 'data'))
DATA_DIR.mkdir(parents=True, exist_ok=True)

DATA_FILE = DATA_DIR / 'pages.json'
TEAM_DATA_FILE = DATA_DIR / 'team.json'
SITE_SETTINGS_FILE = DATA_DIR / 'site_settings.json'
USERS_FILE = DATA_DIR / 'users.json'

# Uploads and cached posters must stay under static/ — Apache serves them.
UPLOAD_DIR = BASE_DIR / app.config['UPLOAD_FOLDER']
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)


# =============================================
# === SECURITY HELPERS ===
# =============================================
# SVG is deliberately absent. It is an XML document that can carry <script>,
# and served from our own origin it would run with full access to admin
# sessions. Same reasoning excludes .html and .htm.
ALLOWED_IMAGE_EXT = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif'}


def save_upload(file_storage):
    """Validate and store an uploaded image.

    Returns (filename, error). filename is '' when no file was supplied.
    """
    if not file_storage or not file_storage.filename:
        return '', None

    name = secure_filename(file_storage.filename)
    if not name:
        return '', 'That filename is not usable.'

    ext = Path(name).suffix.lower()
    if ext not in ALLOWED_IMAGE_EXT:
        return '', f'{ext or "That file type"} is not allowed. Use PNG, JPG, GIF, WEBP or AVIF.'

    # Prefix with a short random token so two people uploading logo.png do not
    # overwrite one another, and so a guessed filename is not predictable.
    final = f'{secrets.token_hex(4)}_{name}'
    file_storage.save(str(UPLOAD_DIR / final))
    return final, None


# Login throttle. In-process and therefore only correct while the app runs as a
# single process — which is exactly how it is deployed (see DEPLOY_APACHE.md).
_login_attempts = {}
_login_lock = threading.Lock()
LOGIN_MAX_ATTEMPTS = 8
LOGIN_WINDOW_SECONDS = 300


def _login_blocked(ip):
    now = time.time()
    with _login_lock:
        hits = [t for t in _login_attempts.get(ip, []) if now - t < LOGIN_WINDOW_SECONDS]
        _login_attempts[ip] = hits
        return len(hits) >= LOGIN_MAX_ATTEMPTS


def _record_login_failure(ip):
    now = time.time()
    with _login_lock:
        hits = [t for t in _login_attempts.get(ip, []) if now - t < LOGIN_WINDOW_SECONDS]
        hits.append(now)
        _login_attempts[ip] = hits
        # Stop the dict growing without bound on a busy or probed server.
        if len(_login_attempts) > 2000:
            for key in [k for k, v in _login_attempts.items() if not v]:
                _login_attempts.pop(key, None)


def _clear_login_failures(ip):
    with _login_lock:
        _login_attempts.pop(ip, None)


def _check_admin_password(password):
    """compare_digest rather than ==, so the comparison takes the same time
    whatever the input. A plain == leaks how much of the password was right."""
    if ADMIN_PASSWORD_HASH:
        return check_password_hash(ADMIN_PASSWORD_HASH, password)
    return secrets.compare_digest(ADMIN_PASSWORD_PLAIN, password)


def _verify_user_password(user, password):
    """Check a users.json entry, upgrading legacy plaintext rows on the way.

    Existing accounts were stored as typed. Rather than force a reset, the first
    successful login rewrites that row as a hash. Nobody is locked out and the
    file drains of plaintext as people sign in.
    """
    stored = user.get('password') or ''
    if not stored:
        return False

    if stored.startswith(('pbkdf2:', 'scrypt:', 'argon2')):
        return check_password_hash(stored, password)

    if secrets.compare_digest(stored, password):
        user['password'] = generate_password_hash(password)
        save_users()
        return True
    return False


@app.after_request
def _security_headers(response):
    response.headers.setdefault('X-Content-Type-Options', 'nosniff')
    response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
    response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
    response.headers.setdefault('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
    return response


# =============================================
# === EXISTING DATA LOADERS ===
# =============================================
def load_pages():
    if DATA_FILE.exists():
        with DATA_FILE.open('r', encoding='utf-8') as handle:
            return json.load(handle)
    return [
        {'slug': 'home', 'title': 'Home', 'content': 'Welcome to 2rare.', 'image': ''},
        {'slug': 'about', 'title': 'About', 'content': 'We are a rising esports organization.', 'image': ''},
    ]


pages = load_pages()


def save_pages():
    with DATA_FILE.open('w', encoding='utf-8') as handle:
        json.dump(pages, handle, indent=2)


def load_team_settings():
    if TEAM_DATA_FILE.exists():
        with TEAM_DATA_FILE.open('r', encoding='utf-8') as handle:
            return json.load(handle)
    return {
        'title': '2rare Team',
        'intro': 'Follow the squad across competition, culture, and community.',
        'stats': [
            {'label': 'Current Rank', 'value': '#12'},
            {'label': 'Win Rate', 'value': '68%'},
            {'label': 'Tournaments', 'value': '24'},
            {'label': 'Followers', 'value': '48K'},
        ],
        'socials': [
            {'name': 'Discord', 'url': 'https://discord.com', 'label': 'Join the community'},
            {'name': 'TikTok', 'url': 'https://www.tiktok.com', 'label': 'Watch highlights'},
            {'name': 'YouTube', 'url': 'https://www.youtube.com', 'label': 'Match recaps'},
        ],
        'discord_widget': '',
        'posts': [],
        'roster': [],
    }


team_settings = load_team_settings()


def load_site_settings():
    if SITE_SETTINGS_FILE.exists():
        with SITE_SETTINGS_FILE.open('r', encoding='utf-8') as handle:
            return json.load(handle)
    return {'logo': '', 'background_image': '', 'audio_url': ''}


site_settings = load_site_settings()


@app.context_processor
def inject_site_settings():
    return dict(site_settings=site_settings)


def save_team_settings():
    with TEAM_DATA_FILE.open('w', encoding='utf-8') as handle:
        json.dump(team_settings, handle, indent=2)


def save_site_settings():
    with SITE_SETTINGS_FILE.open('w', encoding='utf-8') as handle:
        json.dump(site_settings, handle, indent=2)


def load_users():
    if USERS_FILE.exists():
        with USERS_FILE.open('r', encoding='utf-8') as handle:
            return json.load(handle)
    return []


users = load_users()


def save_users():
    with USERS_FILE.open('w', encoding='utf-8') as handle:
        json.dump(users, handle, indent=2)


# =============================================
# === TIKTOK STORAGE ===
# =============================================
# Every like and every view is a read-modify-write against one JSON file. Two
# requests landing together silently lose one of the updates, and a crash
# mid-write truncates the whole library. The lock fixes the first; writing to a
# temp file and renaming fixes the second.
#
# NOTE: threading.Lock is not reentrant, and only spans ONE process. The Apache
# config must run a single process with threads — see DEPLOY_APACHE.md.

TIKTOK_EMBEDS_FILE = DATA_DIR / 'tiktok_embeds.json'
THUMB_DIR = BASE_DIR / 'static' / 'thumbs'
THUMB_DIR.mkdir(parents=True, exist_ok=True)

_embeds_lock = threading.Lock()

# A browser-ish UA plus a tiktok.com Referer is what gets the CDN to serve the
# poster image. Requests without them get refused.
_CDN_HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
                  '(KHTML, like Gecko) Chrome/122.0 Safari/537.36',
    'Referer': 'https://www.tiktok.com/',
    'Accept': 'image/avif,image/webp,image/*,*/*;q=0.8',
}


def load_tiktok_embeds():
    if TIKTOK_EMBEDS_FILE.exists():
        with TIKTOK_EMBEDS_FILE.open('r', encoding='utf-8') as f:
            return json.load(f)
    return []


def save_tiktok_embeds(embeds):
    """Write to a sibling temp file, then rename. Readers never see a partial file."""
    tmp = TIKTOK_EMBEDS_FILE.with_name(TIKTOK_EMBEDS_FILE.name + '.tmp')
    with tmp.open('w', encoding='utf-8') as f:
        json.dump(embeds, f, indent=2, default=str)
    tmp.replace(TIKTOK_EMBEDS_FILE)


def get_embed(embed_id):
    for e in load_tiktok_embeds():
        if e.get('id') == embed_id:
            return e
    return None


def update_embed(embed_id, updates):
    with _embeds_lock:
        embeds = load_tiktok_embeds()
        for e in embeds:
            if e.get('id') == embed_id:
                e.update(updates)
                break
        save_tiktok_embeds(embeds)


# =============================================
# === TIKTOK METADATA HELPERS ===
# =============================================
def fetch_tiktok_meta(url):
    """Ask TikTok's oEmbed endpoint for markup, poster and caption.

    Returns {'html', 'thumbnail_url', 'title', 'author'} or None.
    Must run server-side: the endpoint does not reliably send CORS headers, so
    the same call from a browser is rejected before the JSON is ever readable.
    """
    try:
        if not re.search(r'/video/(\d+)', url or ''):
            return None
        resp = requests.get(
            'https://www.tiktok.com/oembed',
            params={'url': url},
            timeout=10,
            headers={'User-Agent': 'Mozilla/5.0'},
        )
        if resp.status_code != 200:
            return None
        data = resp.json()
        return {
            'html': data.get('html', ''),
            'thumbnail_url': data.get('thumbnail_url', ''),
            'title': (data.get('title') or '').strip(),
            'author': data.get('author_name', ''),
        }
    except Exception:
        return None


def get_tiktok_embed_from_url(url):
    """Kept for backwards compatibility with any other caller."""
    meta = fetch_tiktok_meta(url)
    return meta['html'] if meta else None


def extract_tiktok_url(embed_code):
    """Recover the canonical video URL from pasted embed markup, so manually
    pasted embeds can still get a poster."""
    if not embed_code:
        return None
    match = re.search(r'cite=["\']([^"\']+)["\']', embed_code)
    if match:
        return match.group(1).split('?')[0]
    match = re.search(r'href=["\'](https://www\.tiktok\.com/@[^"\']+/video/\d+)', embed_code)
    if match:
        return match.group(1).split('?')[0]
    match = re.search(r'data-video-id=["\'](\d{6,})["\']', embed_code)
    if match:
        return f'https://www.tiktok.com/@i/video/{match.group(1)}'
    return None


def _thumb_file(embed_id):
    return THUMB_DIR / f'{embed_id}.jpg'


def _resolve_thumb_url(embed):
    """Stored poster URL if we have one, otherwise ask oEmbed and remember it."""
    url = (embed.get('thumbnail_url') or '').strip()
    if url:
        return url

    source = (embed.get('video_url') or '').strip()
    if not source:
        source = extract_tiktok_url(embed.get('embed_code', '')) or ''
    if not source:
        return ''

    meta = fetch_tiktok_meta(source)
    if not meta or not meta.get('thumbnail_url'):
        return ''

    updates = {'thumbnail_url': meta['thumbnail_url'], 'video_url': source}
    if meta.get('title') and not (embed.get('description') or '').strip():
        updates['description'] = meta['title']
    update_embed(embed['id'], updates)
    return meta['thumbnail_url']


def _cached_thumb_response(path):
    resp = send_file(str(path), mimetype='image/jpeg')
    resp.headers['Cache-Control'] = 'public, max-age=2592000'
    return resp


# =============================================
# === PUBLIC ROUTES ===
# =============================================
@app.route('/')
def home():
    return render_template('index.html')


@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        ip = request.remote_addr or 'unknown'

        if _login_blocked(ip):
            flash('Too many failed attempts. Wait a few minutes and try again.', 'error')
            return render_template('login.html'), 429

        username = request.form.get('username', '').strip()
        password = request.form.get('password', '')

        if username == app.config['ADMIN_USERNAME'] and _check_admin_password(password):
            session.clear()                 # drop any pre-existing session id
            session.permanent = True
            session['is_admin'] = True
            session['username'] = username
            _clear_login_failures(ip)
            flash('You are now logged in.', 'success')
            return redirect(url_for('admin'))

        matching_user = next((u for u in users if u.get('username') == username), None)
        if matching_user and _verify_user_password(matching_user, password):
            session.clear()
            session.permanent = True
            session['is_admin'] = False
            session['username'] = username
            _clear_login_failures(ip)
            flash('You are now logged in.', 'success')
            return redirect(url_for('admin'))

        _record_login_failure(ip)
        flash('Invalid credentials.', 'error')

    return render_template('login.html')


@app.route('/api/songs')
def get_songs():
    music_dir = os.path.join(app.static_folder, 'music')
    songs = []
    if os.path.isdir(music_dir):
        for filename in os.listdir(music_dir):
            if filename.lower().endswith(('.mp3', '.wav', '.ogg')):
                title = os.path.splitext(filename)[0]
                url = f"/static/music/{filename}"
                songs.append({'title': title, 'artist': '', 'url': url, 'cover': None})
    return jsonify(songs)


@app.route('/logout')
def logout():
    session.clear()
    flash('You have been logged out.', 'success')
    return redirect(url_for('home'))


@app.route('/page/<slug>')
def page(slug):
    page_data = next((p for p in pages if p['slug'] == slug), None)
    if not page_data:
        return render_template('404.html'), 404
    return render_template('page.html', page=page_data)


# The site nav links to /about, which had no route and 404'd. Served by its own
# template; requires templates/about.html to exist.
@app.route('/about')
def about():
    return render_template('about.html')


# --- Team page ---
def get_tiktok_follower_count(username):
    try:
        response = requests.get(
            f'https://www.tiktok.com/node/share/user/@{username}',
            timeout=8,
            headers={'User-Agent': 'Mozilla/5.0'}
        )
        if response.ok:
            data = response.json()
            if isinstance(data, dict):
                user = data.get('userInfo', {}).get('user') or data.get('userInfo', {}).get('userInfo', {})
                if isinstance(user, dict):
                    follower_count = user.get('followerCount') or user.get('followers') or user.get('follower_count')
                    if follower_count is not None:
                        return str(follower_count)
    except Exception:
        pass
    return None


def get_tiktok_recent_posts(username, limit=3):
    try:
        response = requests.get(
            f'https://www.tiktok.com/@{username}',
            timeout=8,
            headers={'User-Agent': 'Mozilla/5.0'}
        )
        if response.ok:
            html = response.text
            matches = re.findall(r'"videoId":"([^"]+)"', html)
            if matches:
                posts = []
                for video_id in matches[:limit]:
                    posts.append({
                        'title': f'Latest TikTok post',
                        'url': f'https://www.tiktok.com/@{username}/video/{video_id}',
                        'platform': 'TikTok'
                    })
                return posts
    except Exception:
        pass
    return []


@app.route('/team')
def team():
    live_followers = None
    tiktok_username = team_settings.get('tiktok_username', '').strip().lstrip('@')
    if tiktok_username:
        live_followers = get_tiktok_follower_count(tiktok_username)
    stats = list(team_settings['stats'])
    if live_followers:
        stats = [{'label': 'TikTok Followers', 'value': live_followers}] + stats
    posts = list(team_settings.get('posts', []))
    if tiktok_username:
        live_posts = get_tiktok_recent_posts(tiktok_username, limit=3)
        if live_posts:
            posts = live_posts
    return render_template(
        'team.html',
        stats=stats,
        socials=team_settings['socials'],
        title=team_settings['title'],
        intro=team_settings['intro'],
        discord_widget=team_settings.get('discord_widget', ''),
        posts=posts,
        roster=team_settings.get('roster', [])
    )


@app.route('/roster')
def roster():
    roster_members = team_settings.get('roster', [])
    grouped = {}
    for member in roster_members:
        rank = member.get('rank', 'Other')
        grouped.setdefault(rank, []).append(member)
    rank_order = ['Owners', 'Co-Owners', 'Managers', 'Staff',
                  'Sniper Roster', 'Apex Roster', 'Content Creators', 'Editors/Artists']
    sorted_ranks = [r for r in rank_order if r in grouped] + [r for r in grouped if r not in rank_order]
    return render_template('roster.html', grouped_roster=grouped, sorted_ranks=sorted_ranks)


# =============================================
# === ADMIN ===
# =============================================
def admin_required(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        if not session.get('is_admin'):
            flash('Please log in to access the admin area.', 'error')
            return redirect(url_for('login'))
        return func(*args, **kwargs)
    return wrapper


@app.route('/admin')
@admin_required
def admin():
    roster_list = team_settings.get('roster', [])
    rows = len(roster_list) + 1
    if rows < 2:
        rows = 2
    return render_template('admin.html',
        pages=pages,
        site_settings=site_settings,
        title=team_settings.get('title', '2rare Team'),
        intro=team_settings.get('intro', ''),
        stats=team_settings.get('stats', []),
        socials=team_settings.get('socials', []),
        discord_widget=team_settings.get('discord_widget', ''),
        tiktok_username=team_settings.get('tiktok_username', ''),
        roster=roster_list,
        rows=rows,
        posts=team_settings.get('posts', []),
        users=users
    )


@app.route('/admin/pages')
@admin_required
def admin_pages():
    return render_template('admin_pages.html')


@app.route('/admin/branding')
@admin_required
def admin_branding():
    return render_template('admin_branding.html')


@app.route('/admin/team-overview')
@admin_required
def admin_team_overview():
    return render_template('admin_team_overview.html')


@app.route('/admin/team-stats')
@admin_required
def admin_team_stats():
    return render_template('admin_team_stats.html')


@app.route('/admin/social-links')
@admin_required
def admin_social_links():
    return render_template('admin_social_links.html')


@app.route('/admin/embeds')
@admin_required
def admin_embeds():
    return render_template('admin_embeds.html')


@app.route('/admin/users')
@admin_required
def admin_users():
    return render_template('admin_users.html', users=users)


@app.route('/admin/create-user', methods=['POST'])
@admin_required
def create_user():
    global users
    username = request.form.get('username', '').strip()
    password = request.form.get('password', '')
    if not username or not password:
        flash('Username and password are required.', 'error')
        return redirect(url_for('admin_users'))
    if len(password) < 8:
        flash('Passwords must be at least 8 characters.', 'error')
        return redirect(url_for('admin_users'))
    if any(user.get('username') == username for user in users):
        flash('That username already exists.', 'error')
        return redirect(url_for('admin_users'))
    users.append({'username': username, 'password': generate_password_hash(password)})
    save_users()
    flash('User created successfully.', 'success')
    return redirect(url_for('admin_users'))


@app.route('/admin/delete-user/<username>', methods=['POST'])
@admin_required
def delete_user(username):
    global users
    users[:] = [user for user in users if user.get('username') != username]
    save_users()
    flash('User deleted.', 'success')
    return redirect(url_for('admin_users'))


@app.route('/admin/roster')
@admin_required
def admin_roster():
    rows = request.args.get('rows', default=5, type=int)
    if rows is None or rows < 1:
        rows = 5
    return render_template('admin_roster.html', rows=rows)


@app.route('/admin/posts')
@admin_required
def admin_posts():
    return render_template('admin_posts.html')


@app.route('/admin/add-page', methods=['POST'])
@admin_required
def add_page():
    title = request.form.get('title', '').strip()
    slug = request.form.get('slug', '').strip().lower().replace(' ', '-')
    content = request.form.get('content', '').strip()

    if not title or not slug or not content:
        flash('Title, slug, and content are required.', 'error')
        return redirect(url_for('admin'))
    if any(existing['slug'] == slug for existing in pages):
        flash('A page with that slug already exists.', 'error')
        return redirect(url_for('admin'))

    filename, error = save_upload(request.files.get('image'))
    if error:
        flash(error, 'error')
        return redirect(url_for('admin'))

    pages.append({'slug': slug, 'title': title, 'content': content, 'image': filename})
    save_pages()
    flash('Page added successfully.', 'success')
    return redirect(url_for('admin'))


@app.route('/admin/delete-page/<slug>', methods=['POST'])
@admin_required
def delete_page(slug):
    global pages
    pages = [p for p in pages if p['slug'] != slug]
    save_pages()
    flash('Page deleted.', 'success')
    return redirect(url_for('admin'))


@app.route('/admin/update-team', methods=['POST'])
@admin_required
def update_team():
    global team_settings
    team_settings['title'] = request.form.get('team_title', '').strip() or '2rare Team'
    team_settings['intro'] = request.form.get('team_intro', '').strip() or 'Follow the squad across competition, culture, and community.'
    team_settings['stats'] = []
    for index in range(1, 5):
        if request.form.get(f'remove_stat_{index}') == '1':
            continue
        label = request.form.get(f'stat_label_{index}', '').strip()
        value = request.form.get(f'stat_value_{index}', '').strip()
        opponent = request.form.get(f'stat_opponent_{index}', '').strip()
        score = request.form.get(f'stat_score_{index}', '').strip()
        result = request.form.get(f'stat_result_{index}', '').strip()
        date = request.form.get(f'stat_date_{index}', '').strip()
        details = request.form.get(f'stat_details_{index}', '').strip()
        if label or value or opponent or score or result or date or details:
            stat_entry = {'label': label or f'Stat {index}', 'value': value or '-'}
            if opponent or score or result or date or details:
                stat_entry.update({
                    'opponent': opponent,
                    'score': score,
                    'result': result,
                    'date': date,
                    'details': details,
                    'is_match': True,
                })
            team_settings['stats'].append(stat_entry)
    team_settings['socials'] = []
    for index in range(1, 4):
        if request.form.get(f'remove_social_{index}') == '1':
            continue
        name = request.form.get(f'social_name_{index}', '').strip()
        url = request.form.get(f'social_url_{index}', '').strip()
        label = request.form.get(f'social_label_{index}', '').strip()
        if name or url or label:
            team_settings['socials'].append({'name': name or f'Social {index}', 'url': url or '#', 'label': label or 'Connect'})
    team_settings['discord_widget'] = request.form.get('discord_widget', '').strip()
    team_settings['tiktok_username'] = request.form.get('tiktok_username', '').strip()
    posts = []
    for index in range(1, 4):
        if request.form.get(f'remove_post_{index}') == '1':
            continue
        title = request.form.get(f'post_title_{index}', '').strip()
        url = request.form.get(f'post_url_{index}', '').strip()
        platform = request.form.get(f'post_platform_{index}', '').strip()
        if title or url or platform:
            posts.append({'title': title or f'Post {index}', 'url': url or '#', 'platform': platform or 'Social'})
    team_settings['posts'] = posts
    roster_list = []
    for index in range(1, 21):
        if request.form.get(f'remove_roster_{index}') == '1':
            continue
        name = request.form.get(f'roster_name_{index}', '').strip()
        rank = request.form.get(f'roster_rank_{index}', '').strip()
        role = request.form.get(f'roster_role_{index}', '').strip()
        link = request.form.get(f'roster_link_{index}', '').strip()
        if name or rank or role or link:
            roster_list.append({
                'name': name or 'Unnamed',
                'rank': rank or 'Other',
                'role': role,
                'link': link
            })
    team_settings['roster'] = roster_list
    save_team_settings()
    flash('Team settings updated successfully.', 'success')
    return redirect(url_for('admin'))


@app.route('/admin/update-site-settings', methods=['POST'])
@admin_required
def update_site_settings():
    global site_settings

    logo_name, error = save_upload(request.files.get('site_logo'))
    if error:
        flash(error, 'error')
        return redirect(url_for('admin'))
    if logo_name:
        site_settings['logo'] = logo_name
    elif request.form.get('remove_logo') == '1':
        site_settings['logo'] = ''

    bg_name, error = save_upload(request.files.get('site_background'))
    if error:
        flash(error, 'error')
        return redirect(url_for('admin'))
    if bg_name:
        site_settings['background_image'] = bg_name
    elif request.form.get('remove_background') == '1':
        site_settings['background_image'] = ''

    audio_url = request.form.get('site_audio_url', '').strip()
    if audio_url:
        site_settings['audio_url'] = audio_url
    elif request.form.get('remove_audio') == '1':
        site_settings['audio_url'] = ''

    save_site_settings()
    flash('Site branding updated successfully.', 'success')
    return redirect(url_for('admin'))


# =============================================
# === TIKTOK ROUTES ===
# =============================================
def _parse_created(value):
    """datetime.utcnow() is deprecated from Python 3.12 and returns a naive
    value, which raises TypeError the moment a stored timestamp has an offset."""
    if not value:
        return None
    try:
        parsed = datetime.fromisoformat(value)
    except (TypeError, ValueError):
        return None
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return parsed


@app.route('/tiktok/content')
def tiktok_content():
    embeds = load_tiktok_embeds()
    sort_by = request.args.get('sort', 'newest')
    if sort_by not in ('newest', 'oldest', 'likes', 'views'):
        sort_by = 'newest'

    now = datetime.now(timezone.utc)

    grouped = {}
    for e in embeds:
        grouped.setdefault(e.get('profile_name') or 'Unnamed', []).append(e)

    for items in grouped.values():
        for e in items:
            created = _parse_created(e.get('created_at'))
            e['is_new'] = bool(created and (now - created).total_seconds() < 86400)

        if sort_by == 'newest':
            items.sort(key=lambda x: x.get('created_at') or '', reverse=True)
        elif sort_by == 'oldest':
            items.sort(key=lambda x: x.get('created_at') or '')
        elif sort_by == 'likes':
            items.sort(key=lambda x: int(x.get('likes') or 0), reverse=True)
        elif sort_by == 'views':
            items.sort(key=lambda x: int(x.get('views') or 0), reverse=True)

        items[:] = ([i for i in items if i.get('featured')]
                    + [i for i in items if not i.get('featured')])

    # Drop empty members so the page never renders a ghost shelf or a filter
    # chip that matches nothing.
    grouped = {name: items for name, items in grouped.items() if items}

    return render_template('content.html', grouped=grouped, sort_by=sort_by, now=now)


@app.route('/tiktok/manage')
@admin_required
def tiktok_manage():
    embeds = load_tiktok_embeds()
    return render_template('manage.html', embeds=embeds)


@app.route('/tiktok/add', methods=['POST'])
@admin_required
def tiktok_add_embed():
    if 'username' not in session:
        flash('You must be logged in to add content.', 'error')
        return redirect(url_for('login'))

    author = session['username']
    title = request.form.get('title', '').strip()
    embed_code = request.form.get('embed_code', '').strip()
    url = request.form.get('url', '').strip()
    description = request.form.get('description', '').strip()

    if not title and not embed_code and not url:
        flash('Please provide a title, embed code, or video URL.', 'error')
        return redirect(url_for('tiktok_manage'))

    thumbnail_url = ''

    # Path A: a URL was pasted — one oEmbed call gives us markup AND poster.
    if url and not embed_code:
        meta = fetch_tiktok_meta(url)
        if not meta or not meta.get('html'):
            flash('Could not fetch embed from URL. Please paste embed code manually.', 'error')
            return redirect(url_for('tiktok_manage'))
        embed_code = meta['html']
        thumbnail_url = meta.get('thumbnail_url', '')
        if not title and meta.get('title'):
            title = meta['title'][:120]
        if not description and meta.get('title'):
            description = meta['title']

    # Path B: raw embed code — dig the URL out and look the poster up anyway.
    elif embed_code:
        found = extract_tiktok_url(embed_code)
        if found:
            url = url or found
            meta = fetch_tiktok_meta(found)
            if meta:
                thumbnail_url = meta.get('thumbnail_url', '')
                if not description and meta.get('title'):
                    description = meta['title']

    if not embed_code:
        flash('Embed code is required.', 'error')
        return redirect(url_for('tiktok_manage'))

    with _embeds_lock:
        embeds = load_tiktok_embeds()
        new_id = max([e.get('id', 0) for e in embeds], default=0) + 1
        embeds.append({
            'id': new_id,
            'profile_name': author,
            'title': title or 'TikTok Video',
            'embed_code': embed_code,
            'video_url': url,
            'thumbnail_url': thumbnail_url,
            'featured': False,
            'likes': 0,
            'views': 0,
            'created_at': datetime.now(timezone.utc).isoformat(),
            'description': description,
        })
        save_tiktok_embeds(embeds)

    flash('Embed added successfully.', 'success')
    return redirect(url_for('tiktok_manage'))


# POST only. A GET delete link gets followed by crawlers and link prefetchers,
# which would silently wipe content. The current manage.html posts a form.
@app.route('/tiktok/remove/<int:embed_id>', methods=['POST'])
@admin_required
def tiktok_remove_embed(embed_id):
    with _embeds_lock:
        embeds = [e for e in load_tiktok_embeds() if e.get('id') != embed_id]
        save_tiktok_embeds(embeds)

    cached = _thumb_file(embed_id)
    if cached.exists():
        try:
            cached.unlink()
        except OSError:
            pass

    flash('Embed removed.', 'info')
    return redirect(url_for('tiktok_manage'))


# The content page draws a filled or empty heart, so a second click sends the
# same POST expecting an un-like. Incrementing unconditionally made the heart
# empty while the number climbed. This toggles, tracks per-session so one
# visitor cannot spam the counter, and reports state back.
@app.route('/tiktok/like/<int:embed_id>', methods=['POST'])
def tiktok_like(embed_id):
    with _embeds_lock:
        embeds = load_tiktok_embeds()
        embed = next((e for e in embeds if e.get('id') == embed_id), None)
        if embed is None:
            return jsonify({'error': 'Not found'}), 404

        liked_ids = set(session.get('liked_embeds', []))
        if embed_id in liked_ids:
            liked_ids.discard(embed_id)
            embed['likes'] = max(0, int(embed.get('likes') or 0) - 1)
            liked = False
        else:
            liked_ids.add(embed_id)
            embed['likes'] = int(embed.get('likes') or 0) + 1
            liked = True

        session['liked_embeds'] = sorted(liked_ids)
        session.modified = True
        save_tiktok_embeds(embeds)

    return jsonify({'likes': embed['likes'], 'liked': liked})


@app.route('/tiktok/view/<int:embed_id>', methods=['POST'])
def tiktok_view(embed_id):
    with _embeds_lock:
        embeds = load_tiktok_embeds()
        embed = next((e for e in embeds if e.get('id') == embed_id), None)
        if embed is None:
            return jsonify({'error': 'Not found'}), 404
        embed['views'] = int(embed.get('views') or 0) + 1
        save_tiktok_embeds(embeds)
    return jsonify({'views': embed['views']})


@app.route('/tiktok/toggle-featured/<int:embed_id>', methods=['POST'])
@admin_required
def tiktok_toggle_featured(embed_id):
    embed = get_embed(embed_id)
    if embed:
        new_val = not embed.get('featured', False)
        update_embed(embed_id, {'featured': new_val})
        return jsonify({'featured': new_val})
    return jsonify({'error': 'Not found'}), 404


# =============================================
# === THUMBNAIL PROXY ===
# =============================================
# Fetching posters from the browser fails twice over: TikTok's oEmbed endpoint
# does not reliably send CORS headers, and the CDN image it points at is signed,
# expiring, and refuses a foreign Referer. Python has neither restriction.
@app.route('/tiktok/thumb/<int:embed_id>')
def tiktok_thumb(embed_id):
    cached = _thumb_file(embed_id)
    if cached.exists() and cached.stat().st_size > 0:
        return _cached_thumb_response(cached)

    embed = get_embed(embed_id)
    if not embed:
        abort(404)

    url = _resolve_thumb_url(embed)
    if not url:
        abort(404)

    try:
        resp = requests.get(url, timeout=12, headers=_CDN_HEADERS)
    except requests.RequestException:
        abort(404)

    if resp.status_code != 200 or not resp.content:
        abort(404)
    if not resp.headers.get('content-type', '').startswith('image/'):
        abort(404)

    # Write via a temp file so a half-downloaded image is never served.
    tmp = cached.with_suffix('.jpg.part')
    tmp.write_bytes(resp.content)
    tmp.replace(cached)

    return _cached_thumb_response(cached)


@app.route('/tiktok/warm-thumbs')
@admin_required
def tiktok_warm_thumbs():
    done, skipped, failed = 0, 0, 0

    for embed in load_tiktok_embeds():
        embed_id = embed.get('id')
        if embed_id is None:
            continue

        target = _thumb_file(embed_id)
        if target.exists() and target.stat().st_size > 0:
            skipped += 1
            continue

        url = _resolve_thumb_url(embed)
        if not url:
            failed += 1
            continue

        try:
            resp = requests.get(url, timeout=12, headers=_CDN_HEADERS)
            if resp.status_code == 200 and resp.content:
                target.write_bytes(resp.content)
                done += 1
            else:
                failed += 1
        except requests.RequestException:
            failed += 1

    return jsonify({'cached': done, 'already_had': skipped, 'failed': failed})


# =============================================
# === TEAM EVENT & TOURNAMENT CALENDAR ===
# =============================================
# TIME HANDLING: dates and times are stored exactly as typed ('2026-08-14',
# '19:30') with a free-text timezone label per event. Nothing is converted to
# UTC — silent timezone maths is a reliable way to put an event on the wrong
# day.
#
# ACCESS: /events is public. Add, delete and result-setting are admin-only,
# enforced here as well as hidden in the template — a hidden button is not
# access control.

EVENTS_FILE = DATA_DIR / 'events.json'

_events_lock = threading.Lock()

EVENT_TYPES = ('scrim', 'match', 'tournament', 'stream', 'meeting')
EVENT_RESULTS = ('', 'win', 'loss', 'draw')


def load_events():
    if EVENTS_FILE.exists():
        try:
            with EVENTS_FILE.open('r', encoding='utf-8') as f:
                data = json.load(f)
            return data if isinstance(data, list) else []
        except (json.JSONDecodeError, OSError):
            return []
    return []


def save_events(events_list):
    tmp = EVENTS_FILE.with_name(EVENTS_FILE.name + '.tmp')
    with tmp.open('w', encoding='utf-8') as f:
        json.dump(events_list, f, indent=2, default=str)
    tmp.replace(EVENTS_FILE)


def _clean_date(value):
    """Accept only YYYY-MM-DD. Anything else becomes '' and is rejected."""
    try:
        return datetime.strptime((value or '').strip(), '%Y-%m-%d').strftime('%Y-%m-%d')
    except ValueError:
        return ''


def _clean_time(value):
    raw = (value or '').strip()
    if not raw:
        return ''
    try:
        return datetime.strptime(raw, '%H:%M').strftime('%H:%M')
    except ValueError:
        return ''


def _safe_url(value):
    """Only http(s) survives. Blocks javascript: and data: URLs, which would
    otherwise execute when someone clicks the stream link."""
    raw = (value or '').strip()
    if not raw:
        return ''
    return raw if raw.lower().startswith(('http://', 'https://')) else ''


@app.route('/events')
def events():
    items = load_events()
    # Chronological, undated last.
    items.sort(key=lambda e: (e.get('date') or '9999-99-99', e.get('time') or '99:99'))
    return render_template(
        'events.html',
        events=items,
        event_types=EVENT_TYPES,
        today=datetime.now().strftime('%Y-%m-%d'),
    )


@app.route('/events/add', methods=['POST'])
@admin_required
def events_add():
    title = request.form.get('title', '').strip()
    date = _clean_date(request.form.get('date'))

    if not title:
        flash('An event needs a title.', 'error')
        return redirect(url_for('events'))
    if not date:
        flash('That date is not valid — use the date picker.', 'error')
        return redirect(url_for('events'))

    event_type = request.form.get('type', '').strip().lower()
    if event_type not in EVENT_TYPES:
        event_type = 'match'

    with _events_lock:
        items = load_events()
        new_id = max([e.get('id', 0) for e in items], default=0) + 1
        items.append({
            'id': new_id,
            'title': title[:140],
            'type': event_type,
            'game': request.form.get('game', '').strip()[:60],
            'date': date,
            'time': _clean_time(request.form.get('time')),
            'end_time': _clean_time(request.form.get('end_time')),
            'timezone': request.form.get('timezone', '').strip()[:12],
            'opponent': request.form.get('opponent', '').strip()[:80],
            'url': _safe_url(request.form.get('url')),
            'description': request.form.get('description', '').strip()[:1000],
            'result': '',
            'score': '',
            'created_at': datetime.now(timezone.utc).isoformat(),
            'created_by': session.get('username', 'admin'),
        })
        save_events(items)

    flash('Event added.', 'success')
    return redirect(url_for('events'))


@app.route('/events/delete/<int:event_id>', methods=['POST'])
@admin_required
def events_delete(event_id):
    with _events_lock:
        items = load_events()
        remaining = [e for e in items if e.get('id') != event_id]
        if len(remaining) == len(items):
            flash('That event no longer exists.', 'error')
            return redirect(url_for('events'))
        save_events(remaining)

    flash('Event deleted.', 'info')
    return redirect(url_for('events'))


@app.route('/events/result/<int:event_id>', methods=['POST'])
@admin_required
def events_result(event_id):
    """Record the outcome of a finished event. Posted by fetch(), answers JSON."""
    result = (request.form.get('result') or '').strip().lower()
    if result not in EVENT_RESULTS:
        return jsonify({'error': 'bad result'}), 400
    score = (request.form.get('score') or '').strip()[:20]

    with _events_lock:
        items = load_events()
        target = next((e for e in items if e.get('id') == event_id), None)
        if target is None:
            return jsonify({'error': 'not found'}), 404
        target['result'] = result
        target['score'] = score
        save_events(items)

    return jsonify({'id': event_id, 'result': result, 'score': score})


# =============================================
# === MAIN (development only) ===
# =============================================
# Apache never runs this block — it imports `app` from wsgi.py. Debug mode is
# opt-in via FLASK_DEBUG=1 so it can never be switched on accidentally in
# production, where it would expose an interactive Python console to the world.
if __name__ == '__main__':
    app.run(debug=_env_flag('FLASK_DEBUG', False))