from __future__ import annotations
import io
import os
import sys
import time
import threading
import subprocess
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, field
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional, Any

_REQUIRED: dict[str, str] = {"requests": "requests", "colorama": "colorama"}

_file_write_lock = threading.Lock()
_print_lock      = threading.Lock()


def _is_importable(module: str) -> bool:
    try:
        __import__(module)
        return True
    except ImportError:
        return False


def _bootstrap_dependencies() -> None:
    missing = [pip for mod, pip in _REQUIRED.items() if not _is_importable(mod)]
    if not missing:
        return
    print(f"[*] Installing missing packages: {', '.join(missing)}")
    subprocess.check_call(
        [sys.executable, "-m", "pip", "install", "--quiet"] + missing
    )
    print("[+] Packages installed.\n")


_bootstrap_dependencies()

import requests
from colorama import init as _colorama_init, Fore, Style

_colorama_init(autoreset=True)

try:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, OSError, ValueError):
    pass

C: dict[str, str] = {
    "red":     Fore.RED,
    "green":   Fore.GREEN,
    "yellow":  Fore.YELLOW,
    "cyan":    Fore.CYAN,
    "magenta": Fore.MAGENTA,
    "white":   Fore.WHITE,
    "reset":   Style.RESET_ALL,
    "dim":     Style.DIM,
    "bright":  Style.BRIGHT,
}


# ─── Config ───────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class Config:
    api_base:        str = "https://reverse.plusparser.cloud"
    api_key:         str = "FlashKiss"

    thread_count:    int = 30
    request_timeout: int = 30
    output_folder:   str = "Results - IPORBIT"

    @property
    def lookup_url(self) -> str:
        return f"{self.api_base}/Marijuana"


CONFIG = Config()


# ─── Stats ────────────────────────────────────────────────────────────────────
@dataclass
class ScanStats:
    total_targets: int = 0
    processed:     int = 0
    total_found:   int = 0
    errors:        int = 0
    _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)

    def increment(self, key: str, amount: int = 1) -> None:
        if key not in ("processed", "total_found", "errors"):
            return
        with self._lock:
            setattr(self, key, getattr(self, key) + amount)


# ─── UI Helpers ───────────────────────────────────────────────────────────────
def clear_screen() -> None:
    os.system("cls" if os.name == "nt" else "clear")


def print_separator() -> None:
    print(f"\n  {C['cyan']}{C['dim']}{'-' * 60}{C['reset']}\n")


def _atomic_print(*lines: str) -> None:
    block = "\n".join(lines)
    with _print_lock:
        print(block, flush=True)


def success(msg: str) -> None:
    _atomic_print(f"  {C['green']}[+]{C['reset']} {msg}")


def warning(msg: str) -> None:
    _atomic_print(f"  {C['yellow']}[WARN]{C['reset']} {msg}")


def failure(msg: str) -> None:
    _atomic_print(f"  {C['red']}[FAIL]{C['reset']} {msg}")


def domain_row(value: str) -> str:
    return (
        f"  {C['magenta']}  |--{C['reset']} "
        f"{C['dim']}domain:{C['reset']} "
        f"{C['green']}{value}{C['reset']}"
    )


# ─── ASCII Banner ─────────────────────────────────────────────────────────────
_ASCII_LINES: list[str] = [
    "██╗██████╗  ██████╗ ██████╗ ██████╗ ██╗████████╗",
    "██║██╔══██╗██╔═══██╗██╔══██╗██╔══██╗██║╚══██╔══╝",
    "██║██████╔╝██║   ██║██████╔╝██████╔╝██║   ██║   ",
    "██║██╔═══╝ ██║   ██║██╔══██╗██╔══██╗██║   ██║   ",
    "██║██║     ╚██████╔╝██║  ██║██████╔╝██║   ██║   ",
    "╚═╝╚═╝      ╚═════╝ ╚═╝  ╚═╝╚═════╝ ╚═╝   ╚═╝   ",
]
_LABEL = "Reverse IP Lookup By PlusParser | @FlashKiss_1337"


def show_banner() -> None:
    clear_screen()
    box_w  = max(max(len(r) for r in _ASCII_LINES), len(_LABEL))
    border = f"+{'-' * (box_w + 4)}+"
    empty  = f"|{' ' * (box_w + 4)}|"
    bc     = C["cyan"] + C["bright"]
    rst    = C["reset"]

    print()
    for row in (border, empty):
        print(f"  {bc}{row}{rst}")

    for line in _ASCII_LINES:
        pad = box_w - len(line)
        lp  = pad // 2
        rp  = pad - lp
        print(f"  {bc}|  {' ' * lp}{line}{' ' * rp}  |{rst}")

    print(f"  {bc}{empty}{rst}")

    pad    = box_w - len(_LABEL)
    lp     = pad // 2
    rp     = pad - lp
    title  = "Reverse IP Lookup By PlusParser  "
    credit = "| @FlashKiss_1337"
    print(
        f"  {bc}|  {' ' * lp}"
        f"{C['white']}{title}"
        f"{C['cyan']}{C['bright']}{credit}"
        f"{rst}{bc}{' ' * rp}  |{rst}"
    )

    for row in (empty, border):
        print(f"  {bc}{row}{rst}")
    print()


