#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import sys
import re
import time
import html
import json
import base64
import random
import hashlib
import smtplib
import hmac
from datetime import datetime, timedelta
from email.message import EmailMessage
from email.utils import formataddr, make_msgid, formatdate
from urllib.parse import urlencode, urlsplit, urlunsplit, parse_qsl
from typing import List, Dict, Optional, Tuple

try:
    from colorama import init as colorama_init, Fore, Style
    colorama_init(autoreset=True)
except Exception:
    class _NoColor:
        BLACK = RED = GREEN = YELLOW = BLUE = MAGENTA = CYAN = WHITE = ""
        RESET = ""
    class _NoStyle:
        BRIGHT = NORMAL = DIM = RESET_ALL = ""
    Fore = _NoColor()
    Style = _NoStyle()

try:
    import dns.resolver
    DNS_AVAILABLE = True
except Exception:
    DNS_AVAILABLE = False

# =========================================================
# CONFIG
# =========================================================
CFG = {
    # SMTP local
    "smtp_host": "127.0.0.1",
    "smtp_port": 25,
    "smtp_timeout": 30,

    # Fichiers
    "recipients_file": "list.txt",
    "sender_emails_file": "sender_emails.txt",
    "sender_names_file": "sender_names.txt",
    "subjects_file": "Subj.txt",
    "links_file": "links.txt",
    "rand_words_file": "rand_words.txt",
    "plain_file": "plain.txt",      # optionnel
    "templates_dir": "templates",   # *.html

    # Logs
    "log_sent": "sent_emails.log",
    "log_failed": "invalid_emails.log",

    # Mode strict: si fichier manquant/invalid -> stop
    "strict_txt_only": True,

    # Fallbacks (utilisés seulement si strict=False)
    "fallback_sender_email": "contact@example.com",
    "fallback_sender_name": "Support",
    "fallback_subject": "Notification [DATE] [RAND]",
    "fallback_link": "https://example.com",
    "fallback_rand_words": ["Info", "Update", "Service", "Support"],

    # Validation
    "dedupe_recipients": True,
    "check_mx": True,   # nécessite dnspython pour check complet

    # Batch
    "batch_enabled": True,
    "batch_size": 100,
    "batch_pause_sec": 5,

    # Délai entre emails
    "per_email_delay_sec": 0.15,

    # Test sans envoi réel
    "dry_run": False,

    # Affichage terminal
    # False = une ligne par email (plus lisible)
    # True  = une seule ligne qui se met à jour en continu
    "terminal_single_line": False,

    # Protection lien (NOUVEAU)
    "link_protection_enabled": True,
    "link_secret_key": "a82cc5178303bf311521f12c6fba95dfe5ccfd114b92978ebecdc434ad6980c0b9a843627c88ce596fd8d892cacf8436d2fe6691f45cab09cf7b9c2250a73331",
    "link_ttl_seconds": 0,  # 24h

}

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

# =========================================================
# HELPERS
# =========================================================
def log_line(path: str, line: str) -> None:
    with open(path, "a", encoding="utf-8") as f:
        f.write(line + "\n")


def print_banner(total: int) -> None:
    print(Style.BRIGHT + Fore.CYAN + "=" * 68)
    print(Style.BRIGHT + Fore.CYAN + f" Mailer started | Total recipients: {total}")
    print(Style.BRIGHT + Fore.CYAN + "=" * 68)


def print_progress(
    status: str,
    color: str,
    done: int,
    total: int,
    sent: int,
    failed: int,
    skipped: int,
    email_addr: str,
    single_line: bool = True
) -> None:
    pct = (done / total * 100) if total else 0.0
    line = (
        f"\r{Style.BRIGHT}{color}[{status}]{Style.RESET_ALL} "
        f"{done}/{total} ({pct:6.2f}%) | "
        f"{Fore.GREEN}Sent:{sent}{Style.RESET_ALL} "
        f"{Fore.RED}Failed:{failed}{Style.RESET_ALL} "
        f"{Fore.YELLOW}Skipped:{skipped}{Style.RESET_ALL} | "
        f"{Fore.WHITE}{email_addr}{Style.RESET_ALL}    "
    )
    if single_line:
        sys.stdout.write(line)
        sys.stdout.flush()
    else:
        print(line.replace("\r", ""))


def print_summary(total: int, sent: int, failed: int, skipped: int, started_at: float) -> None:
    duration = max(0.0, time.time() - started_at)
    print()
    print(Style.BRIGHT + Fore.CYAN + "=" * 68)
    print(
        Style.BRIGHT + Fore.CYAN +
        f" Done | Total={total} | Sent={sent} | Failed={failed} | Skipped={skipped} | Time={duration:.1f}s"
    )
    print(Style.BRIGHT + Fore.CYAN + "=" * 68)


