#!/usr/bin/env python3
"""
Nostr Relay Discovery Tool

This tool discovers Nostr relays by performing breadth-first search through
follow lists (kind 3 events) and analyzing relay tags to build a network map.
"""

import asyncio
import json
import logging
import re
import time
import argparse
import secrets
import base64
from collections import deque
from dataclasses import dataclass, field
from typing import Set, List, Dict, Optional, Tuple
from urllib.parse import urlparse
import websockets
import websockets.exceptions


# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

# Save progress every N relays processed
SAVE_POINT = 10


@dataclass
class RelayDiscoveryStats:
    """Statistics for the relay discovery process"""
    total_relays_found: int = 0
    functioning_relays: int = 0
    events_processed: int = 0
    existing_relays_verified: int = 0
    existing_relays_failed: int = 0
    start_time: float = field(default_factory=time.time)
    
    def print_stats(self):
        """Print current statistics"""
        elapsed = time.time() - self.start_time
        print(f"\n=== Discovery Statistics ===")
        print(f"Elapsed time: {elapsed:.2f} seconds")
        print(f"Total relays found: {self.total_relays_found}")
        print(f"Functioning relays: {self.functioning_relays}")
        print(f"Events processed: {self.events_processed}")
        if self.existing_relays_verified > 0 or self.existing_relays_failed > 0:
            print(f"Existing relays verified: {self.existing_relays_verified}")
            print(f"Existing relays failed verification: {self.existing_relays_failed}")
        print(f"Success rate: {(self.functioning_relays/max(1, self.total_relays_found)*100):.1f}%")