def _prompt(question: str) -> str:
    return input(
        f"  {C['cyan']}[{C['white']}?{C['cyan']}]{C['reset']} {question}: "
    ).strip()


# ─── API Health Check ─────────────────────────────────────────────────────────
def check_api_health() -> bool:
    """Probe the Marijuana endpoint before starting the scan."""
    try:
        resp = requests.get(
            CONFIG.lookup_url,
            params={"key": CONFIG.api_key, "ip": "1.1.1.1"},
            timeout=15,
        )
        if resp.status_code == 200:
            success(
                f"API online — "
                f"{C['cyan']}reverse.plusparser.cloud{C['reset']}"
            )
            return True
        failure(f"API health check failed: HTTP {resp.status_code}")
        return False
    except Exception as exc:
        failure(f"API health check failed: {str(exc)[:80]}")
        return False


# ─── Target Loading ───────────────────────────────────────────────────────────
def load_targets(file_path: Optional[str] = None) -> list[str]:
    print_separator()
    success("Provide a .txt file with one IP address per line.")
    print()

    if file_path is None:
        raw       = _prompt("File path")
        file_path = raw.strip("'\"")

    path = Path(file_path)
    if not path.is_file():
        failure(f"File not found: {file_path}")
        sys.exit(1)

    targets = [
        line.strip()
        for line in path.read_text(encoding="utf-8", errors="ignore").splitlines()
        if line.strip()
    ]

    if not targets:
        failure("The file is empty — no targets to process.")
        sys.exit(1)

    success(f"Loaded {C['green']}{len(targets)}{C['reset']} target(s).")
    return targets


# ─── Response Parsing ─────────────────────────────────────────────────────────
def _extract_domains(data: Any) -> list[str]:
    """
    Parse Marijuana API response:
      {
        "Reverse Ip By @FlashKiss_1337": null,
        "Ip": "1.1.1.1",
        "Total Domains": 3,
        "Domains": ["a.com", "b.net", ...]
      }
    Also handles legacy lowercase shapes.
    """
    domains: list[str] = []

    if isinstance(data, dict):
        raw = (
            data.get("Domains")
            or data.get("domains")
            or data.get("domain_list")
            or data.get("results")
        )

        if isinstance(raw, list):
            for entry in raw:
                if isinstance(entry, str) and entry.strip():
                    domains.append(entry.strip())
                elif isinstance(entry, dict):
                    # batch sub-result: recurse once
                    domains.extend(_extract_domains(entry))

    elif isinstance(data, list):
        for entry in data:
            if isinstance(entry, str) and entry.strip():
                domains.append(entry.strip())
            elif isinstance(entry, dict):
                domains.extend(_extract_domains(entry))

    return domains


# ─── File Output ──────────────────────────────────────────────────────────────
def _append_to_file(path: Path, domains: list[str]) -> None:
    """Append domains only, one per line."""
    if not domains:
        return
    block = "\n".join(domains) + "\n"
    with _file_write_lock:
        with io.open(path, "a", encoding="utf-8", newline="\n") as fh:
            fh.write(block)
            fh.flush()
            try:
                os.fsync(fh.fileno())
            except OSError:
                pass