def add_professional_headers(msg: EmailMessage, sender_email: str, recipient_email: str) -> None:
    sender_domain = sender_email.split("@", 1)[1] if "@" in sender_email else None
    if sender_domain:
        msg["Message-ID"] = make_msgid(domain=sender_domain)
    else:
        msg["Message-ID"] = make_msgid()

    msg["Date"] = formatdate(localtime=True)
    msg["Auto-Submitted"] = "auto-generated"
    msg["X-Auto-Response-Suppress"] = "OOF, AutoReply"

    campaign_id = str(CFG.get("campaign_id", "main")).strip() or "main"
    msg["Feedback-ID"] = f"{campaign_id}:{sender_email}:{recipient_email}"

    unsub_values = []
    unsub_mailto = str(CFG.get("list_unsubscribe_mailto", "")).strip()
    unsub_url = str(CFG.get("list_unsubscribe_url", "")).strip()

    if unsub_mailto:
        unsub_values.append(f"<mailto:{unsub_mailto}>")
    if unsub_url:
        unsub_values.append(f"<{unsub_url}>")

    if unsub_values:
        msg["List-Unsubscribe"] = ", ".join(unsub_values)
        if unsub_url.lower().startswith("https://"):
            msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"

def load_text_lines(path: str, strict: bool) -> List[str]:
    if not os.path.isfile(path):
        if strict:
            raise RuntimeError(f"Fichier introuvable: {path}")
        return []
    out = []
    with open(path, "r", encoding="utf-8", errors="ignore") as f:
        for row in f:
            row = row.strip()
            if not row or row.startswith("#"):
                continue
            out.append(row)
    return out


def chunked(seq: List[str], size: int) -> List[List[str]]:
    return [seq[i:i + size] for i in range(0, len(seq), size)]


def has_valid_mx(email_addr: str) -> bool:
    # Si dnspython absent, on n'échoue pas brutalement
    if not DNS_AVAILABLE:
        return True

    try:
        domain = email_addr.split("@", 1)[1].strip().lower()
    except Exception:
        return False
    if not domain:
        return False

    try:
        mx = dns.resolver.resolve(domain, "MX")
        if mx:
            return True
    except Exception:
        pass

    try:
        a = dns.resolver.resolve(domain, "A")
        return bool(a)
    except Exception:
        return False


def load_senders(
    emails_file: str,
    names_file: str,
    strict: bool,
    fallback_email: str,
    fallback_name: str
) -> List[Dict]:
    emails_raw = load_text_lines(emails_file, strict)
    names_raw = load_text_lines(names_file, strict)

    valid_emails = []
    for e in emails_raw:
        if EMAIL_RE.match(e):
            valid_emails.append(e)
        elif strict:
            raise RuntimeError(f"Email sender invalide: {e}")

    if not valid_emails:
        if strict:
            raise RuntimeError(f"Aucun sender email valide dans: {emails_file}")
        if not EMAIL_RE.match(fallback_email):
            raise RuntimeError("Fallback sender email invalide.")
        valid_emails = [fallback_email]

    if not names_raw:
        if strict:
            raise RuntimeError(f"Aucun sender name dans: {names_file}")
        names_raw = [fallback_name]

    senders = []
    for i, email_addr in enumerate(valid_emails):
        name = names_raw[i % len(names_raw)].strip() or fallback_name
        senders.append({"email": email_addr, "name": name})
    return senders


def load_templates(templates_dir: str) -> List[str]:
    if not os.path.isdir(templates_dir):
        raise RuntimeError(f"Dossier templates introuvable: {templates_dir}")
    files = sorted(
        os.path.join(templates_dir, f)
        for f in os.listdir(templates_dir)
        if f.lower().endswith(".html")
    )
    if not files:
        raise RuntimeError(f"Aucun template .html dans: {templates_dir}")

    out = []
    for p in files:
        with open(p, "r", encoding="utf-8", errors="ignore") as f:
            out.append(f.read())
    return out


def load_links(path: str, strict: bool, fallback_link: str) -> List[str]:
    rows = load_text_lines(path, strict)
    links = []

    for u in rows:
        if re.match(r"^https?://", u, flags=re.I):
            links.append(u)
        elif strict:
            raise RuntimeError(f"URL invalide dans {path}: {u}")

    links = list(dict.fromkeys(links))
    if not links:
        if not strict and re.match(r"^https?://", fallback_link, flags=re.I):
            links = [fallback_link]
        else:
            raise RuntimeError("Aucun lien valide.")
    return links


