"""
Insider Web Mapper
==================
Map the complete network of insider-connected wallets across token projects.
Reveals shared funding sources, coordinated trading rings, and team-to-team
relationships — all using free-tier data sources.

TOOL  : insider_network
TIER  : premium / intelligence
PRICE : $0.10 (100000 atoms)
TRIAL : 1 free check

Data Sources (all free):
- DexScreener — token pairs and holders
- Solscan (public) — token holder lists
- Etherscan/BscScan (public free tier) — EVM token holders
- Birdeye public — holder data
- Public RPCs — on-chain queries
"""

import asyncio
import json
import logging
import re
import time
from collections import defaultdict
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any

import httpx

logger = logging.getLogger(__name__)

# ── Constants ────────────────────────────────────────────────────

FREE_APIS = {
    "dexscreener": "https://api.dexscreener.com/latest/dex",
    "birdeye_public": "https://public-api.birdeye.so",
    "jupiter": "https://api.jup.ag",
}

CHAIN_NAMES: dict[str, str] = {
    "solana": "Solana",
    "ethereum": "Ethereum",
    "bsc": "BSC",
    "polygon": "Polygon",
    "arbitrum": "Arbitrum",
    "optimism": "Optimism",
    "base": "Base",
}

ETHERSCAN_BASES: dict[str, str] = {
    "ethereum": "https://api.etherscan.io/api",
    "bsc": "https://api.bscscan.com/api",
    "polygon": "https://api.polygonscan.com/api",
    "arbitrum": "https://api.arbiscan.io/api",
    "optimism": "https://api-optimistic.etherscan.io/api",
    "base": "https://api.basescan.org/api",
}

SOLSCAN_BASE = "https://api.solscan.io"

ADDRESS_PATTERN_EVM = re.compile(r"^0x[a-fA-F0-9]{40}$")
ADDRESS_PATTERN_SOLANA = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")

# Cache for HTTP responses
_cache: dict[str, tuple[float, Any]] = {}
_CACHE_TTL = 120  # 2 minutes
_MAX_STALE_TTL = 600  # 10 minutes — refuse stale cache older than this


# ── Data Models ───────────────────────────────────────────────────


@dataclass
class InsiderLink:
    """A connection between two wallets/entities indicating insider relationship."""

    source_wallet: str
    target_wallet: str
    relationship_type: str  # "shared_funding", "co_trading", "co_deploy", "value_transfer"
    strength: float = 0.0  # 0.0 - 1.0 confidence
    evidence: list[str] = field(default_factory=list)
    first_seen: str = ""
    last_seen: str = ""


@dataclass
class InsiderCluster:
    """A cluster of wallets that form an insider network."""

    cluster_id: str
    wallets: list[str] = field(default_factory=list)
    projects_involved: list[str] = field(default_factory=list)
    total_value_usd: float = 0.0
    risk_score: float = 0.0  # 0-100
    risk_factors: list[str] = field(default_factory=list)
    member_count: int = 0
    links: list[InsiderLink] = field(default_factory=list)
    top_relationships: list[dict] = field(default_factory=list)


@dataclass
class InsiderNetworkReport:
    """Complete insider network report."""

    target_wallet: str
    target_label: str = ""
    clusters: list[InsiderCluster] = field(default_factory=list)
    total_connected_wallets: int = 0
    total_clusters: int = 0
    highest_risk_score: float = 0.0
    summary: str = ""


# ── Address Validation ───────────────────────────────────────────


