#!/usr/bin/env python3
import asyncio
import io
import logging
import random
import threading
import time
from pathlib import Path

import yaml
from flask import Flask, abort, redirect, render_template_string, send_from_directory
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update, WebAppInfo
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, filters

BASE_DIR = Path(__file__).resolve().parent
CONFIG_FILE = BASE_DIR / "config.yaml"
IMAGES_DIR = BASE_DIR / "images"

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s"
)
log = logging.getLogger("link-manager")

app = Flask(__name__)

def load_config():
    with CONFIG_FILE.open("r", encoding="utf-8") as f:
        return yaml.safe_load(f) or {}

CONFIG = load_config()
SETTINGS = CONFIG.get("settings", {})
DOMAIN = SETTINGS.get("domain", "localhost")
HOST = SETTINGS.get("host", "127.0.0.1")
PORT = int(SETTINGS.get("port", 8080))
BOTS = CONFIG.get("bots", {})

def photo_file(value):
    """Fichier local correspondant a photo: dans config.yaml, ou None (URL / absent)."""
    value = str(value or "").strip()
    if not value or value.startswith(("http://", "https://")):
        return None
    # On ignore le chemin fourni : tout est cherche dans images/.
    path = IMAGES_DIR / Path(value.replace("\\", "/")).name
    if path.is_file():
        return path
    log.warning("photo introuvable : %s (attendu dans %s)", value, IMAGES_DIR)
    return None

def photo_web_url(value):
    """Valeur a mettre dans <img src=...>."""
    value = str(value or "").strip()
    if not value or value.startswith(("http://", "https://")):
        return value
    path = photo_file(value)
    return "/images/" + path.name if path else ""

# ============================================================
# CAPTCHA
# ============================================================
CAPTCHA_CHARS = "ABCDEFGHJKLMNPQRTUVWXYZ2346789"
CAPTCHA_LEN = 5
CAPTCHA_W = 340
CAPTCHA_H = 130
CAPTCHA_TIMEOUT = 300
CAPTCHA_MAX_TRY = 3

FONT_PATHS = [
    "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
    "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
    "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
    "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
    "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
]

FONT_DIRS = [
    "/usr/share/fonts",
    "/usr/local/share/fonts",
    str(Path.home() / ".fonts"),
    "C:/Windows/Fonts",
]

def find_fonts():
    found = [p for p in FONT_PATHS if Path(p).exists()]
    if found:
        return found
    # Aucun chemin connu : on cherche n'importe quelle police grasse installée.
    for directory in FONT_DIRS:
        root = Path(directory)
        if not root.is_dir():
            continue
        found = [str(f) for f in root.rglob("*.ttf") if "bold" in f.name.lower()]
        if found:
            return sorted(found)[:10]
    return []

AVAILABLE_FONTS = find_fonts()
if not AVAILABLE_FONTS:
    log.warning(
        "Aucune police TrueType trouvee : le captcha sera illisible. "
        "Installe le paquet fonts-dejavu-core (apt-get install -y fonts-dejavu-core)."
    )

def get_font(size):
    for path in random.sample(AVAILABLE_FONTS, len(AVAILABLE_FONTS)):
        try:
            return ImageFont.truetype(path, size)
        except Exception:
            continue
    try:
        # Pillow >= 10.1 sait agrandir sa police de secours.
        return ImageFont.load_default(size=size)
    except TypeError:
        return ImageFont.load_default()

def captcha_code():
    return "".join(random.choice(CAPTCHA_CHARS) for _ in range(CAPTCHA_LEN))

def char_layers(code, size, color):
    # Chaque caractere est dessine seul puis pivote, pour garder des bords nets.
    font = get_font(size)
    layers = []
    for char in code:
        box = font.getbbox(char)
        layer = Image.new("RGBA", (box[2] - box[0] + 16, box[3] - box[1] + 16), (0, 0, 0, 0))
        ImageDraw.Draw(layer).text((8 - box[0], 8 - box[1]), char, font=font, fill=color + (255,))
        layers.append(layer.rotate(
            random.uniform(-14, 14),
            resample=Image.Resampling.BICUBIC,
            expand=True
        ))
    return layers

