#!/usr/bin/env python3
"""
Sonar V2.5: The Dual-Minded HUD.
Tracks both Javascript Execution (Rendering) and Semantic Reading (Source).
"""

import sys
import re
import hashlib
from datetime import datetime
from collections import Counter
import os 
import time

from textual.app import App, ComposeResult
from textual.containers import Container, Vertical, Horizontal
from textual.widgets import Header, Footer, Static, Log, RichLog, Label, Markdown, DataTable
from textual import work
from rich.text import Text
from rich.console import Console

from db import db, KNOWN_BOTS  # Import the list

# --- Configuration ---
STRIP_MOZILLA_PREFIX = False  # Set to True to save space in bottom panels

# A hidden console to render styles into ANSI codes for the Log widget
# We set a massive width to prevent the console from hard-wrapping the text before the Log widget gets it
OFFSCREEN_CONSOLE = Console(force_terminal=True, color_system="truecolor", file=open(os.devnull, "w"), width=100000)

BOT_STYLE = "bold orange1"  # Amazon Orange

try:
    from db import db  # Import our new database singleton
except ImportError:
    db = None

# --- Configuration ---
ANSI_ESCAPE = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]')
# Updated to optionally capture the new custom fields at the end of the line
LOG_PATTERN = re.compile(
    r'(?P<ip>[\d\.]+) - - \[(?P<time>.*?)\] "(?P<request>.*?)" (?P<status>\d+) (?P<bytes>\d+) '
    r'"(?P<referrer>.*?)" "(?P<ua>.*?)"(?: Accept:"(?P<accept>.*?)" MarkdownServed:(?P<md>\d))?'
)