def _validate_eip55_checksum(address: str) -> bool:
    """
    Validate EIP-55 mixed-case checksum for Ethereum addresses.
    Implements the exact EIP-55 algorithm using keccak256 hashing.
    """
    if not address.startswith("0x") or len(address) != 42:
        return False

    try:
        # EIP-55: lowercase address, hash with keccak256
        addr_lower = address[2:].lower().encode("ascii")

        # Try to use eth_hash if available (pysha3)
        try:
            from eth_hash.auto import keccak

            hashed = keccak(addr_lower).hex()
        except ImportError:
            # Fallback: accept mixed-case without strict EIP-55
            return True

        # EIP-55 checksum validation
        for i, ch in enumerate(address[2:]):
            if ch.isalpha():
                should_be_upper = int(hashed[i], 16) >= 8
                if should_be_upper and ch.islower():
                    return False
                if not should_be_upper and ch.isupper():
                    return False
        return True
    except Exception:
        # If keccak unavailable, accept basic hex format
        return bool(ADDRESS_PATTERN_EVM.match(address))


def is_valid_address(address: str) -> bool:
    """Validate Solana (base58) or EVM (0x-hex) address with checksum validation."""
    if not address or not isinstance(address, str):
        return False
    addr = address.strip()

    # EVM address validation
    if ADDRESS_PATTERN_EVM.match(addr):
        # EIP-55 checksum validation
        if addr == addr.lower() or addr == addr.upper():
            return True  # All-lower or all-upper passes basic validation
        return _validate_eip55_checksum(addr)

    # Solana address validation (base58)
    return bool(ADDRESS_PATTERN_SOLANA.match(addr))


def _truncate_address(addr: str) -> str:
    """Truncate address for display."""
    if len(addr) <= 12:
        return addr
    return f"{addr[:6]}...{addr[-4:]}"


# ── HTTP Helpers with Caching ────────────────────────────────────


async def _cached_get(
    client: httpx.AsyncClient,
    url: str,
    params: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
    cache_ttl: int = _CACHE_TTL,
) -> dict[str, Any] | None:
    """GET with simple in-memory caching and bounded stale-return."""
    cache_key = f"{url}?{json.dumps(params or {}, sort_keys=True)}"
    now = time.time()

    if cache_key in _cache:
        cached_at, cached_data = _cache[cache_key]
        if now - cached_at < cache_ttl:
            return cached_data

    try:
        resp = await client.get(url, params=params, headers=headers, timeout=15)
        if resp.status_code == 200:
            data = resp.json()
            _cache[cache_key] = (now, data)
            return data
        elif resp.status_code == 429:
            # Rate limited — return stale cache if fresh enough
            if cache_key in _cache:
                cached_at, cached_data = _cache[cache_key]
                if now - cached_at < _MAX_STALE_TTL:
                    return cached_data
            return None
        return None
    except (httpx.TimeoutException, httpx.RequestError):
        if cache_key in _cache:
            cached_at, cached_data = _cache[cache_key]
            if now - cached_at < _MAX_STALE_TTL:
                return cached_data
        return None


# ── API Data Fetching ────────────────────────────────────────────


async def _fetch_dexscreener_pairs(client: httpx.AsyncClient, token_address: str) -> list[dict]:
    """Fetch trading pairs for a token from DexScreener."""
    url = f"{FREE_APIS['dexscreener']}/tokens/{token_address}"
    data = await _cached_get(client, url)
    if data and "pairs" in data:
        return data["pairs"]
    return []


async def _fetch_etherscan_holders(
    client: httpx.AsyncClient,
    token_address: str,
    chain: str,
    api_key: str | None = None,
    limit: int = 500,
) -> list[dict]:
    """Fetch top token holders from Etherscan-like explorer."""
    base = ETHERSCAN_BASES.get(chain)
    if not base:
        return []

    params: dict[str, Any] = {
        "module": "token",
        "action": "tokenholderlist",
        "contractaddress": token_address,
        "limit": min(limit, 500),
    }
    if api_key:
        params["apikey"] = api_key

    data = await _cached_get(client, base, params=params)
    if data and data.get("status") == "1" and "result" in data:
        result = data["result"]
        if isinstance(result, list):
            return result
    return []


async def _fetch_solscan_holders(client: httpx.AsyncClient, token_address: str, limit: int = 100) -> list[dict]:
    """Fetch top token holders from Solscan (free public API)."""
    params = {
        "tokenAddress": token_address,
        "limit": str(min(limit, 100)),
        "offset": "0",
    }
    headers = {"Accept": "application/json"}
    url = f"{SOLSCAN_BASE}/token/holders"
    data = await _cached_get(client, url, params=params, headers=headers)
    if data and "data" in data:
        result = data["data"]
        if isinstance(result, list):
            return result
    return []