def load_rand_words(path: str, strict: bool, fallback: List[str]) -> List[str]:
    rows = load_text_lines(path, strict)
    rows = list(dict.fromkeys(rows))
    if not rows:
        if strict:
            raise RuntimeError(f"Aucun mot RAND valide dans: {path}")
        return fallback
    return rows


def choose_subject(path: str, strict: bool, fallback: str) -> str:
    subs = load_text_lines(path, strict)
    if not subs:
        if strict:
            raise RuntimeError(f"Aucun sujet dans: {path}")
        return fallback
    return random.choice(subs)


def load_plain_template(path: str) -> str:
    if not os.path.isfile(path):
        return ""
    with open(path, "r", encoding="utf-8", errors="ignore") as f:
        return f.read().strip()


def extract_name_from_email(email_addr: str) -> List[str]:
    local = email_addr.split("@")[0]
    local = re.sub(r"[._-]+", " ", local)
    local = re.sub(r"\d+", "", local)
    parts = [p for p in local.strip().split() if p]
    return [p.capitalize() for p in parts]


def generate_id(email_addr: str) -> str:
    return hashlib.md5(email_addr.encode("utf-8")).hexdigest()[:10].upper()


def generate_ref(email_addr: str) -> str:
    return hashlib.md5((email_addr + "ref").encode("utf-8")).hexdigest()[:8].upper()


def apply_spin_tag(text: str) -> str:
    def repl(m):
        choices = [x.strip() for x in m.group(1).split("|") if x.strip()]
        return random.choice(choices) if choices else ""
    return re.sub(r"\[SPIN:([^\]]+)\]", repl, text)


def generate_plain_from_html(html_text: str) -> str:
    t = re.sub(r"(?i)<\s*br\s*/?\s*>", "\n", html_text)
    t = re.sub(r"(?i)</\s*p\s*>", "\n\n", t)
    t = re.sub(r"(?i)</\s*div\s*>", "\n", t)
    t = re.sub(r"(?i)</\s*li\s*>", "\n", t)
    t = re.sub(r"<[^>]+>", "", t)
    t = html.unescape(t)
    t = t.replace("\r\n", "\n").replace("\r", "\n")
    t = re.sub(r"[ \t]+", " ", t)
    t = re.sub(r"\n{3,}", "\n\n", t)
    return t.strip()

# =========================================================
# LINK PROTECTION (NOUVEAU)
# =========================================================
def _b64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")

def _b64url_decode(data: str) -> bytes:
    pad = "=" * (-len(data) % 4)
    return base64.urlsafe_b64decode(data + pad)

def build_protected_link(base_url: str, payload: dict, secret: str, ttl_seconds: int = 0) -> str:
    data = dict(payload)

    # Ajoute exp seulement si ttl > 0
    if int(ttl_seconds) > 0:
        data["exp"] = int(time.time()) + int(ttl_seconds)

    raw = json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8")
    d = _b64url_encode(raw)
    s = hmac.new(secret.encode("utf-8"), d.encode("ascii"), hashlib.sha256).hexdigest()

    parts = urlsplit(base_url)
    q = dict(parse_qsl(parts.query, keep_blank_values=True))
    q["d"] = d
    q["s"] = s
    new_query = urlencode(q, doseq=True)

    return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))

def protect_link_if_enabled(base_link: str, email_addr: str, ref: str) -> str:
    if not CFG.get("link_protection_enabled", False):
        return base_link

    secret = str(CFG.get("link_secret_key", "")).strip()
    if not secret or secret.startswith("CHANGE_ME_"):
        raise RuntimeError("link_secret_key non configurée. Mets une vraie clé secrète.")

    ttl = int(CFG.get("link_ttl_seconds", 0))  # 0 = no expiry
    payload = {
        "email": email_addr,
        "ref": ref,
    }
    return build_protected_link(base_link, payload, secret, ttl)