class NostrRelayDiscovery:
    """Nostr relay discovery tool using breadth-first search through follow lists"""
    
    def __init__(self, initial_relay: str, max_depth: int = 3, connection_timeout: int = 5, output_file: str = "relay_discovery_results.json", save_point: int = SAVE_POINT, batch_size: int = 10):
        self.initial_relay = initial_relay
        self.max_depth = max_depth
        self.connection_timeout = connection_timeout
        self.output_file = output_file
        self.save_point = save_point
        self.batch_size = batch_size
        
        # Discovery state
        self.to_visit: deque = deque()  # (relay_url, depth) - will be populated by load_existing_results
        self.to_visit_set: Set[str] = set()
        self.visited_relays: Set[str] = set()
        self.functioning_relays: Set[str] = set()
        
        # Statistics
        self.stats = RelayDiscoveryStats()
        
        # Regex for validating relay URLs
        self.relay_url_pattern = re.compile(
            r'^wss?://[a-zA-Z0-9.-]+(?:\:[0-9]+)?(?:/[a-zA-Z0-9._~:/?#[\]@!$&\'()*+,;=-]*)?$'
        )
    
    def is_valid_relay_url(self, url: str) -> bool:
        """Validate if a URL looks like a valid relay URL"""
        if not url or not isinstance(url, str):
            return False
        
        # Clean up the URL
        url = url.strip()
        
        # Check basic format
        if not self.relay_url_pattern.match(url):
            return False
        
        # Parse and validate
        try:
            parsed = urlparse(url)
            return (
                parsed.scheme in ['ws', 'wss'] and
                parsed.netloc and
                len(parsed.netloc) > 0
            )
        except Exception:
            return False
    
    def normalize_relay_url(self, url: str) -> str:
        """Normalize relay URL (remove trailing slashes, etc.)"""
        url = url.strip().lower()
        match = re.match(r'^(wss://[^/]+)/?$', url)

        if match:
            matched_url = match.group(1)
            logger.debug(f"matched url: {matched_url}")
            return matched_url
        else:
            return ''
    
    def generate_subscription_id(self) -> str:
        """Generate a random subscription ID using 16 random bytes encoded as base64"""
        random_bytes = secrets.token_bytes(16)
        return base64.b64encode(random_bytes).decode()
    
    async def test_relay_connection(self, relay_url: str) -> bool:
        """Test if a relay is functioning by attempting to connect and validate Nostr protocol responses"""
        try:
            logger.debug(f"Testing connection to {relay_url}")
            
            async with websockets.connect(
                relay_url,
                open_timeout=self.connection_timeout,
                close_timeout=5,
                max_size=2**20,  # 1MB max message size
                ping_interval=None  # Disable ping
            ) as websocket:
                # Send a simple REQ to test functionality
                subscription_id = self.generate_subscription_id()
                test_filter = {
                    "kinds": [1],
                    "limit": 1
                }
                req_msg = ["REQ", subscription_id, test_filter]
                await websocket.send(json.dumps(req_msg))
                logger.debug(f"Sent REQ with subscription ID: {subscription_id}")
                
                # Wait for a response and validate it's a proper Nostr protocol message
                try:
                    response = await asyncio.wait_for(websocket.recv(), timeout=5.0)
                    logger.debug(f"Received response: {response[:200]}...")  # Log first 200 chars
                    
                    try:
                        data = json.loads(response)
                        
                        # Check if it's a valid Nostr protocol message
                        if not isinstance(data, list) or len(data) < 2:
                            logger.debug(f"Invalid Nostr message format from {relay_url}: not a list or too short")
                            return False
                        
                        message_type = data[0]
                        message_subscription_id = data[1]
                        
                        # Validate subscription ID matches
                        if message_subscription_id != subscription_id:
                            logger.debug(f"Subscription ID mismatch from {relay_url}: expected {subscription_id}, got {message_subscription_id}")
                            return False
                        
                        # Check for valid Nostr protocol message types
                        if message_type == "EVENT":
                            logger.debug(f"✓ Received EVENT from {relay_url}")
                            # Send CLOSE to clean up
                            close_msg = ["CLOSE", subscription_id]
                            await websocket.send(json.dumps(close_msg))
                            return True
                        elif message_type == "EOSE":
                            logger.warning(f"Received EOSE immediately from {relay_url} - relay has no events (unusual)")
                            # Send CLOSE to clean up
                            close_msg = ["CLOSE", subscription_id]
                            await websocket.send(json.dumps(close_msg))
                            return True
                        elif message_type == "NOTICE":
                            notice_message = data[2] if len(data) > 2 else "unknown"
                            logger.debug(f"Received NOTICE from {relay_url}: {notice_message}")
                            return False
                        else:
                            logger.debug(f"Unexpected message type from {relay_url}: {message_type}")
                            return False
                            
                    except json.JSONDecodeError as e:
                        logger.debug(f"Invalid JSON response from {relay_url}: {e}")
                        return False
                        
                except asyncio.TimeoutError:
                    logger.debug(f"Timeout waiting for response from {relay_url}")
                    return False
                    
        except Exception as e:
            logger.debug(f"Failed to connect to {relay_url}: {e}")
            return False
    
    async def test_relays_connections(self, relay_urls: List[str]) -> Dict[str, bool]:
        """Test multiple relays concurrently and return a dict of relay_url -> functioning status"""
        if not relay_urls:
            return {}
        
        logger.info(f"Testing {len(relay_urls)} relays concurrently")
        
        # Create tasks for concurrent testing
        tasks = []
        for relay_url in relay_urls:
            task = asyncio.create_task(self.test_relay_connection(relay_url))
            tasks.append((relay_url, task))
        
        # Wait for all tasks to complete
        results = {}
        for relay_url, task in tasks:
            try:
                is_functioning = await task
                results[relay_url] = is_functioning
                if is_functioning:
                    logger.info(f"✓ Relay {relay_url} is functioning")
                else:
                    logger.warning(f"✗ Relay {relay_url} is not functioning")
            except Exception as e:
                logger.error(f"Error testing relay {relay_url}: {e}")
                results[relay_url] = False
        
        functioning_count = sum(1 for status in results.values() if status)
        logger.info(f"Batch test completed: {functioning_count}/{len(relay_urls)} relays functioning")
        
        return results
    
    async def fetch_events(self, relay_url: str) -> List[Dict]:
        """Fetch kind 3 events (follow lists) or kind 10002 (NIP-66) from a relay"""
        follow_events = []
        
        try:
            logger.info(f"Fetching follow lists from {relay_url}")
            
            async with websockets.connect(
                relay_url,
                open_timeout=self.connection_timeout,
                close_timeout=5,
                max_size=2**20
            ) as websocket:
                
                filter_req = {
                    "kinds": [3, 10002],
                    "limit": 300,
                }
                
                subscription_id = self.generate_subscription_id()
                req_msg = ["REQ", subscription_id, filter_req]
                
                await websocket.send(json.dumps(req_msg))
                logger.debug(f"Sent request: {req_msg}")
                
                # Collect events until EOSE
                start_time = time.time()
                timeout_duration = 30.0
                
                while time.time() - start_time < timeout_duration:
                    try:
                        message = await asyncio.wait_for(websocket.recv(), timeout=5.0)
                        data = json.loads(message)
                        
                        if data[0] == "EVENT" and data[1] == subscription_id:
                            event = data[2]
                            if event.get("kind") == 3 or event.get("kind") == 10002:
                                follow_events.append(event)
                                logger.debug(f"Collected follow event from {event.get('pubkey', 'unknown')[:8]}...")
                        
                        elif data[0] == "EOSE" and data[1] == subscription_id:
                            logger.debug(f"Received EOSE for {subscription_id}")
                            break
                            
                    except asyncio.TimeoutError:
                        continue
                    except json.JSONDecodeError as e:
                        logger.warning(f"Failed to parse message from {relay_url}: {e}")
                        continue
                
                # Send CLOSE message
                close_msg = ["CLOSE", subscription_id]
                await websocket.send(json.dumps(close_msg))
                
        except Exception as e:
            logger.error(f"Error fetching follow lists from {relay_url}: {e}")
        
        logger.info(f"Collected {len(follow_events)} follow events from {relay_url}")
        return follow_events

    async def fetch_events_from_relays(self, relay_urls: List[str]) -> Dict[str, List[Dict]]:
        """Fetch events from multiple relays concurrently and return a dict of relay_url -> events"""
        if not relay_urls:
            return {}
        
        logger.info(f"Fetching events from {len(relay_urls)} relays concurrently")
        
        # Create tasks for concurrent event fetching
        tasks = []
        for relay_url in relay_urls:
            task = asyncio.create_task(self.fetch_events(relay_url))
            tasks.append((relay_url, task))
        
        # Wait for all tasks to complete
        results = {}
        for relay_url, task in tasks:
            try:
                events = await task
                results[relay_url] = events
                logger.info(f"✓ Fetched {len(events)} events from {relay_url}")
            except Exception as e:
                logger.error(f"Error fetching events from relay {relay_url}: {e}")
                results[relay_url] = []
        
        total_events = sum(len(events) for events in results.values())
        logger.info(f"Batch fetch completed: {total_events} total events from {len(relay_urls)} relays")
        
        return results

    
    def extract_relays_from_events(self, events: List[Dict]) -> Set[str]:
        """Extract relay URLs from follow list events"""
        relay_urls = set()
        
        for event in events:
            self.stats.events_processed += 1
            
            # Process tags to find relay information
            tags = event.get('tags', [])
            
            for tag in tags:
                if not isinstance(tag, list) or len(tag) < 2:
                    continue
                
                # Look for 'r' tags (relay tags)
                if tag[0] == 'r' and len(tag) >= 2:
                    potential_relay = tag[1]
                    if self.is_valid_relay_url(potential_relay):
                        normalized_url = self.normalize_relay_url(potential_relay)
                        relay_urls.add(normalized_url)
                
                # Also check 'p' tags for potential relay info in some implementations
                elif tag[0] == 'p' and len(tag) >= 3:
                    # Some implementations put relay info in the 3rd element of p tags
                    if len(tag) > 2 and self.is_valid_relay_url(tag[2]):
                        normalized_url = self.normalize_relay_url(tag[2])
                        relay_urls.add(normalized_url)
        
        return relay_urls
    
    async def load_existing_results(self) -> bool:
        """Load existing results and verify that functioning relays still work"""
        import os
        
        if not os.path.exists(self.output_file):
            logger.info(f"No existing results file found at {self.output_file}, starting fresh")
            # Initialize with just the initial relay
            self.to_visit.append((self.initial_relay, 0))
            self.to_visit_set.add(self.initial_relay)
            return False
        
        try:
            logger.info(f"Loading existing results from {self.output_file}")
            with open(self.output_file, 'r') as f:
                data = json.load(f)
            
            existing_relays = data.get('functioning_relays', [])
            logger.info(f"Found {len(existing_relays)} existing functioning relays to verify")
            
            if not existing_relays:
                logger.info("No existing relays found, starting fresh")
                self.to_visit.append((self.initial_relay, 0))
                self.to_visit_set.add(self.initial_relay)
                return False
            else:
                logger.info("Existing relays found, building on the previous results")
                self.to_visit.extend([(existing_relay, 0) for existing_relay in existing_relays])
                self.to_visit_set.update(existing_relays)
                
        except Exception as e:
            logger.error(f"Error loading existing results: {e}")
            logger.info("Starting fresh due to error")
            self.to_visit.append((self.initial_relay, 0))
            self.to_visit_set.add(self.initial_relay)
            return False
    
    async def discover_relays(self) -> Set[str]:
        """Main discovery method using breadth-first search with concurrent processing"""
        # First, try to load existing results and verify them
        logger.info("Checking for existing results to build upon...")
        await self.load_existing_results()
        
        logger.info(f"Starting relay discovery with batch size {self.batch_size}")
        logger.info(f"Maximum depth: {self.max_depth}")
        
        while self.to_visit:
            # Collect a batch of relays to process
            current_batch = []
            batch_depth_map = {}
            
            # Get up to batch_size relays from the queue
            for _ in range(min(self.batch_size, len(self.to_visit))):
                if not self.to_visit:
                    break
                    
                current_relay, depth = self.to_visit.popleft()
                self.to_visit_set.remove(current_relay)
                
                # Skip if already visited
                if current_relay in self.visited_relays:
                    continue
                    
                # Skip if depth exceeds maximum
                if depth > self.max_depth:
                    logger.info(f"Reached maximum depth {self.max_depth}, stopping exploration")
                    continue
                
                current_batch.append(current_relay)
                batch_depth_map[current_relay] = depth
                self.visited_relays.add(current_relay)
            
            if not current_batch:
                break
            
            logger.info(f"Processing batch of {len(current_batch)} relays")
            
            # Test all relays in the batch concurrently
            test_results = await self.test_relays_connections(current_batch)
            
            # Collect functioning relays for event fetching
            functioning_relays_batch = []
            for relay_url, is_functioning in test_results.items():
                if is_functioning:
                    self.functioning_relays.add(relay_url)
                    self.stats.functioning_relays += 1
                    
                    # Only add to event fetching if we haven't reached max depth
                    depth = batch_depth_map[relay_url]
                    if depth < self.max_depth:
                        functioning_relays_batch.append(relay_url)
            
            # Fetch events from all functioning relays concurrently
            if functioning_relays_batch:
                events_results = await self.fetch_events_from_relays(functioning_relays_batch)
                
                # Process events and extract new relays
                for relay_url, events in events_results.items():
                    if events:
                        depth = batch_depth_map[relay_url]
                        new_relays = self.extract_relays_from_events(events)
                        
                        # Add new relays to visit queue for next depth level
                        for new_relay_url in new_relays:
                            if new_relay_url not in self.visited_relays and new_relay_url not in self.to_visit_set:
                                self.to_visit.append((new_relay_url, depth + 1))
                                self.to_visit_set.add(new_relay_url)
                                self.stats.total_relays_found += 1
                                logger.debug(f"Added {new_relay_url} to visit queue at depth {depth + 1}")
            
            # Save progress periodically
            if len(self.visited_relays) % self.save_point == 0:
                logger.info(f"Progress checkpoint: Saving results after processing {len(self.visited_relays)} relays")
                self.save_results()
            
            # Print periodic statistics
            if len(self.visited_relays) % 10 == 0:
                self.stats.print_stats()
        
        logger.info("Discovery completed!")
        return self.functioning_relays
    
    def save_results(self, output_file: str = None):
        """Save discovery results to a JSON file"""
        if output_file is None:
            output_file = self.output_file
            
        results = {
            "discovery_settings": {
                "initial_relay": self.initial_relay,
                "max_depth": self.max_depth,
                "connection_timeout": self.connection_timeout,
                "save_point": self.save_point,
                "batch_size": self.batch_size
            },
            "progress_info": {
                "relays_processed": len(self.visited_relays),
                "relays_remaining": len(self.to_visit),
                "discovery_complete": len(self.to_visit) == 0,
                "last_saved": time.time()
            },
            "statistics": {
                "total_relays_found": self.stats.total_relays_found,
                "functioning_relays_count": len(self.functioning_relays),
                "events_processed": self.stats.events_processed,
                "existing_relays_verified": self.stats.existing_relays_verified,
                "existing_relays_failed": self.stats.existing_relays_failed,
                "discovery_duration": time.time() - self.stats.start_time
            },
            "functioning_relays": list(self.functioning_relays)
        }
        
        with open(output_file, 'w') as f:
            json.dump(results, f, indent=2)
        
        logger.info(f"Results saved to {output_file}")