async def _fetch_birdeye_holders(client: httpx.AsyncClient, token_address: str, chain: str = "solana") -> list[dict]:
    """Fetch holder data from Birdeye public API."""
    chain_map: dict[str, str] = {
        "solana": "solana",
        "ethereum": "ethereum",
        "bsc": "bsc",
        "polygon": "polygon",
        "arbitrum": "arbitrum",
        "base": "base",
    }
    c = chain_map.get(chain, "solana")
    url = f"{FREE_APIS['birdeye_public']}/public/defi/holderlist"
    params = {"address": token_address, "chain": c, "limit": 100}
    headers = {"Accept": "application/json"}
    data = await _cached_get(client, url, params=params, headers=headers)
    if data and "data" in data and "items" in data["data"]:
        return data["data"]["items"]
    return []


async def _fetch_token_transfers(
    client: httpx.AsyncClient,
    token_address: str,
    chain: str,
    limit: int = 100,
) -> list[dict]:
    """Fetch recent token transfers from explorer APIs."""
    base = ETHERSCAN_BASES.get(chain)
    if not base:
        return []

    params = {
        "module": "account",
        "action": "tokentx",
        "contractaddress": token_address,
        "sort": "desc",
        "limit": str(min(limit, 100)),
    }
    data = await _cached_get(client, base, params=params)
    if data and data.get("status") == "1" and "result" in data:
        result = data["result"]
        if isinstance(result, list):
            return result
    return []


async def _fetch_wallet_transactions(
    client: httpx.AsyncClient, wallet_address: str, chain: str, limit: int = 50
) -> list[dict]:
    """Fetch normal transactions for a wallet address."""
    base = ETHERSCAN_BASES.get(chain)
    if not base:
        return []

    params = {
        "module": "account",
        "action": "txlist",
        "address": wallet_address,
        "sort": "desc",
        "limit": str(min(limit, 50)),
    }
    data = await _cached_get(client, base, params=params)
    if data and data.get("status") == "1" and "result" in data:
        result = data["result"]
        if isinstance(result, list):
            return result
    return []


# ── Core Analysis Logic ──────────────────────────────────────────


def _detect_shared_funding(
    cluster_wallets: list[str],
    wallet_transactions: dict[str, list[dict]],
) -> list[InsiderLink]:
    """Detect wallets that share common funding sources."""
    links: list[InsiderLink] = []
    funders: dict[str, set[str]] = defaultdict(set)

    for wallet in cluster_wallets:
        txs = wallet_transactions.get(wallet, [])
        for tx in txs[:10]:
            sender = tx.get("from", "").lower()
            if sender and sender != wallet.lower():
                funders[sender].add(wallet)

    for funder, wallets_set in funders.items():
        wallet_list = list(wallets_set)
        if len(wallet_list) >= 2:
            for i in range(len(wallet_list)):
                for j in range(i + 1, len(wallet_list)):
                    strength = min(1.0, 0.3 + 0.1 * len(wallet_list))
                    link = InsiderLink(
                        source_wallet=wallet_list[i],
                        target_wallet=wallet_list[j],
                        relationship_type="shared_funding",
                        strength=strength,
                        evidence=[f"Both funded by {_truncate_address(funder)}"],
                    )
                    links.append(link)
    return links