class SonarApp(App):
    """The Cybernetic HUD (Dual-Panel Edition)."""

    CSS = """
    Screen {
        layout: grid;
        grid-size: 1 6; 
        background: #0f1f27;
    }

    /* TOP SECTION: Full Width Log Stream (Rows 1-4) */
    #log_stream {
        row-span: 4;
        background: #000000;
        color: #00ff00;
        height: 100%;
        overflow-y: auto;   /* <-- RESTORES VERTICAL SCROLL & AUTO-SCROLLING */
        overflow-x: hidden; /* <-- KEEPS THE BOTTOM FLUSH */
        width: 100%;
        min-width: 100%;
    }

    /* BOTTOM SECTION: The Intelligence Panel (Rows 5-6) */
    #intelligence_panel {
        row-span: 2;
        border-top: solid cyan;
        background: #051515;
        padding: 0;
        layout: horizontal; /* Split Left/Right */
        overflow: hidden;
    }
    
    .half_panel {
        width: 50%;
        height: 100%;
        border-right: solid #004400;
    }

    .panel_header {
        text-align: center;
        background: #002200;
        color: #00ff00;
        text-style: bold;
        border-bottom: solid green;
        width: 100%;
    }

    DataTable {
        height: 1fr;
        width: 100%;
        overflow: hidden;
    }
    
    /* Countdown moves to the footer area style override if needed, 
       but standard Footer widget handles it well. */
    """

    TITLE = "Honeybot Sonar of https://mikelev.in/"

    # --- NEW: Banish the interactive Command Palette ---
    ENABLE_COMMAND_PALETTE = False
    
    def get_sub_title(self):
        return f"Live Nginx Log Analysis | Next Report: {self.countdown_str}"

    def compose(self) -> ComposeResult:
        yield Header()
        # RichLog handles wrapping and styling natively. 
        # wrap=True ensures lines don't get cut off.
        yield RichLog(id="log_stream", highlight=False, wrap=True)
        
        # --- NEW: The Dual Intelligence Panel ---
        with Container(id="intelligence_panel"):
            
            # LEFT: Javascript Executors
            with Vertical(classes="half_panel"):
                yield Label("⚡ JAVASCRIPT EXECUTORS (Caught by CAPTCHA)", classes="panel_header")
                yield DataTable(id="js_table")
            
            # RIGHT: Markdown Seekers
            with Vertical(classes="half_panel"):
                yield Label("🧠 ACCEPTS MARKDOWN (Negotiated / Traced)", classes="panel_header")
                yield DataTable(id="md_table")
        # ----------------------------------------
            
        # yield Footer()

    def on_mount(self) -> None:
        self.ua_counter = Counter()
        self.countdown_str = "--:--"
        self.stream_logs()
        
        # Setup Tables (Now with 3 columns!)
        js_table = self.query_one("#js_table", DataTable)
        js_table.add_columns("Hits", "Bot Identity", "Raw Signature")
        
        md_table = self.query_one("#md_table", DataTable)
        md_table.add_columns("Hits", "Bot Identity", "Raw Signature")

        self.refresh_tables() # Initial load

        # Timers
        try:
            self.start_time = float(os.environ.get("SONAR_START_TIME", time.time()))
            self.duration_mins = float(os.environ.get("SONAR_DURATION", 15))
        except:
            self.start_time = time.time()
            self.duration_mins = 15
            
        self.set_interval(1, self.update_countdown)
        self.set_interval(5, self.refresh_tables)

    def stylize_agent(self, agent_str):
        """Converts a raw UA string into a Rich Text object with highlights."""
        agent_str = agent_str.strip()
        text = Text(agent_str)

        # Default styling (Broadened to catch browsers even if "Mozilla/5.0" is stripped)
        # We look for "Mozilla" OR "AppleWebKit" OR "Gecko" to identify standard browser chains
        if ("Mozilla" in agent_str or "AppleWebKit" in agent_str) and "compatible" not in agent_str:
            text.stylize("dim white")
            
        # Highlight ANY known bot
        for bot_name in KNOWN_BOTS:
            if bot_name in agent_str:
                text.highlight_regex(re.escape(bot_name), BOT_STYLE)
                
        return text

    def extract_and_stylize(self, agent_str):
        """Returns a tuple: (Styled Identity, Dimmed Full String)"""
        agent_str = agent_str.strip().replace("Mozilla/5.0 ", "")
        
        identity = "Unknown"
        identity_style = "dim white"
        
        # Find the specific Bot Identity
        for bot_name in KNOWN_BOTS:
            if bot_name in agent_str:
                identity = bot_name
                identity_style = "bold orange1"
                break
                
        # Format the outputs
        id_text = Text(identity, style=identity_style)
        full_text = Text(agent_str, style="dim green")
        
        return id_text, full_text

    def refresh_tables(self):
        """Updates both Intelligence tables from DB."""
        if not db: return
        
        # 1. Update JS Executors (Left)
        try:
            table = self.query_one("#js_table", DataTable)
            table.clear()
            data = db.get_js_executors(limit=50) 
            if not data:
                table.add_row("-", "None", "Waiting for data...")
            else:
                for ua, count in data:
                    display_ua = ua.replace("Mozilla/5.0 ", "") if STRIP_MOZILLA_PREFIX else ua
                    identity, signature = self.extract_and_stylize(display_ua)
                    table.add_row(str(count), identity, signature)
        except Exception as e:
            table.clear()
            table.add_row("ERROR", "None", str(e)[:40])

        # 2. Update Markdown Readers (Right)
        try:
            table = self.query_one("#md_table", DataTable)
            table.clear()
            data = db.get_markdown_readers(limit=50)
            if not data:
                table.add_row("-", "None", "Waiting for data...")
            else:
                for ua, count in data:
                    display_ua = ua.replace("Mozilla/5.0 ", "") if STRIP_MOZILLA_PREFIX else ua
                    identity, signature = self.extract_and_stylize(display_ua)
                    table.add_row(str(count), identity, signature)
        except Exception as e:
            table.clear()
            table.add_row("ERROR", "None", str(e)[:40])

    def update_countdown(self):
        """Ticks the clock and updates the Sub-Title."""
        elapsed = time.time() - self.start_time
        total_seconds = self.duration_mins * 60
        remaining = total_seconds - elapsed
        
        if remaining < 0: remaining = 0
        mins, secs = divmod(int(remaining), 60)
        
        self.countdown_str = f"{mins:02d}:{secs:02d}"
        self.sub_title = self.get_sub_title() # Trigger header update

    # -------------------------------------------------------------------------
    # Core Logic: IP Anonymization & Stream Parsing
    # -------------------------------------------------------------------------
    def anonymize_ip(self, ip_str):
        try:
            parts = ip_str.split('.')
            if len(parts) == 4:
                masked = f"{parts[0]}.{parts[1]}.*.*"
                salt = datetime.now().strftime("%Y-%m-%d")
                hash_input = f"{ip_str}-{salt}"
                hash_digest = hashlib.md5(hash_input.encode()).hexdigest()[:4]
                return Text.assemble((masked, "cyan"), " ", (f"[{hash_digest}]", "bold cyan"))
            return Text(ip_str, style="red")
        except:
            return Text(ip_str, style="red")

    def parse_request(self, request_str):
        try:
            parts = request_str.split()
            if len(parts) >= 2:
                return parts[0], parts[1]
            return "???", request_str
        except:
            return "???", request_str

    @work(thread=True)
    def stream_logs(self) -> None:
        hits = 0
        for line in sys.stdin:
            clean_line = ANSI_ESCAPE.sub('', line).strip()
            if not clean_line or " - - [" not in clean_line: continue

            match = LOG_PATTERN.search(clean_line)
            if match:
                data = match.groupdict()
                if "porn" in data['request'].lower(): continue

                hits += 1

                # Persist to DB
                if db:
                    try:
                        # Extract path and strip query strings to prevent DB bloat
                        raw_path = data['request'].split()[1] if len(data['request'].split()) > 1 else data['request']
                        
                        # SELECTIVE QUERY STRING PRESERVATION
                        # We keep the ?src= tracking dyes for attribution, but strip everything else (like cache busters)
                        if '?' in raw_path:
                            base_path, qs = raw_path.split('?', 1)
                            if qs in ['src=a+href', 'src=llms.txt', 'src=link+rel']:
                                clean_path = raw_path
                            else:
                                clean_path = base_path
                        else:
                            clean_path = raw_path

                        # THE ALCHEMICAL NORMALIZATION (Purging Ambiguity)
                        # If Nginx served markdown via Content-Negotiation, explicitly tag it
                        if data.get('md') == '1':
                            if clean_path.endswith('/'):
                                clean_path += "index.md?src=content_neg"
                            elif 'src=' not in clean_path:
                                clean_path += "?src=content_neg"

                        db.log_request(
                            ip=data['ip'],
                            ua=data['ua'],
                            path=clean_path,
                            status=int(data['status']),
                            referrer=data.get('referrer'),
                            accept=data.get('accept'),
                            served_md=data.get('md')
                        )
                        db.increment_counter("global_hits")
                    except: pass

                # Update Logs UI
                self.ua_counter[data['ua'].split('/')[0]] += 1
                rich_text = self.format_log_line(data)
                if hits > 1: rich_text = Text("\n") + rich_text
                self.call_from_thread(self.write_log, rich_text)

    def write_log(self, text):
        """Write the Rich Text object directly to the RichLog widget."""
        # Note: We query RichLog now, not Log
        log = self.query_one(RichLog)
        log.write(text)

    def format_log_line(self, data):
        status = int(data['status'])
        if 200 <= status < 300: status_style = "bold green"
        elif 300 <= status < 400: status_style = "bold white"
        elif 400 <= status < 500: status_style = "bold red"
        else: status_style = "white on red"

        ip_display = self.anonymize_ip(data['ip'])
        method, path = self.parse_request(data['request'])
        
        ua = data['ua']
        
        # 1. Get the base styled text from our helper
        # This handles the dimming of Mozilla AND the regex highlighting of Bots
        ua_text = self.stylize_agent(ua)
        
        # 2. Add the prefix icon manually (since stylize_agent just does text)
        prefix = ""
        if "Mozilla" in ua and "compatible" not in ua:
            prefix = "👤 "
        elif "python" in ua.lower():
            prefix = "🔧 "
        
        # Check if any known bot is in there to add the Robot Icon
        for bot_name in KNOWN_BOTS:
            if bot_name in ua:
                prefix = "🤖 "
                break

        # Dynamic styling for the requested path
        path_style = "blue"
        if "js_confirm.gif" in path:
            prefix = "🪤 " + prefix
            path_style = "black on blue"

        text = Text()
        try:
            time_str = data['time'].split(':')[1:]
            time_str = ":".join(time_str).split(' ')[0]
            text.append(f"[{time_str}] ", style="dim")
        except: text.append("[TIME] ", style="dim")

        text.append(ip_display)
        text.append("  ") # Just space
        text.append(f"{method:4} ", style="bold")
        text.append(f"{path} ", style=path_style)
        text.append(f"[{status}] ", style=status_style)
        text.append("  ") # Just space
        
        # 3. Append the prefix
        text.append(prefix)
        
        # 4. Append the already-styled UA text
        text.append(ua_text)
        
        return text


if __name__ == "__main__":
    app = SonarApp()
    app.run()