# ─── Core Lookup Worker ───────────────────────────────────────────────────────
def lookup_ip(
    ip: str,
    output_path: Path,
    stats: ScanStats,
    session: requests.Session,
) -> None:
    """
    Single-IP lookup against /Marijuana?key=X&ip=Y
    Uses a shared requests.Session for connection reuse (faster under threads).
    """
    ip = ip.strip()

    try:
        resp = session.get(
            CONFIG.lookup_url,
            params={"key": CONFIG.api_key, "ip": ip},
            timeout=CONFIG.request_timeout,
        )
    except requests.exceptions.Timeout:
        stats.increment("errors")
        failure(f"{ip} — request timed out")
        return
    except requests.exceptions.ConnectionError:
        stats.increment("errors")
        failure(f"{ip} — connection error")
        return
    except requests.exceptions.RequestException as exc:
        stats.increment("errors")
        failure(f"{ip} — {str(exc)[:80]}")
        return

    # ── Auth / server errors ──────────────────────────────────────────────────
    if resp.status_code == 401:
        stats.increment("errors")
        failure(f"{ip} — 401 Unauthorized (check API key)")
        return
    if resp.status_code == 503:
        stats.increment("errors")
        warning(f"{ip} — 503 API still starting up, skipping")
        return
    if resp.status_code != 200:
        stats.increment("errors")
        failure(f"{ip} — HTTP {resp.status_code}")
        return

    # ── Parse JSON ────────────────────────────────────────────────────────────
    try:
        data = resp.json()
    except ValueError:
        stats.increment("errors")
        failure(f"{ip} — invalid JSON response")
        return

    if isinstance(data, dict) and "error" in data:
        stats.increment("errors")
        failure(f"{ip} — {data.get('error', 'unknown error')}")
        return

    domains = _extract_domains(data)
    count   = len(domains)

    stats.increment("processed")
    stats.increment("total_found", count)

    if domains:
        lines: list[str] = [
            f"\n  {C['green']}[+]{C['reset']} "
            f"{C['cyan']}{ip}{C['reset']}  "
            f"{C['dim']}→{C['reset']}  "
            f"{C['green']}{C['bright']}{count} domain(s){C['reset']}"
        ]
        for d in domains:
            lines.append(domain_row(d))
        _atomic_print(*lines)
        _append_to_file(output_path, domains)
    else:
        warning(f"{ip} → 0 domains found")


# ─── Summary ──────────────────────────────────────────────────────────────────
def show_summary(output_path: Path, elapsed: float, stats: ScanStats) -> None:
    print_separator()
    bc  = C["cyan"] + C["bright"]
    rst = C["reset"]
    print(f"  {bc}  +===================================+{rst}")
    print(f"  {bc}  |         SCAN COMPLETE             |{rst}")
    print(f"  {bc}  +===================================+{rst}\n")

    rows: list[tuple[str, str, object]] = [
        ("Targets processed", C["green"],   stats.processed),
        ("Total domains",     C["green"],   stats.total_found),
        ("Errors",            C["red"],     stats.errors),
        ("Time elapsed",      C["yellow"],  f"{elapsed:.2f}s"),
        ("Threads used",      C["magenta"], CONFIG.thread_count),
        ("Results saved to",  C["cyan"],    str(output_path)),
    ]
    for label, clr, val in rows:
        print(f"  {C['white']}  {label:<20}: {clr}{val}{rst}")

    print_separator()


# ─── Main ─────────────────────────────────────────────────────────────────────
def main() -> None:
    show_banner()

    # Verify API is reachable before doing anything
    print_separator()
    if not check_api_health():
        failure("Cannot reach the API. Check your server and try again.")
        sys.exit(1)

    targets = load_targets()
    stats   = ScanStats(total_targets=len(targets))

    output_dir = Path(CONFIG.output_folder)
    output_dir.mkdir(parents=True, exist_ok=True)
    timestamp   = datetime.now().strftime("%d-%b-%Y_%I.%M%p").upper()
    output_path = output_dir / f"ReverseIP_Scan_{timestamp}.txt"

    print_separator()
    bc  = C["cyan"] + C["bright"]
    rst = C["reset"]
    print(f"  {bc}  +===================================+{rst}")
    print(f"  {bc}  |         STARTING SCAN             |{rst}")
    print(f"  {bc}  +===================================+{rst}\n")

    meta: list[tuple[str, str, object]] = [
        ("Mode",     C["green"],   "Reverse IP Lookup"),
        ("Endpoint", C["cyan"],    CONFIG.lookup_url),
        ("Targets",  C["green"],   len(targets)),
        ("Threads",  C["magenta"], CONFIG.thread_count),
        ("API Key",  C["green"],   "FlashKiss ✓"),
        ("Output",   C["cyan"],    str(output_path)),
    ]
    for label, clr, val in meta:
        print(f"  {C['white']}  {label:<10}: {clr}{val}{rst}")

    print_separator()

    start = time.perf_counter()

    # Shared session = connection pooling → much faster under 30 threads
    session = requests.Session()
    session.headers.update({
        "User-Agent": "IPORBIT/3.0 (@FlashKiss_1337)",
        "Accept":     "application/json",
    })

    with ThreadPoolExecutor(max_workers=CONFIG.thread_count) as pool:
        futures = {
            pool.submit(lookup_ip, ip, output_path, stats, session): ip
            for ip in targets
        }
        for future in as_completed(futures):
            try:
                future.result()
            except Exception as exc:
                stats.increment("errors")
                failure(f"Thread error: {str(exc)[:80]}")

    session.close()
    show_summary(output_path, time.perf_counter() - start, stats)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print(f"\n\n  {C['red']}[!] Interrupted by user.{C['reset']}\n")
        sys.exit(0)