def _detect_co_trading(
    cluster_wallets: list[str],
    token_transfers: dict[str, list[dict]],
) -> list[InsiderLink]:
    """Detect wallets that trade the same tokens at similar times."""
    links: list[InsiderLink] = []
    token_wallets: dict[str, set[str]] = defaultdict(set)

    for wallet in cluster_wallets:
        transfers = token_transfers.get(wallet, [])
        for tx in transfers:
            token = tx.get("contractAddress", "").lower()
            if token:
                token_wallets[token].add(wallet.lower())

    for token, wallets_set in token_wallets.items():
        wallet_list = list(wallets_set)
        if len(wallet_list) >= 2:
            for i in range(len(wallet_list)):
                for j in range(i + 1, len(wallet_list)):
                    link = InsiderLink(
                        source_wallet=wallet_list[i],
                        target_wallet=wallet_list[j],
                        relationship_type="co_trading",
                        strength=0.4,
                        evidence=[f"Both traded token {_truncate_address(token)}"],
                    )
                    existing = [
                        lnk
                        for lnk in links
                        if lnk.source_wallet == link.source_wallet and lnk.target_wallet == link.target_wallet
                    ]
                    if existing:
                        existing[0].strength = min(1.0, existing[0].strength + 0.1)
                        existing[0].evidence.append(f"Also traded {_truncate_address(token)}")
                    else:
                        links.append(link)
    return links


def _detect_value_transfers(
    cluster_wallets: list[str],
    wallet_transactions: dict[str, list[dict]],
) -> list[InsiderLink]:
    """Detect direct value transfers between wallets in the cluster."""
    links: list[InsiderLink] = []
    wallet_set = {w.lower() for w in cluster_wallets}

    for wallet in cluster_wallets:
        txs = wallet_transactions.get(wallet, [])
        for tx in txs[:30]:
            from_addr = tx.get("from", "").lower()
            to_addr = tx.get("to", "").lower()
            value = float(tx.get("value", 0))

            if from_addr in wallet_set and to_addr in wallet_set and from_addr != to_addr:
                link = InsiderLink(
                    source_wallet=from_addr,
                    target_wallet=to_addr,
                    relationship_type="value_transfer",
                    strength=min(1.0, 0.5 + 0.3 * min(value / 10**18, 1.0)),
                    evidence=[f"Direct transfer of {value / 10**18:.4f} native token"],
                )
                links.append(link)
    return links


def _detect_co_deployer(
    cluster_wallets: list[str],
    wallet_transactions: dict[str, list[dict]],
) -> list[InsiderLink]:
    """Detect wallets that were funded by the same deployer."""
    links: list[InsiderLink] = []
    deployers: dict[str, set[str]] = defaultdict(set)

    for wallet in cluster_wallets:
        txs = wallet_transactions.get(wallet, [])
        if txs:
            first_tx = txs[-1] if len(txs) > 5 else txs[0]
            deployer = first_tx.get("from", "").lower()
            if deployer and deployer != wallet.lower():
                deployers[deployer].add(wallet)

    for deployer, wallets_set in deployers.items():
        wallet_list = list(wallets_set)
        if len(wallet_list) >= 2:
            for i in range(len(wallet_list)):
                for j in range(i + 1, len(wallet_list)):
                    link = InsiderLink(
                        source_wallet=wallet_list[i],
                        target_wallet=wallet_list[j],
                        relationship_type="co_deploy",
                        strength=0.6,
                        evidence=[f"Both originated from deployer {_truncate_address(deployer)}"],
                    )
                    links.append(link)
    return links


def _compute_cluster_risk(cluster: InsiderCluster, links: list[InsiderLink]) -> float:
    """Compute risk score (0-100) for a cluster."""
    if not links or len(cluster.wallets) < 2:
        return 0.0

    risk = 0.0
    n_wallets = len(cluster.wallets)

    # Density: more links = higher coordination risk
    max_possible = n_wallets * (n_wallets - 1) / 2
    density = len(links) / max_possible if max_possible > 0 else 0
    risk += density * 30

    # Relationship type weighting
    type_counts = defaultdict(int)
    for link in links:
        type_counts[link.relationship_type] += 1

    if type_counts.get("co_deploy", 0) >= 2:
        risk += 20
    if type_counts.get("value_transfer", 0) >= 3:
        risk += 15
    if type_counts.get("co_trading", 0) >= 5:
        risk += 15
    if n_wallets >= 10:
        risk += 10
    elif n_wallets >= 5:
        risk += 5

    strong_links = sum(1 for link in links if link.strength >= 0.7)
    if strong_links >= 3:
        risk += 10

    return min(100.0, risk)