async def main():
    """Main function"""
    parser = argparse.ArgumentParser(description="Discover Nostr relays using breadth-first search")
    parser.add_argument(
        "initial_relay",
        help="Initial relay URL to start discovery from (e.g., wss://relay.damus.io)"
    )
    parser.add_argument(
        "--max-depth",
        type=int,
        default=3,
        help="Maximum depth for breadth-first search (default: 3)"
    )
    parser.add_argument(
        "--timeout",
        type=int,
        default=5,
        help="Connection timeout in seconds (default: 5)"
    )
    parser.add_argument(
        "--output",
        default="relay_discovery_results.json",
        help="Output file for results (default: relay_discovery_results.json)"
    )
    parser.add_argument(
        "--save-point",
        type=int,
        default=SAVE_POINT,
        help=f"Save progress every N relays processed (default: {SAVE_POINT})"
    )
    parser.add_argument(
        "--batch-size",
        type=int,
        default=10,
        help="Number of relays to process concurrently (default: 10)"
    )
    parser.add_argument(
        "--verbose",
        action="store_true",
        help="Enable verbose logging"
    )
    
    args = parser.parse_args()
    
    if args.verbose:
        logging.getLogger().setLevel(logging.DEBUG)
    
    # Validate initial relay URL
    discovery = NostrRelayDiscovery(
        args.initial_relay, 
        args.max_depth, 
        args.timeout, 
        args.output, 
        args.save_point,
        args.batch_size
    )
    if not discovery.is_valid_relay_url(args.initial_relay):
        print(f"Error: Invalid relay URL: {args.initial_relay}")
        print("Relay URL should start with ws:// or wss://")
        return 1
    
    try:
        # Run discovery
        functioning_relays = await discovery.discover_relays()
        
        # Print final results
        print("\n=== DISCOVERY COMPLETED ===")
        discovery.stats.print_stats()
        
        print(f"\n=== FUNCTIONING RELAYS ({len(functioning_relays)}) ===")
        for relay in sorted(functioning_relays):
            print(f"  {relay}")
        
        # Save results
        discovery.save_results()
        
        return 0
        
    except KeyboardInterrupt:
        print("\nDiscovery interrupted by user")
        discovery.save_results()
        return 1
    except Exception as e:
        logger.error(f"Discovery failed: {e}")
        return 1


if __name__ == "__main__":
    import sys
    sys.exit(asyncio.run(main()))