def verify_protected_link(d: str, s: str, secret: str) -> Tuple[bool, Optional[Dict]]:
    calc = hmac.new(secret.encode("utf-8"), d.encode("ascii"), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(calc, s):
        return False, None

    try:
        payload = json.loads(_b64url_decode(d).decode("utf-8"))
    except Exception:
        return False, None

    # Vérifie exp seulement si présent
    exp = payload.get("exp")
    if exp is not None:
        try:
            if int(exp) < int(time.time()):
                return False, None
        except Exception:
            return False, None

    return True, payload

# =========================================================
# TAG REPLACER
# =========================================================
def replace_tags(text: str, email_addr: str, link: str, rand_words: List[str]) -> str:
    now = datetime.now()
    name_parts = extract_name_from_email(email_addr)
    prenom = name_parts[0] if name_parts else "Client"
    nom_complet = " ".join(name_parts) if name_parts else "Client"

    local = email_addr.split("@")[0]
    domain = email_addr.split("@")[1] if "@" in email_addr else ""

    _id = generate_id(email_addr)
    _ref = generate_ref(email_addr)

    mapping = {
        "[-email-]": email_addr,
        "{EMAIL}": email_addr,
        "[NAME]": prenom,
        "[PRENOM]": prenom,
        "[NOM]": nom_complet,
        "[EMAIL_LOCAL]": local,
        "[EMAIL_DOMAIN]": domain,

        "[DAY]": now.strftime("%A"),
        "[JOUR]": now.strftime("%A"),
        "[DATE]": now.strftime("%d/%m/%Y"),
        "{DATE}": now.strftime("%d/%m/%Y"),
        "[MOIS]": now.strftime("%B"),
        "[MONTH]": now.strftime("%B"),
        "{MONTH}": now.strftime("%B"),
        "[ANNEE]": now.strftime("%Y"),
        "[ANNÉE]": now.strftime("%Y"),
        "[YEAR]": now.strftime("%Y"),
        "[HEURE]": now.strftime("%H:%M"),
        "[TIME]": now.strftime("%H:%M"),
        "{TIME}": now.strftime("%H:%M"),

        "[-link-]": link,
        "[LINK]": link,
        "[LIEN]": link,

        "[ID]": _id,
        "[REF]": _ref,
        "[REF-CODE]": "REF-" + _ref,
    }

    for k, v in mapping.items():
        text = text.replace(k, v)

    def d(fmt: str, days: int = 0, hours: int = 0):
        dt = now + timedelta(days=days, hours=hours)
        return dt.strftime(fmt)

    text = re.sub(r"\[DAY\+(\d+)\]", lambda m: d("%A %d %B %Y", days=int(m.group(1))), text)
    text = re.sub(r"\[DAY-(\d+)\]", lambda m: d("%A %d %B %Y", days=-int(m.group(1))), text)
    text = re.sub(r"\[DAYNAME\+(\d+)\]", lambda m: d("%A", days=int(m.group(1))), text)
    text = re.sub(r"\[REMIND\+(\d+)\]", lambda m: d("%A %d %B %Y", days=int(m.group(1))), text)
    text = re.sub(r"\{REMIND\+(\d+)\}", lambda m: d("%A %d %B %Y", days=int(m.group(1))), text)
    text = re.sub(r"\[DATE\+(\d+)\]", lambda m: d("%d/%m/%Y", days=int(m.group(1))), text)
    text = re.sub(r"\[DATE-(\d+)\]", lambda m: d("%d/%m/%Y", days=-int(m.group(1))), text)
    text = re.sub(r"\{DATE\+(\d+)\}", lambda m: d("%d/%m/%Y", days=int(m.group(1))), text)
    text = re.sub(r"\{DATE-(\d+)\}", lambda m: d("%d/%m/%Y", days=-int(m.group(1))), text)
    text = re.sub(r"\{DAY\+(\d+)\}", lambda m: d("%A %d %B %Y", days=int(m.group(1))), text)
    text = re.sub(r"\{TIME\+(\d+)\}", lambda m: d("%H:%M", hours=int(m.group(1))), text)

    text = re.sub(r"\[RAND\]", lambda _: random.choice(rand_words), text)
    text = re.sub(r"\{n,(\d+)\}", lambda m: str(random.randint(0, int(m.group(1)))), text)

    def rand_alnum(m):
        n = int(m.group(1))
        chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
        return "".join(random.choice(chars) for _ in range(n))
    text = re.sub(r"\{Aan,(\d+)\}", rand_alnum, text)

    text = apply_spin_tag(text)
    return text


def main():
    strict = bool(CFG["strict_txt_only"])
    terminal_single_line = bool(CFG.get("terminal_single_line", False))

    recipients = load_text_lines(CFG["recipients_file"], True)
    if not recipients:
        raise RuntimeError("Aucun destinataire dans list.txt")

    if CFG["dedupe_recipients"]:
        recipients = list(dict.fromkeys([r.strip().lower() for r in recipients if r.strip()]))

    senders = load_senders(
        CFG["sender_emails_file"],
        CFG["sender_names_file"],
        strict,
        CFG["fallback_sender_email"],
        CFG["fallback_sender_name"]
    )
    templates = load_templates(CFG["templates_dir"])
    links = load_links(CFG["links_file"], strict, CFG["fallback_link"])
    rand_words = load_rand_words(CFG["rand_words_file"], strict, CFG["fallback_rand_words"])
    plain_tpl = load_plain_template(CFG["plain_file"])

    sender_count = len(senders)
    template_count = len(templates)
    link_count = len(links)

    sent = failed = skipped = 0
    global_index = 0

    batch_enabled = bool(CFG["batch_enabled"])
    batch_size = max(1, int(CFG["batch_size"]))
    batch_pause = max(0, int(CFG["batch_pause_sec"]))
    batches = chunked(recipients, batch_size) if batch_enabled else [recipients]
    total_recipients = len(recipients)
    started_at = time.time()
    print_banner(total_recipients)

    smtp = smtplib.SMTP(CFG["smtp_host"], int(CFG["smtp_port"]), timeout=int(CFG["smtp_timeout"]))

    try:
        for bi, batch in enumerate(batches):
            for rec in batch:
                email_addr = rec.strip()
                if not email_addr:
                    skipped += 1
                    global_index += 1
                    done = sent + failed + skipped
                    print_progress("SKIPPED", Fore.YELLOW, done, total_recipients, sent, failed, skipped, "(empty)", terminal_single_line)
                    continue

                if not EMAIL_RE.match(email_addr):
                    log_line(CFG["log_failed"], f"{email_addr} - format invalide")
                    skipped += 1
                    global_index += 1
                    done = sent + failed + skipped
                    print_progress("SKIPPED", Fore.YELLOW, done, total_recipients, sent, failed, skipped, email_addr, terminal_single_line)
                    continue

                if CFG["check_mx"] and not has_valid_mx(email_addr):
                    log_line(CFG["log_failed"], f"{email_addr} - pas de MX/A valide")
                    skipped += 1
                    global_index += 1
                    done = sent + failed + skipped
                    print_progress("SKIPPED", Fore.YELLOW, done, total_recipients, sent, failed, skipped, email_addr, terminal_single_line)
                    continue

                sender = senders[global_index % sender_count]
                template = templates[global_index % template_count]
                base_link = links[global_index % link_count]
                subject_tpl = choose_subject(CFG["subjects_file"], strict, CFG["fallback_subject"])

                # Lien protégé auto
                ref_value = generate_ref(email_addr)
                final_link = protect_link_if_enabled(base_link, email_addr, ref_value)

                subject = replace_tags(subject_tpl, email_addr, final_link, rand_words)
                body_html = replace_tags(template, email_addr, final_link, rand_words)

                if plain_tpl:
                    body_plain = replace_tags(plain_tpl, email_addr, final_link, rand_words)
                else:
                    body_plain = generate_plain_from_html(body_html)

                msg = EmailMessage()
                msg["From"] = formataddr((sender["name"], sender["email"]))
                msg["To"] = email_addr
                msg["Subject"] = subject
                msg["Reply-To"] = formataddr((sender["name"], sender["email"]))
                add_professional_headers(msg, sender["email"], email_addr)

                msg.set_content(body_plain, charset="utf-8")
                msg.add_alternative(body_html, subtype="html", charset="utf-8")

                if CFG["dry_run"]:
                    sent += 1
                    done = sent + failed + skipped
                    print_progress("DRY-RUN", Fore.CYAN, done, total_recipients, sent, failed, skipped, email_addr, terminal_single_line)
                else:
                    try:
                        smtp.send_message(msg, from_addr=sender["email"], to_addrs=[email_addr])
                        sent += 1
                        log_line(CFG["log_sent"], f'{email_addr} - envoye le {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
                        done = sent + failed + skipped
                        print_progress("SENT", Fore.GREEN, done, total_recipients, sent, failed, skipped, email_addr, terminal_single_line)
                    except Exception as e:
                        failed += 1
                        err = str(e)
                        log_line(CFG["log_failed"], f"{email_addr} - echec envoi: {err}")
                        done = sent + failed + skipped
                        print_progress("FAILED", Fore.RED, done, total_recipients, sent, failed, skipped, email_addr, terminal_single_line)
                        print()
                        print(Fore.RED + f"Error: {email_addr} | {err}")

                global_index += 1
                time.sleep(float(CFG["per_email_delay_sec"]))

            is_last = (bi == len(batches) - 1)
            if batch_enabled and not is_last and batch_pause > 0:
                print()
                print(Fore.MAGENTA + f"Pause lot: {batch_pause}s...")
                time.sleep(batch_pause)

    finally:
        try:
            smtp.quit()
        except Exception:
            pass

    print_summary(total_recipients, sent, failed, skipped, started_at)


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(f"Fatal error: {exc}")