def _build_summary(report: InsiderNetworkReport) -> str:
    """Generate a human-readable summary."""
    if not report.clusters:
        return (
            f"No insider networks detected for wallet "
            f"{_truncate_address(report.target_wallet)}. "
            f"This wallet appears to operate independently."
        )

    top_cluster = max(report.clusters, key=lambda c: c.risk_score, default=None)
    parts = [
        f"\U0001f575\ufe0f **Insider Network Report for {_truncate_address(report.target_wallet)}**\n",
        f"\u2022 **Clusters found:** {report.total_clusters}",
        f"\u2022 **Connected wallets:** {report.total_connected_wallets}",
        f"\u2022 **Highest risk score:** {report.highest_risk_score:.0f}/100",
    ]

    if top_cluster and top_cluster.risk_score >= 50:
        parts.append(
            f"\n\u26a0\ufe0f **HIGH RISK** \u2014 Insider network detected! "
            f"{top_cluster.member_count} wallets across "
            f"{len(top_cluster.projects_involved)} projects."
        )

    for i, cluster in enumerate(report.clusters[:3]):
        parts.append(f"\n**Cluster {i + 1}** (Risk: {cluster.risk_score:.0f}/100):")
        parts.append(f"\u2022 {cluster.member_count} wallets")
        if cluster.projects_involved:
            parts.append(f"\u2022 Projects: {', '.join(cluster.projects_involved[:5])}")
        if cluster.risk_factors:
            parts.append(f"\u2022 Risks: {', '.join(cluster.risk_factors[:3])}")

    return "\n".join(parts)


# ── Public API ────────────────────────────────────────────────────