def captcha_image(code):
    img = Image.new("RGB", (CAPTCHA_W, CAPTCHA_H), (246, 246, 244))
    draw = ImageDraw.Draw(img)

    for _ in range(int(CAPTCHA_W * CAPTCHA_H * 0.03)):
        x = random.randrange(CAPTCHA_W)
        y = random.randrange(CAPTCHA_H)
        g = random.randint(165, 220)
        draw.point((x, y), fill=(g, g, g))

    color = (random.randint(20, 55), random.randint(20, 55), random.randint(25, 70))
    gap = -4  # leger chevauchement : les lettres forment un bloc compact.

    # On reduit la taille jusqu'a ce que le mot tienne dans l'image.
    for size in range(72, 27, -4):
        layers = char_layers(code, size, color)
        total = sum(l.width for l in layers) + gap * (len(layers) - 1)
        if total <= CAPTCHA_W - 24:
            break

    x = (CAPTCHA_W - total) // 2
    for layer in layers:
        y = (CAPTCHA_H - layer.height) // 2 + random.randint(-7, 7)
        img.paste(layer, (x, max(0, min(y, CAPTCHA_H - layer.height))), layer)
        x += layer.width + gap

    for _ in range(random.randint(3, 5)):
        points = [
            (random.randint(0, CAPTCHA_W), random.randint(0, CAPTCHA_H))
            for _ in range(random.randint(2, 3))
        ]
        draw.line(points, fill=(random.randint(120, 170),) * 2 + (random.randint(150, 195),), width=1)

    img = img.filter(ImageFilter.SMOOTH)

    buf = io.BytesIO()
    img.save(buf, "PNG")
    buf.seek(0)
    buf.name = "captcha.png"
    return buf

def new_captcha(context):
    code = captcha_code()
    context.user_data["captcha"] = {
        "code": code,
        "expires": time.time() + CAPTCHA_TIMEOUT,
        "tries": 0,
    }

async def send_captcha(update, context):
    chat_id = update.effective_chat.id

    old_id = context.user_data.pop("captcha_msg_id", None)
    if old_id:
        try:
            await context.bot.delete_message(chat_id, old_id)
        except Exception:
            pass

    new_captcha(context)
    code = context.user_data["captcha"]["code"]

    keyboard = InlineKeyboardMarkup([
        [InlineKeyboardButton("🔄 Nouvelle image", callback_data="captcha:new")]
    ])

    msg = await context.bot.send_photo(
        chat_id=chat_id,
        photo=captcha_image(code),
        caption=(
            "🔐 Vérification anti-robot\n\n"
            f"Recopie les {CAPTCHA_LEN} caractères de l'image et envoie-les ici.\n"
            "Les majuscules/minuscules n'ont pas d'importance."
        ),
        reply_markup=keyboard,
    )
    context.user_data["captcha_msg_id"] = msg.message_id

def captcha_result(context, text):
    data = context.user_data.get("captcha")
    if not data:
        return "none"

    if time.time() > data["expires"]:
        return "expired"

    entered = "".join(c for c in (text or "").upper() if c.isalnum())
    return "ok" if entered == data["code"] else "bad"

async def send_welcome(chat_id, context):
    bot_name = context.bot_data["bot_name"]
    cfg = BOTS[bot_name]

    buttons = []

    # Une entrée par lien configuré.
    # web_app= ouvre une Mini App ; url= ouvrirait le navigateur in-app.
    for i, link in enumerate(cfg.get("links", [])):
        buttons.append([
            InlineKeyboardButton(
                link.get("name", f"Lien {i + 1}"),
                web_app=WebAppInfo(url=f"https://{DOMAIN}/r/{bot_name}/{i}")
            )
        ])

    buttons.append([
        InlineKeyboardButton(
            "🔒 Politique de Confidentialité",
            web_app=WebAppInfo(url=f"https://{DOMAIN}/{bot_name}/privacy_policy.php")
        )
    ])
    buttons.append([
        InlineKeyboardButton(
            "📄 Conditions d’Utilisation",
            web_app=WebAppInfo(url=f"https://{DOMAIN}/{bot_name}/terms.php")
        )
    ])

    markup = InlineKeyboardMarkup(buttons)
    text = cfg.get("description") or cfg.get("title") or "Bienvenue."

    photo = str(cfg.get("photo", "")).strip()
    local = photo_file(photo)
    if local or photo:
        try:
            if local:
                # Telegram ne sait pas lire un chemin local : on lui envoie les octets.
                with local.open("rb") as fh:
                    await context.bot.send_photo(
                        chat_id=chat_id,
                        photo=fh,
                        caption=text,
                        reply_markup=markup
                    )
            else:
                await context.bot.send_photo(
                    chat_id=chat_id,
                    photo=photo,
                    caption=text,
                    reply_markup=markup
                )
            return
        except Exception:
            log.exception("[%s] impossible d'envoyer la photo", bot_name)

    await context.bot.send_message(
        chat_id=chat_id,
        text=text,
        reply_markup=markup
    )