async def analyze_insider_network(
    wallet_address: str,
    chains: list[str] | None = None,
    max_wallets_per_chain: int = 50,
    deep_scan: bool = False,
) -> InsiderNetworkReport:
    """
    Analyze a wallet's insider connections across token projects.

    Args:
        wallet_address: The wallet address to investigate.
        chains: Chains to search (default: all supported).
        max_wallets_per_chain: Max wallet addresses to fetch per chain.
        deep_scan: If True, do deeper analysis (more API calls, slower).

    Returns:
        InsiderNetworkReport with clusters, risk scores, and summary.
    """
    addr = wallet_address.strip()
    if not is_valid_address(addr):
        raise ValueError(f"Invalid wallet address: {addr}")

    if chains is None:
        chains = ["solana", "ethereum", "bsc", "base"]

    report = InsiderNetworkReport(target_wallet=addr)

    async with httpx.AsyncClient(timeout=20) as client:
        wallet_transactions: dict[str, list[dict]] = {}
        token_transfers: dict[str, list[dict]] = {}
        defaultdict(set)

        for chain in chains:
            txs = await _fetch_wallet_transactions(client, addr, chain, limit=50)
            if txs:
                wallet_transactions[addr] = txs

            base = ETHERSCAN_BASES.get(chain)
            if base:
                transfer_params = {
                    "module": "account",
                    "action": "tokentx",
                    "address": addr,
                    "sort": "desc",
                    "limit": 100,
                }
                token_data = await _cached_get(client, base, params=transfer_params)
                if token_data and token_data.get("status") == "1":
                    result = token_data.get("result", [])
                    if isinstance(result, list):
                        token_transfers[addr] = result[:100]

                int_tx_params = {
                    "module": "account",
                    "action": "txlistinternal",
                    "address": addr,
                    "sort": "desc",
                    "limit": 50,
                }
                with suppress(Exception):
                    int_data = await _cached_get(client, base, params=int_tx_params)
                    if int_data and int_data.get("status") == "1":
                        result = int_data.get("result", [])
                        if isinstance(result, list):
                            if addr not in wallet_transactions:
                                wallet_transactions[addr] = []
                            wallet_transactions[addr].extend(result[:50])

        # Step 2: Discover related wallets from transactions
        related_wallets: set[str] = set()
        txs = wallet_transactions.get(addr, [])
        for tx in txs:
            from_addr = tx.get("from", "").lower().strip()
            to_addr = tx.get("to", "").lower().strip()
            if from_addr and from_addr != addr.lower():
                related_wallets.add(from_addr)
            if to_addr and to_addr != addr.lower():
                related_wallets.add(to_addr)

        # Step 3: Also discover wallets from token transfers
        transfers = token_transfers.get(addr, [])
        for tx in transfers:
            fr = tx.get("from", "").lower().strip()
            to = tx.get("to", "").lower().strip()
            if fr and fr != addr.lower() and is_valid_address(fr):
                related_wallets.add(fr)
            if to and to != addr.lower() and is_valid_address(to):
                related_wallets.add(to)

        # Step 4: For each related wallet, fetch their transactions too (deep scan)
        if deep_scan:
            related_list = list(related_wallets)[:max_wallets_per_chain]
            batch_size = 5
            for i in range(0, len(related_list), batch_size):
                batch = related_list[i : i + batch_size]
                tasks = []
                for rw in batch:
                    for chain in chains:
                        tasks.append(_fetch_wallet_transactions(client, rw, chain, limit=20))
                results = await asyncio.gather(*tasks, return_exceptions=True)
                idx = 0
                for rw in batch:
                    for _ in chains:
                        res = results[idx] if idx < len(results) else None
                        if isinstance(res, list) and res:
                            wallet_transactions[rw] = res
                        idx += 1
                    await asyncio.sleep(0.1)

        # Step 5: Detect relationships
        all_wallets = {addr.lower()} | {w.lower() for w in related_wallets}
        wallet_list = list(all_wallets)

        links: list[InsiderLink] = []
        links.extend(_detect_shared_funding(wallet_list, wallet_transactions))
        links.extend(_detect_co_trading(wallet_list, token_transfers))
        links.extend(_detect_value_transfers(wallet_list, wallet_transactions))
        links.extend(_detect_co_deployer(wallet_list, wallet_transactions))

        # Step 6: Cluster wallets based on links (connected components)
        if links:
            adj: dict[str, set[str]] = defaultdict(set)
            for link in links:
                adj[link.source_wallet].add(link.target_wallet)
                adj[link.target_wallet].add(link.source_wallet)

            visited: set[str] = set()
            clusters_raw: list[set[str]] = []

            for w in adj:
                if w in visited:
                    continue
                cluster_set: set[str] = set()
                queue = [w]
                while queue:
                    node = queue.pop(0)
                    if node in visited:
                        continue
                    visited.add(node)
                    cluster_set.add(node)
                    for neighbor in adj.get(node, set()):
                        if neighbor not in visited:
                            queue.append(neighbor)
                if len(cluster_set) >= 2:
                    clusters_raw.append(cluster_set)

            for i, cluster_set in enumerate(clusters_raw):
                cluster_wallets = list(cluster_set)
                cluster_links = [
                    lnk for lnk in links if lnk.source_wallet in cluster_set or lnk.target_wallet in cluster_set
                ]

                projects: set[str] = set()
                for cw in cluster_wallets:
                    cw_transfers = token_transfers.get(cw, [])
                    for tx in cw_transfers[:20]:
                        token = tx.get("contractAddress", "")
                        name = tx.get("tokenName", "") or tx.get("tokenSymbol", "")
                        if name:
                            projects.add(name)
                        elif token:
                            projects.add(_truncate_address(token))

                cluster = InsiderCluster(
                    cluster_id=f"INSIDER-{i + 1:04d}",
                    wallets=cluster_wallets,
                    member_count=len(cluster_wallets),
                    links=cluster_links,
                    projects_involved=list(projects)[:10],
                )

                cluster.risk_score = _compute_cluster_risk(cluster, cluster_links)
                if cluster.risk_score >= 50:
                    cluster.risk_factors.append("High coordination density")
                if any(lnk.relationship_type == "co_deploy" for lnk in cluster_links):
                    cluster.risk_factors.append("Shared deployer origin")
                if any(lnk.relationship_type == "value_transfer" for lnk in cluster_links):
                    cluster.risk_factors.append("Direct value transfers between members")
                if len(cluster_links) > 10:
                    cluster.risk_factors.append("Extensive relationship network")

                sorted_links = sorted(cluster_links, key=lambda x: x.strength, reverse=True)[:5]
                cluster.top_relationships = [
                    {
                        "from": _truncate_address(lnk.source_wallet),
                        "to": _truncate_address(lnk.target_wallet),
                        "type": lnk.relationship_type,
                        "strength": lnk.strength,
                    }
                    for lnk in sorted_links
                ]

                report.clusters.append(cluster)

        # Step 7: Compile report
        report.total_connected_wallets = len(related_wallets)
        report.total_clusters = len(report.clusters)
        if report.clusters:
            report.highest_risk_score = max(c.risk_score for c in report.clusters)

        report.summary = _build_summary(report)

    return report


# ── Formatting ────────────────────────────────────────────────────


def format_insider_network_report(report: InsiderNetworkReport) -> str:
    """Format insider network report as a human-readable string."""
    lines = [
        "=" * 60,
        "INSIDER NETWORK ANALYSIS REPORT",
        "=" * 60,
        f"Target Wallet : {report.target_wallet}",
        f"Label         : {report.target_label or 'Unknown'}",
        f"Connected Wallets : {report.total_connected_wallets}",
        f"Clusters Found    : {report.total_clusters}",
        f"Highest Risk      : {report.highest_risk_score:.0f}/100",
        "",
    ]

    if not report.clusters:
        lines.append("\u2705 No insider networks detected. Wallet appears independent.")
        return "\n".join(lines)

    for r_idx, cluster in enumerate(report.clusters):
        lines.extend(
            [
                f"{'─' * 60}",
                f"Cluster {r_idx + 1}: {cluster.cluster_id}",
                f"{'─' * 60}",
                f"  Members          : {cluster.member_count}",
                f"  Risk Score        : {cluster.risk_score:.0f}/100",
                f"  Link Count        : {len(cluster.links)}",
            ]
        )

        if cluster.projects_involved:
            lines.append(f"  Projects Involved : {', '.join(cluster.projects_involved[:5])}")

        if cluster.risk_factors:
            lines.append("  Risk Factors      :")
            for rf in cluster.risk_factors[:3]:
                lines.append(f"    \u26a0\ufe0f {rf}")

        if cluster.top_relationships:
            lines.append("  Top Relationships :")
            for rel in cluster.top_relationships[:3]:
                lines.append(f"    {rel['from']} \u2194 {rel['to']}  [{rel['type']}, strength={rel['strength']:.1f}]")

        lines.append("")

    lines.append(report.summary)
    return "\n".join(lines)


# ── Singleton Holder ──────────────────────────────────────────────

_insider_network_instance = None


def get_insider_network_analyzer():
    """Get or create the singleton InsiderNetworkAnalyzer."""
    global _insider_network_instance
    if _insider_network_instance is None:
        _insider_network_instance = InsiderNetworkAnalyzer()
    return _insider_network_instance


class InsiderNetworkAnalyzer:
    """Wrapper class for InsiderNetwork analysis (backward-compat)."""

    async def analyze(
        self,
        wallet_address: str,
        chains: list[str] | None = None,
        max_wallets_per_chain: int = 50,
        deep_scan: bool = False,
    ) -> InsiderNetworkReport:
        return await analyze_insider_network(
            wallet_address=wallet_address,
            chains=chains,
            max_wallets_per_chain=max_wallets_per_chain,
            deep_scan=deep_scan,
        )

    @staticmethod
    def format_report(report: InsiderNetworkReport) -> str:
        return format_insider_network_report(report)