async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await send_captcha(update, context)

async def captcha_refresh(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer("Nouvelle image")
    await send_captcha(update, context)

async def on_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not update.message:
        return

    # Si un captcha est en attente, tout texte est considéré comme réponse.
    if context.user_data.get("captcha"):
        result = captcha_result(context, update.message.text or "")

        if result == "ok":
            context.user_data.pop("captcha", None)

            old_id = context.user_data.pop("captcha_msg_id", None)
            if old_id:
                try:
                    await context.bot.delete_message(
                        chat_id=update.effective_chat.id,
                        message_id=old_id
                    )
                except Exception:
                    pass

            await update.message.reply_text("✅ Vérification réussie !")
            await send_welcome(update.effective_chat.id, context)
            return

        if result == "expired":
            await update.message.reply_text(
                "⏳ Le captcha a expiré. Voici une nouvelle image 👇"
            )
            await send_captcha(update, context)
            return

        data = context.user_data["captcha"]
        data["tries"] += 1

        if data["tries"] >= CAPTCHA_MAX_TRY:
            await update.message.reply_text(
                "❌ Trop d'erreurs. Une nouvelle image va être générée 👇"
            )
            await send_captcha(update, context)
        else:
            remaining = CAPTCHA_MAX_TRY - data["tries"]
            await update.message.reply_text(
                f"❌ Code incorrect. Il reste {remaining} essai(s)."
            )
        return

# ============================================================
# PAGES WEB
# ============================================================
PAGE = """
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ title }}</title>
<style>
body{font-family:Arial,sans-serif;background:#111;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;padding:20px}
.card{width:min(560px,100%);background:#1c1c1c;border-radius:18px;padding:24px;box-shadow:0 15px 50px #0008;text-align:center}
img{max-width:110px;max-height:110px;border-radius:14px;object-fit:cover;margin-bottom:15px}
a{display:block;background:#fff;color:#111;text-decoration:none;padding:14px;border-radius:10px;margin:10px 0;font-weight:bold}
p{color:#bbb;line-height:1.5}
.legal{font-size:13px;color:#999;margin-top:20px}
.legal a{display:inline;background:none;color:#aaa;padding:0;margin:0 8px}
</style>
</head>
<body>
<div class="card">
{% if photo %}<img src="{{ photo }}" alt="Photo">{% endif %}
<h1>{{ title }}</h1>
{% if description %}<p>{{ description }}</p>{% endif %}
{% for link in links %}
<a href="/r/{{ bot }}/{{ loop.index0 }}">{{ link.name }}</a>
{% endfor %}
<div class="legal">
<a href="/{{ bot }}/privacy_policy.php">Politique de Confidentialité</a>
<a href="/{{ bot }}/terms.php">Conditions d’Utilisation</a>
</div>
</div>
</body>
</html>
"""

LEGAL_PAGE = """
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ page_title }} — {{ bot_label }}</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
body{font-family:Arial,sans-serif;background:#111;color:#eee;margin:0;padding:30px 15px}
.container{max-width:850px;margin:auto;background:#1c1c1c;border-radius:18px;padding:30px;box-sizing:border-box}
h1{margin-top:0;font-size:30px}
h2{margin-top:30px;font-size:20px}
p,li{line-height:1.7;color:#ccc}
a{color:#fff}
.nav{margin-top:30px;padding-top:20px;border-top:1px solid #333}
.nav a{margin-right:18px}
</style>
</head>
<body>
<div class="container">
{{ content|safe }}
<div class="nav">
<a href="/{{ bot }}/">← Retour</a>
<a href="/{{ bot }}/privacy_policy.php">Politique de Confidentialité</a>
<a href="/{{ bot }}/terms.php">Conditions d’Utilisation</a>
</div>
</div>
<script>
var tg = window.Telegram && window.Telegram.WebApp;
if (tg && tg.platform && tg.platform !== 'unknown') { tg.ready(); tg.expand(); }
</script>
</body>
</html>
"""

def bot_display_name(bot_name, cfg):
    return str(cfg.get("name") or cfg.get("username") or bot_name)

def bot_mention(bot_name, cfg):
    username = str(cfg.get("username") or bot_display_name(bot_name, cfg))
    return "@" + username.lstrip("@")

def legal_content(bot_name, cfg, kind):
    mention = bot_mention(bot_name, cfg)
    privacy_url = f"https://{DOMAIN}/{bot_name}/privacy_policy.php"
    terms_url = f"https://{DOMAIN}/{bot_name}/terms.php"

    if kind == "privacy":
        return f"""
<h1>Politique de Confidentialité</h1>
<p>Bienvenue sur <strong>{mention}</strong> 👋</p>
<p>Cette politique de confidentialité explique comment notre bot Telegram fonctionne et comment les informations sont traitées lors de son utilisation.</p>

<h2>1. Objectif du Bot</h2>
<p>{mention} permet aux utilisateurs d’accéder facilement à nos canaux Telegram officiels. Ces canaux contiennent uniquement des liens approuvés par notre communauté.</p>

<h2>2. Acceptation des Conditions</h2>
<p>Avant de continuer à utiliser le bot, les utilisateurs doivent lire et accepter nos <strong>Terms &amp; Conditions</strong>.</p>
<p>Vous pouvez les consulter ici : <a href="{terms_url}">Terms &amp; Conditions</a></p>
<p>En cliquant sur <strong>« Continuer »</strong> dans le bot, vous acceptez :</p>
<ol>
<li>Nos Terms &amp; Conditions.</li>
<li>D’être redirigé vers nos canaux Telegram officiels.</li>
<li>Que le bot ne collecte pas vos données personnelles sensibles.</li>
</ol>

<h2>3. Collecte des Données</h2>
<p>{mention} ne collecte, ne stocke et ne traite <strong>aucune donnée personnelle sensible</strong>.</p>
<p>Le bot sert uniquement à rediriger les utilisateurs vers nos canaux Telegram.</p>

<h2>4. Services Tiers</h2>
<p>Ce bot fonctionne via la plateforme <strong>Telegram</strong>. Toute donnée éventuellement traitée lors de l'utilisation de Telegram est soumise à la politique de confidentialité de Telegram.</p>

<h2>5. Choix de l’Utilisateur</h2>
<p>Si vous n’acceptez pas ces conditions, vous pouvez arrêter d’utiliser le bot à tout moment.</p>

<h2>6. Modification de la Politique</h2>
<p>Cette politique de confidentialité peut être modifiée à tout moment. Les changements seront publiés sur cette page.</p>

<p>© 2026 {mention}</p>
"""

    return f"""
<h1>Conditions d’Utilisation</h1>
<p>Bienvenue sur <strong>{mention}</strong> 👋</p>
<p>Ce bot officiel a pour unique objectif de vous rediriger vers nos canaux Telegram officiels. Veuillez utiliser les liens via le bot et accepter nos politiques avant d’accéder aux contenus.</p>

<h2>1. Objet du bot</h2>
<p>Ce bot est fourni uniquement afin de rediriger les utilisateurs vers les canaux Telegram officiels associés au service.</p>

<h2>2. Utilisation autorisée</h2>
<p>Vous acceptez de ne pas utiliser ce bot de manière abusive, notamment :</p>
<ul>
<li>Envoyer des requêtes automatisées ou excessives</li>
<li>Tenter de perturber ou de surcharger le service</li>
<li>Tenter d’exploiter ou de contourner le fonctionnement du bot</li>
</ul>

<h2>3. Disponibilité du service</h2>
<p>Le service est fourni « tel quel ». Nous ne garantissons pas une disponibilité continue ou sans interruption du bot.</p>

<h2>4. Redirection vers des canaux</h2>
<p>Le bot peut rediriger les utilisateurs vers des canaux Telegram officiels. Ces canaux sont gérés indépendamment et leur contenu peut évoluer.</p>

<h2>5. Responsabilité</h2>
<p>Les administrateurs du bot ne peuvent être tenus responsables de toute perte ou dommage résultant de l’utilisation du bot ou de l’accès aux canaux mentionnés.</p>

<h2>6. Données et confidentialité</h2>
<p>Le bot ne collecte pas de données personnelles sensibles. Pour plus d’informations, veuillez consulter notre <a href="{privacy_url}">Politique de Confidentialité</a>.</p>

<h2>7. Modifications</h2>
<p>Les administrateurs se réservent le droit de modifier ces conditions à tout moment sans préavis. Les modifications seront publiées sur cette page.</p>

<h2>8. Acceptation</h2>
<p>En cliquant sur <strong>« Accepter les conditions »</strong> dans le bot, vous confirmez avoir lu et accepté ces Conditions d’Utilisation.</p>

<p>© 2026 {mention}</p>
"""

@app.get("/")
def home():
    enabled = [n for n, c in BOTS.items() if c.get("enabled")]
    if len(enabled) == 1:
        return redirect("/" + enabled[0])
    return "<h1>Link Manager</h1><p>Utilise /nom_du_bot</p>"

@app.get("/images/<path:filename>")
def serve_image(filename):
    if not IMAGES_DIR.is_dir():
        abort(404)
    return send_from_directory(IMAGES_DIR, filename)

@app.get("/<bot_name>/privacy_policy.php")
def privacy_policy(bot_name):
    cfg = BOTS.get(bot_name)
    if not cfg or not cfg.get("enabled"):
        abort(404)
    return render_template_string(
        LEGAL_PAGE,
        page_title="Politique de Confidentialité",
        bot_label=bot_display_name(bot_name, cfg),
        bot=bot_name,
        content=legal_content(bot_name, cfg, "privacy")
    )

@app.get("/<bot_name>/terms.php")
def terms(bot_name):
    cfg = BOTS.get(bot_name)
    if not cfg or not cfg.get("enabled"):
        abort(404)
    return render_template_string(
        LEGAL_PAGE,
        page_title="Conditions d’Utilisation",
        bot_label=bot_display_name(bot_name, cfg),
        bot=bot_name,
        content=legal_content(bot_name, cfg, "terms")
    )

@app.get("/<bot_name>")
def bot_page(bot_name):
    cfg = BOTS.get(bot_name)
    if not cfg or not cfg.get("enabled"):
        abort(404)
    return render_template_string(
        PAGE,
        bot=bot_name,
        title=cfg.get("title", bot_display_name(bot_name, cfg)),
        description=cfg.get("description", ""),
        photo=photo_web_url(cfg.get("photo", "")),
        links=cfg.get("links", [])
    )

@app.get("/r/<bot_name>/<int:index>")
def do_redirect(bot_name, index):
    cfg = BOTS.get(bot_name)
    if not cfg or not cfg.get("enabled"):
        abort(404)
    links = cfg.get("links", [])
    if index < 0 or index >= len(links):
        abort(404)
    url = str(links[index].get("url", "")).strip()
    if not url.startswith(("http://", "https://")):
        abort(400)
    # La WebView Telegram intercepte elle-meme les URL t.me : un 302 suffit.
    return redirect(url, 302)

async def run_bot(bot_name, cfg):
    if not cfg.get("enabled"):
        log.info("[%s] désactivé", bot_name)
        return

    token = str(cfg.get("token", "")).strip()
    if not token:
        log.warning("[%s] activé mais token vide", bot_name)
        return

    application = Application.builder().token(token).build()
    application.bot_data["bot_name"] = bot_name

    application.add_handler(CommandHandler("start", start_command))
    application.add_handler(
        CallbackQueryHandler(captcha_refresh, pattern=r"^captcha:new$")
    )
    application.add_handler(
        MessageHandler(filters.TEXT & ~filters.COMMAND, on_message)
    )

    await application.initialize()
    await application.start()
    await application.updater.start_polling()
    log.info("[%s] Telegram démarré", bot_name)

    try:
        while True:
            await asyncio.sleep(3600)
    finally:
        await application.updater.stop()
        await application.stop()
        await application.shutdown()

def telegram_thread():
    async def runner():
        await asyncio.gather(
            *(run_bot(name, cfg) for name, cfg in BOTS.items())
        )
    try:
        asyncio.run(runner())
    except Exception:
        log.exception("Erreur Telegram")

if __name__ == "__main__":
    threading.Thread(target=telegram_thread, daemon=True).start()
    app.run(host=HOST, port=PORT, debug=False, use_reloader=False)
