#!/usr/bin/env python3
"""
IT³ C-PHAM v2.3: Auto-Update MPCORB with Delta Analysis
========================================================
Automatically checks for MPCORB.DAT updates, downloads new versions
with date stamps, and performs DELTA ANALYSIS on newly discovered objects.

CRITICAL INSIGHT: This is an OUT-OF-SAMPLE TEST of the IT³ theory!
If the theory is correct, NEWLY DISCOVERED objects must fall on the
SAME quantized topological floors as old objects — no fitting involved.

Author: Victor Logvinovich
Date: July 2026
Version: 2.3 (with auto-update and delta analysis)
License: MIT
"""

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patheffects as pe
import os
import csv
import logging
import warnings
import ssl
import urllib.request
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Dict, List, Set, Tuple, Optional
from pathlib import Path
from tqdm import tqdm

warnings.filterwarnings('ignore')

# ============================================================================
# LOGGING CONFIGURATION
# ============================================================================
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    datefmt='%H:%M:%S'
)
logger = logging.getLogger(__name__)

# ============================================================================
# MPCORB AUTO-UPDATER
# ============================================================================
class MPCORBUpdater:
    """Handles automatic checking and downloading of MPCORB.DAT updates."""
    
    MPCORB_URL = "https://www.minorplanetcenter.net/iau/MPCORB/MPCORB.DAT"
    FILE_PREFIX = "MPCORB_"
    FILE_SUFFIX = ".DAT"
    
    def __init__(self, directory: str = "."):
        self.directory = Path(directory)
        self.directory.mkdir(parents=True, exist_ok=True)
        self._setup_ssl()
    
    def _setup_ssl(self):
        """Setup SSL context for HTTPS requests."""
        self.ssl_context = ssl.create_default_context()
        self.ssl_context.check_hostname = False
        self.ssl_context.verify_mode = ssl.CERT_NONE
    
    def find_latest_file(self) -> Optional[Tuple[Path, datetime]]:
        """Find the most recent dated MPCORB file in the directory."""
        pattern = f"{self.FILE_PREFIX}*{self.FILE_SUFFIX}"
        files = list(self.directory.glob(pattern))
        
        # Also check for plain MPCORB.DAT (legacy)
        plain_file = self.directory / "MPCORB.DAT"
        if plain_file.exists():
            mtime = datetime.fromtimestamp(plain_file.stat().st_mtime)
            files_with_dates = [(plain_file, mtime)]
        else:
            files_with_dates = []
        
        # Parse dates from filenames
        for f in files:
            try:
                date_str = f.stem.replace(self.FILE_PREFIX, "")
                file_date = datetime.strptime(date_str, "%Y-%m-%d")
                files_with_dates.append((f, file_date))
            except ValueError:
                continue
        
        if not files_with_dates:
            return None
        
        # Return the most recent
        return max(files_with_dates, key=lambda x: x[1])
    
    def check_remote_update(self) -> Optional[datetime]:
        """Check Last-Modified date on MPC server via HEAD request."""
        try:
            req = urllib.request.Request(self.MPCORB_URL, method='HEAD')
            req.add_header('User-Agent', 'IT3-C-PHAM/2.3 (Research Tool)')
            
            with urllib.request.urlopen(req, context=self.ssl_context, timeout=30) as response:
                last_modified = response.headers.get('Last-Modified')
                if last_modified:
                    # Parse: "Wed, 02 Jul 2026 12:33:00 GMT"
                    from email.utils import parsedate_to_datetime
                    return parsedate_to_datetime(last_modified)
        except Exception as e:
            logger.warning(f"Could not check remote update: {e}")
        return None
    
    def download(self, output_path: Path, show_progress: bool = True) -> bool:
        """Download MPCORB.DAT to the specified path."""
        logger.info(f"[*] Downloading MPCORB.DAT → {output_path.name}")
        logger.info(f"    URL: {self.MPCORB_URL}")
        
        try:
            req = urllib.request.Request(self.MPCORB_URL)
            req.add_header('User-Agent', 'IT3-C-PHAM/2.3 (Research Tool)')
            
            with urllib.request.urlopen(req, context=self.ssl_context, timeout=600) as response:
                total_size = int(response.headers.get('Content-Length', 0))
                downloaded = 0
                block_size = 65536
                
                with open(output_path, 'wb') as f:
                    if show_progress:
                        pbar = tqdm(total=total_size, unit='B', unit_scale=True,
                                   desc="Downloading MPCORB.DAT")
                    
                    while True:
                        buffer = response.read(block_size)
                        if not buffer:
                            break
                        f.write(buffer)
                        downloaded += len(buffer)
                        if show_progress:
                            pbar.update(len(buffer))
                    
                    if show_progress:
                        pbar.close()
                
                size_mb = downloaded / (1024 * 1024)
                logger.info(f"[✓] Downloaded: {size_mb:.1f} MB → {output_path.name}")
                return True
                
        except Exception as e:
            logger.error(f"[-] Download failed: {e}")
            if output_path.exists():
                output_path.unlink()
            return False
    
    def check_and_update(self) -> Tuple[Path, bool]:
        """
        Main entry point: check for updates and download if needed.
        
        Returns:
            (file_path, is_new_download)
        """
        logger.info("=" * 95)
        logger.info(" MPCORB AUTO-UPDATER")
        logger.info("=" * 95)
        
        # Find latest local file
        latest = self.find_latest_file()
        if latest:
            latest_path, latest_date = latest
            logger.info(f"[*] Latest local file: {latest_path.name}")
            logger.info(f"    Date: {latest_date.strftime('%Y-%m-%d')}")
        else:
            logger.info("[*] No existing MPCORB files found — first run")
            latest_path, latest_date = None, None
        
        # Check remote
        logger.info("[*] Checking MPC server for updates...")
        remote_date = self.check_remote_update()
        
        if remote_date:
            logger.info(f"    Remote Last-Modified: {remote_date.strftime('%Y-%m-%d %H:%M UTC')}")
        else:
            logger.warning("    Could not determine remote date")
        
        # Decide whether to download
        today_str = datetime.now().strftime("%Y-%m-%d")
        today_file = self.directory / f"{self.FILE_PREFIX}{today_str}{self.FILE_SUFFIX}"
        
        # Skip if we already downloaded today
        if today_file.exists():
            logger.info(f"[✓] Already have today's file: {today_file.name}")
            return today_file, False
        
        should_download = False
        if latest is None:
            logger.info("[*] No local file — downloading baseline")
            should_download = True
        elif remote_date and remote_date.date() > latest_date.date():
            logger.info(f"[+] Newer version available on server")
            should_download = True
        elif remote_date is None:
            # Can't verify — download anyway if we don't have today's
            logger.info("[*] Cannot verify remote date — downloading to be safe")
            should_download = True
        else:
            logger.info("[✓] Local file is up to date — no download needed")
            return latest_path, False
        
        if should_download:
            if self.download(today_file):
                return today_file, True
            elif latest_path:
                logger.warning("[!] Download failed, using latest local file")
                return latest_path, False
            else:
                raise RuntimeError("Failed to download MPCORB.DAT and no local copy exists")
        
        return latest_path, False


# ============================================================================
# CONFIGURATION (same as v2.2)
# ============================================================================
@dataclass(frozen=True)
class IT3Config:
    LAMBDA_Y: float = np.sqrt(3)
    K_TORUS: float = 3 + 2*np.sqrt(2)
    LAMBDA_H: float = 2.0
    R_BASE: float = 27.0
    R_PHASE_TRANSITION: float = 243.0
    NORMALIZATION_FACTOR: float = 24.07
    INNER_NORMALIZATION: float = 10.095
    MAX_BACKGROUND_ASTEROIDS: int = 200
    MAX_FLOOR_DISPLAY: int = 15
    Z_SCALE_FACTOR: float = 50.0
    FIGURE_DPI: int = 300
    BG_COLOR: str = '#0a0a12'
    GRID_COLOR: str = '#2a2a35'
    TEXT_COLOR: str = '#e0e0e0'
    COLOR_PLANET: str = '#FFD700'
    COLOR_DWARF: str = '#00FFFF'
    COLOR_ETNO: str = '#FF1493'
    COLOR_ASTEROID: str = '#4A4A6A'
    COLOR_NEW: str = '#00FF00'      # Bright green for NEW objects!
    COLOR_RING_GLOW: str = '#8A2BE2'
    COLOR_RING_CORE: str = '#DDA0DD'
    COLOR_SUN: str = '#FFFAA0'
    COLOR_SUN_EDGE: str = '#FF8C00'


# ============================================================================
# DATA CLASSES
# ============================================================================
@dataclass
class CelestialObject:
    mpc_code: str
    name: str
    obj_type: str
    a: float
    e: float
    i: float
    omega: float
    Omega: float
    
    @property
    def Q(self) -> float:
        return self.a * (1 + self.e)
    
    @property
    def q(self) -> float:
        return self.a * (1 - self.e)


@dataclass
class TopologicalResult:
    Q: float
    Phi_topo: float
    r_eff: float
    S_n: int
    S_k: int
    S_h: int
    R_bowl: float


# ============================================================================
# MPC NAMING DATABASE (same as v2.2)
# ============================================================================
MPC_TO_NAME = {
    'Mercury': 'Mercury', 'Venus': 'Venus', 'Earth': 'Earth', 'Mars': 'Mars',
    'Jupiter': 'Jupiter', 'Saturn': 'Saturn', 'Uranus': 'Uranus', 'Neptune': 'Neptune',
    '00001': 'Ceres',
    '134340': 'Pluto', 'D4340': 'Pluto',
    '136108': 'Haumea', 'D6108': 'Haumea',
    '136472': 'Makemake', 'D6472': 'Makemake',
    '136199': 'Eris', 'D6199': 'Eris',
    '90377': 'Sedna',
    'K12VB3P': '2012VP113',
    's1132': '2015TG387',
    '~0caL': '2015BP519',
}

OBJECT_TYPES = {
    'Mercury': 'planet', 'Venus': 'planet', 'Earth': 'planet', 'Mars': 'planet',
    'Jupiter': 'planet', 'Saturn': 'planet', 'Uranus': 'planet', 'Neptune': 'planet',
    'Ceres': 'dwarf', 'Pluto': 'dwarf', 'Eris': 'dwarf',
    'Haumea': 'dwarf', 'Makemake': 'dwarf',
    'Sedna': 'etno', '2012VP113': 'etno', '2015TG387': 'etno', '2015BP519': 'etno',
}

KEY_OBJECTS = frozenset([
    'Mercury', 'Mars', 'Earth', 'Neptune', 'Pluto', 'Eris',
    'Haumea', 'Makemake', 'Sedna', '2012VP113', '2015TG387', '2015BP519'
])

NAME_PATTERNS = [
    ('Haumea', '(136108)', 'Haumea'),
    ('2012 VP113', '2012VP113', '2012VP113'),
    ('2015 TG387', '541132', 'Leleakuhonua', '2015TG387'),
    ('(487581) 2015 BP519', '2015BP519', '2015BP519'),
    ('Makemake', '(136472)', 'Makemake'),
    ('Eris', '(136199)', 'Eris'),
    ('Sedna', '(90377)', 'Sedna'),
    ('Pluto', '(134340)', 'Pluto'),
]


# ============================================================================
# C-PHAM v2.2 DESCENT OPERATOR (unchanged)
# ============================================================================
class CPHAMOperator:
    def __init__(self, config: IT3Config):
        self.config = config
    
    def _get_steps_n(self, val: float, max_steps: int = 20) -> int:
        if val < 1.0:
            return 0
        steps = 0
        curr = val
        for _ in range(max_steps):
            next_val = np.floor(curr / self.config.LAMBDA_Y)
            if next_val < 1.0:
                break
            curr = next_val
            steps += 1
        return steps
    
    def _get_steps_kh(self, val: float, scale: float, max_steps: int = 20) -> int:
        if val < 1.0:
            return 1
        steps = 0
        curr = val
        for _ in range(max_steps):
            next_val = np.floor(curr / scale)
            if next_val < 1.0:
                break
            curr = next_val
            steps += 1
        return steps + 1
    
    def apply(self, obj: CelestialObject) -> TopologicalResult:
        Q = obj.Q
        Phi_topo = 1 + 0.5 * obj.e**2
        r_eff = Q * Phi_topo
        
        if r_eff >= self.config.R_PHASE_TRANSITION:
            n_raw = (r_eff - self.config.R_PHASE_TRANSITION) / self.config.NORMALIZATION_FACTOR
        else:
            n_raw = r_eff / self.config.INNER_NORMALIZATION
        
        delta_k = (obj.i / 90.0) * 0.5 * np.sin(np.radians(obj.Omega))
        k_raw = (obj.omega % 360.0) / 30.0 + 1.0 + delta_k
        k_raw = np.clip(k_raw, 1.0, 12.999)
        h_raw = 1.0 + (obj.i / 90.0) * 5.0
        
        S_n = self._get_steps_n(n_raw)
        S_k = self._get_steps_kh(k_raw, self.config.K_TORUS)
        S_h = self._get_steps_kh(h_raw, self.config.LAMBDA_H)
        
        if S_n == 0:
            R_bowl = self.config.R_BASE
        else:
            R_bowl = self.config.R_BASE * (self.config.LAMBDA_Y ** (S_n - 1))
        
        return TopologicalResult(
            Q=Q, Phi_topo=Phi_topo, r_eff=r_eff,
            S_n=S_n, S_k=S_k, S_h=S_h, R_bowl=R_bowl
        )


# ============================================================================
# UNIVERSAL DATA LOADER (same as v2.2)
# ============================================================================
class UniversalLoader:
    def __init__(self, config: IT3Config):
        self.config = config
    
    def _detect_format(self, filepath: str) -> str:
        with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
            for line in f:
                line = line.strip()
                if not line or len(line) < 50:
                    continue
                if len(line) > 100:
                    return 'mpcorb'
                parts = line.split()
                if len(parts) >= 11:
                    try:
                        float(parts[10])
                        return 'distant'
                    except (ValueError, IndexError):
                        continue
        raise ValueError(f"Cannot detect format of {filepath}")
    
    def _identify_object(self, mpc_code: str, name_field: str) -> Tuple[str, str]:
        readable_name = MPC_TO_NAME.get(mpc_code, mpc_code)
        for patterns in NAME_PATTERNS:
            if any(pattern in name_field for pattern in patterns):
                readable_name = patterns[-1]
                break
        obj_type = OBJECT_TYPES.get(readable_name, 'asteroid')
        return readable_name, obj_type
    
    def load_mpcorb(self, filepath: str) -> Dict[str, CelestialObject]:
        objects: Dict[str, CelestialObject] = {}
        with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
            lines = f.readlines()
        for line in tqdm(lines, desc=f"Parsing {Path(filepath).name}", unit="lines"):
            if len(line) < 103:
                continue
            try:
                mpc_code = line[0:7].strip()
                omega = float(line[37:46])
                Omega = float(line[48:57])
                i = float(line[59:68])
                e = float(line[70:79])
                a = float(line[92:103])
                if not (a > 0 and 0 <= e < 1.0):
                    continue
                name_field = line[166:].strip() if len(line) >= 166 else ""
                readable_name, obj_type = self._identify_object(mpc_code, name_field)
                objects[mpc_code] = CelestialObject(
                    mpc_code=mpc_code, name=readable_name, obj_type=obj_type,
                    a=a, e=e, i=i, omega=omega, Omega=Omega
                )
            except (ValueError, IndexError):
                continue
        return objects
    
    def load_distant(self, filepath: str) -> Dict[str, CelestialObject]:
        objects: Dict[str, CelestialObject] = {}
        with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
            lines = f.readlines()
        for line in tqdm(lines, desc=f"Parsing {Path(filepath).name}", unit="lines"):
            if len(line) < 50:
                continue
            try:
                parts = line.split()
                if len(parts) < 11:
                    continue
                mpc_code = parts[0]
                omega = float(parts[5])
                Omega = float(parts[6])
                i = float(parts[7])
                e = float(parts[8])
                a = float(parts[10])
                if not (a > 0 and 0 <= e < 1.0):
                    continue
                name_field = ' '.join(parts[11:]) if len(parts) > 11 else ""
                readable_name, obj_type = self._identify_object(mpc_code, name_field)
                objects[mpc_code] = CelestialObject(
                    mpc_code=mpc_code, name=readable_name, obj_type=obj_type,
                    a=a, e=e, i=i, omega=omega, Omega=Omega
                )
            except (ValueError, IndexError):
                continue
        return objects
    
    def load(self, filepath: str) -> Dict[str, CelestialObject]:
        path = Path(filepath)
        if not path.exists():
            raise FileNotFoundError(f"File not found: {filepath}")
        file_format = self._detect_format(filepath)
        logger.info(f"[*] Detected format: {file_format.upper()}")
        if file_format == 'mpcorb':
            objects = self.load_mpcorb(filepath)
        else:
            objects = self.load_distant(filepath)
        logger.info(f"[✓] Loaded {len(objects):,} unique objects from {path.name}")
        return objects


# ============================================================================
# DELTA ANALYZER — THE CORE OF THE OUT-OF-SAMPLE TEST
# ============================================================================
class DeltaAnalyzer:
    """
    Compares old and new MPCORB datasets to find NEWLY DISCOVERED objects,
    then applies C-PHAM to test if they follow the same topological quantization.
    
    THIS IS THE ULTIMATE TEST OF THE IT³ THEORY:
    If new objects fall on the same quantized floors as old objects,
    the theory is validated as a REAL physical law, not a statistical fluke.
    """
    
    def __init__(self, config: IT3Config):
        self.config = config
        self.operator = CPHAMOperator(config)
    
    def compute_delta(
        self,
        old_objects: Dict[str, CelestialObject],
        new_objects: Dict[str, CelestialObject]
    ) -> Dict:
        """
        Compute the delta between old and new object sets.
        
        Returns:
            Dictionary with 'added', 'removed', 'unchanged' sets and analyzed new objects
        """
        old_codes = set(old_objects.keys())
        new_codes = set(new_objects.keys())
        
        added_codes = new_codes - old_codes
        removed_codes = old_codes - new_codes
        unchanged_codes = old_codes & new_codes
        
        logger.info("\n" + "=" * 95)
        logger.info(" DELTA ANALYSIS RESULTS")
        logger.info("=" * 95)
        logger.info(f"  Old dataset: {len(old_objects):>10,} objects")
        logger.info(f"  New dataset: {len(new_objects):>10,} objects")
        logger.info(f"  Net change:  {len(new_objects) - len(old_objects):>+10,} objects")
        logger.info(f"  ─────────────────────────────────")
        logger.info(f"  ➕ NEW objects:     {len(added_codes):>10,}")
        logger.info(f"  ➖ REMOVED objects: {len(removed_codes):>10,}")
        logger.info(f"  ✓  UNCHANGED:       {len(unchanged_codes):>10,}")
        logger.info("=" * 95)
        
        if not added_codes:
            logger.info("\n[!] NO NEW OBJECTS IN THE NEW VERSION!")
            logger.info("    IT³ theory cannot be tested on new data.")
            return {
                'added': set(),
                'removed': removed_codes,
                'unchanged': unchanged_codes,
                'new_analyzed': {},
                'old_distribution': self._compute_sn_distribution(old_objects),
                'new_distribution': {},
            }
        
        # Apply C-PHAM to NEW objects only
        logger.info(f"\n[*] Applying C-PHAM to {len(added_codes):,} NEW objects...")
        new_analyzed = {}
        for code in tqdm(added_codes, desc="Analyzing NEW objects", unit="obj"):
            obj = new_objects[code]
            new_analyzed[code] = {
                'object': obj,
                'result': self.operator.apply(obj)
            }
        
        # Compute distributions
        old_distribution = self._compute_sn_distribution(old_objects)
        new_distribution = self._compute_sn_distribution_from_analyzed(new_analyzed)
        
        return {
            'added': added_codes,
            'removed': removed_codes,
            'unchanged': unchanged_codes,
            'new_analyzed': new_analyzed,
            'old_distribution': old_distribution,
            'new_distribution': new_distribution,
        }
    
    def _compute_sn_distribution(
        self, objects: Dict[str, CelestialObject]
    ) -> Dict[int, int]:
        """Compute S_n distribution for a set of objects."""
        dist: Dict[int, int] = {}
        for obj in objects.values():
            result = self.operator.apply(obj)
            dist[result.S_n] = dist.get(result.S_n, 0) + 1
        return dist
    
    def _compute_sn_distribution_from_analyzed(
        self, analyzed: Dict[str, Dict]
    ) -> Dict[int, int]:
        """Compute S_n distribution from pre-analyzed objects."""
        dist: Dict[int, int] = {}
        for data in analyzed.values():
            sn = data['result'].S_n
            dist[sn] = dist.get(sn, 0) + 1
        return dist
    
    def print_delta_report(self, delta: Dict) -> None:
        """Print comprehensive delta analysis report."""
        added = delta['added']
        new_analyzed = delta['new_analyzed']
        
        if not added:
            logger.info("\n" + "=" * 95)
            logger.info(" 📋 RESULT: NO NEW OBJECTS")
            logger.info("=" * 95)
            logger.info(" MPC has not added new objects since the last update.")
            logger.info(" Retry later (MPC updates MPCORB.DAT daily ~12:30 UTC).")
            logger.info("=" * 95)
            return
        
        new_dist = delta['new_distribution']
        old_dist = delta['old_distribution']
        total_new = len(added)
        total_old = sum(old_dist.values())
        
        logger.info("\n" + "=" * 95)
        logger.info(" 📊 DISTRIBUTION OF NEW OBJECTS BY Sₙ FLOORS")
        logger.info("    (Comparison with old distribution — OUT-OF-SAMPLE TEST)")
        logger.info("=" * 95)
        logger.info(f"{'Sₙ':<5} | {'NEW':<10} | {'NEW %':<10} | {'OLD %':<10} | {'Match?'}")
        logger.info("-" * 60)
        
        all_sn = sorted(set(list(new_dist.keys()) + list(old_dist.keys())))
        matches = 0
        total_compared = 0
        
        for sn in all_sn:
            new_count = new_dist.get(sn, 0)
            old_count = old_dist.get(sn, 0)
            new_pct = (new_count / total_new * 100) if total_new > 0 else 0
            old_pct = (old_count / total_old * 100) if total_old > 0 else 0
            
            # Check if new distribution matches old (within statistical tolerance)
            diff = abs(new_pct - old_pct)
            # Allow 3σ statistical fluctuation: sqrt(p*(1-p)/N)*3 * 100
            tolerance = 3 * np.sqrt(old_pct * (100 - old_pct) / max(total_new, 1))
            match_flag = "✓" if diff <= max(tolerance, 5.0) else "✗"
            
            if match_flag == "✓":
                matches += 1
            total_compared += 1
            
            logger.info(f"  {sn:<3} | {new_count:<10,} | {new_pct:>6.2f}%  | {old_pct:>6.2f}%  | {match_flag}")
        
        match_rate = (matches / total_compared * 100) if total_compared > 0 else 0
        
        logger.info("-" * 60)
        logger.info(f" Distribution match: {matches}/{total_compared} ({match_rate:.1f}%)")
        
        # VERDICT
        logger.info("\n" + "=" * 95)
        if match_rate >= 80:
            logger.info(" 🎉 TRIUMPH! NEW OBJECTS FOLLOW THE SAME QUANTIZED STRUCTURE!")
            logger.info("    This is OUT-OF-SAMPLE confirmation of IT³ theory.")
            logger.info("    New discoveries were NOT used in building the theory,")
            logger.info("    but they automatically fall on the same topological floors.")
            logger.info("    This is STRONG EVIDENCE of the quantum nature of the macroscopic vacuum!")
        elif match_rate >= 60:
            logger.info(" ✅ GOOD MATCH. IT³ theory receives support.")
            logger.info("    Most new objects follow the predicted structure.")
        else:
            logger.info(" ⚠️  WEAK MATCH. Additional analysis required.")
            logger.info("    Perhaps new objects have a different origin or")
            logger.info("    the theory needs refinement for certain classes.")
        logger.info("=" * 95)
        
        # Show notable new objects (ETNOs, high S_n, etc.)
        logger.info("\n" + "=" * 95)
        logger.info(" 🌟 INTERESTING NEW OBJECTS (Sₙ ≥ 5 or ETNO)")
        logger.info("=" * 95)
        
        notable = []
        for code, data in new_analyzed.items():
            obj = data['object']
            res = data['result']
            if res.S_n >= 5 or obj.obj_type in ('etno', 'dwarf'):
                notable.append((code, obj, res))
        
        notable.sort(key=lambda x: (-x[2].S_n, -x[2].Q))
        
        if notable:
            logger.info(f"{'MPC Code':<12} | {'Name':<20} | {'Sₙ':<4} {'Sₖ':<4} {'Sₕ':<4} | {'Q (AU)':<10} | {'Type'}")
            logger.info("-" * 95)
            for code, obj, res in notable[:30]:
                logger.info(
                    f"{code:<12} | {obj.name:<20} | {res.S_n:<4} {res.S_k:<4} {res.S_h:<4} | "
                    f"{res.Q:<10.2f} | {obj.obj_type}"
                )
        else:
            logger.info("  (No interesting new objects with Sₙ ≥ 5)")
        
        logger.info("=" * 95)
    
    def visualize_delta(
        self,
        delta: Dict,
        output_path: str
    ) -> None:
        """Create visualization comparing old vs new distributions."""
        if not delta['added']:
            logger.info("[*] No new objects — skipping delta visualization")
            return
        
        new_dist = delta['new_distribution']
        old_dist = delta['old_distribution']
        total_new = sum(new_dist.values())
        total_old = sum(old_dist.values())
        
        plt.style.use('dark_background')
        fig, axes = plt.subplots(2, 2, figsize=(18, 14), facecolor='#0a0a12')
        fig.suptitle('IT³ DELTA ANALYSIS: New vs Old Objects',
                    color='white', fontsize=20, fontweight='bold', y=0.98)
        
        # Plot 1: Side-by-side S_n histograms
        ax = axes[0, 0]
        all_sn = sorted(set(list(new_dist.keys()) + list(old_dist.keys())))
        x = np.arange(len(all_sn))
        width = 0.35
        
        old_pcts = [old_dist.get(sn, 0) / total_old * 100 for sn in all_sn]
        new_pcts = [new_dist.get(sn, 0) / total_new * 100 for sn in all_sn]
        
        bars1 = ax.bar(x - width/2, old_pcts, width, label='OLD objects',
                      color='#8A2BE2', alpha=0.7, edgecolor='white', linewidth=0.5)
        bars2 = ax.bar(x + width/2, new_pcts, width, label='NEW objects',
                      color='#00FF00', alpha=0.8, edgecolor='white', linewidth=0.5)
        
        ax.set_xlabel('Floor Level (Sₙ)', color='white', fontsize=12)
        ax.set_ylabel('Percentage (%)', color='white', fontsize=12)
        ax.set_title('Sₙ Distribution: OLD vs NEW', color='white', fontsize=14, fontweight='bold')
        ax.set_xticks(x)
        ax.set_xticklabels(all_sn)
        ax.legend()
        ax.grid(True, alpha=0.2)
        
        # Plot 2: New objects on 2×3 lattice
        ax = axes[0, 1]
        for sk in [1, 2]:
            for sh in [1, 2, 3]:
                rect = plt.Rectangle((sk - 0.45, sh - 0.45), 0.9, 0.9,
                                    fill=False, color='#9370DB',
                                    linewidth=2, alpha=0.8)
                ax.add_patch(rect)
                ax.text(sk, sh, f'({sk},{sh})', color='#E6E6FA',
                       fontsize=14, ha='center', va='center', alpha=0.3)
        
        rng = np.random.default_rng(42)
        for code, data in delta['new_analyzed'].items():
            res = data['result']
            obj = data['object']
            if res.S_k in [1, 2] and res.S_h in [1, 2, 3]:
                jitter_x = rng.uniform(-0.35, 0.35)
                jitter_y = rng.uniform(-0.35, 0.35)
                color = '#00FF00' if obj.obj_type == 'asteroid' else '#FF1493'
                ax.scatter(res.S_k + jitter_x, res.S_h + jitter_y,
                          color=color, s=20, alpha=0.7, edgecolors='none')
        
        ax.set_xlabel('Sₖ (Transverse)', color='white', fontsize=12)
        ax.set_ylabel('Sₕ (Vertical)', color='white', fontsize=12)
        ax.set_title('NEW Objects on 2×3 Lattice', color='white', fontsize=14, fontweight='bold')
        ax.set_xticks([1, 2])
        ax.set_yticks([1, 2, 3])
        ax.set_xlim(0.3, 2.7)
        ax.set_ylim(0.3, 3.7)
        ax.grid(True, alpha=0.2)
        
        # Plot 3: Q distribution of new objects
        ax = axes[1, 0]
        Q_values = [data['result'].Q for data in delta['new_analyzed'].values()]
        Q_values_clipped = [q for q in Q_values if q < 1000]
        
        ax.hist(np.log10(Q_values_clipped), bins=50, color='#00FF00',
               alpha=0.7, edgecolor='white', linewidth=0.5)
        ax.axvline(np.log10(243), color='#FF1493', linestyle='--', linewidth=2,
                  label='Phase Transition (243 AU)')
        ax.set_xlabel('log₁₀(Q) [AU]', color='white', fontsize=12)
        ax.set_ylabel('Count', color='white', fontsize=12)
        ax.set_title('Aphelion Distribution of NEW Objects', color='white',
                    fontsize=14, fontweight='bold')
        ax.legend()
        ax.grid(True, alpha=0.2)
        
        # Plot 4: Summary statistics
        ax = axes[1, 1]
        ax.axis('off')
        
        summary_text = [
            f"📊 DELTA ANALYSIS SUMMARY",
            f"",
            f"Total NEW objects: {total_new:,}",
            f"Total OLD objects: {total_old:,}",
            f"",
            f"Sₙ distribution match:",
        ]
        
        matches = 0
        for sn in all_sn:
            new_pct = new_dist.get(sn, 0) / total_new * 100
            old_pct = old_dist.get(sn, 0) / total_old * 100
            diff = abs(new_pct - old_pct)
            tolerance = max(3 * np.sqrt(old_pct * (100 - old_pct) / max(total_new, 1)), 5.0)
            if diff <= tolerance:
                matches += 1
        
        match_rate = (matches / len(all_sn) * 100) if all_sn else 0
        summary_text.append(f"  {matches}/{len(all_sn)} floors match ({match_rate:.1f}%)")
        summary_text.append("")
        
        if match_rate >= 80:
            summary_text.append("🎉 STRONG VALIDATION")
            summary_text.append("New objects follow IT³ quantization!")
        elif match_rate >= 60:
            summary_text.append("✅ Good agreement")
        else:
            summary_text.append("⚠️  Weak match")
        
        for i, line in enumerate(summary_text):
            ax.text(0.1, 0.95 - i * 0.07, line, color='white',
                   fontsize=14 if i == 0 else 12,
                   fontweight='bold' if i == 0 else 'normal',
                   transform=ax.transAxes, va='top')
        
        plt.tight_layout()
        plt.savefig(output_path, dpi=300, bbox_inches='tight', facecolor='#0a0a12')
        plt.close()
        logger.info(f"[✓] Delta visualization saved: {output_path}")


# ============================================================================
# MAIN PIPELINE WITH AUTO-UPDATE
# ============================================================================
class IT3AnalysisPipeline:
    """Complete pipeline with auto-update and delta analysis."""
    
    def __init__(self, config: IT3Config = IT3Config()):
        self.config = config
        self.updater = MPCORBUpdater(directory=".")
        self.loader = UniversalLoader(config)
        self.delta_analyzer = DeltaAnalyzer(config)
    
    def run(self):
        """Main execution flow."""
        logger.info("=" * 95)
        logger.info(" IT³ C-PHAM v2.3: AUTO-UPDATE + DELTA ANALYSIS")
        logger.info(" The Ultimate Out-of-Sample Test of Macroscopic Vacuum Quantization")
        logger.info("=" * 95)
        
        # Step 1: Check for updates and get file
        try:
            current_file, is_new_download = self.updater.check_and_update()
        except RuntimeError as e:
            logger.error(f"Fatal: {e}")
            return
        
        # Step 2: Find previous file for comparison
        previous_file = self._find_previous_file(current_file)
        
        if previous_file is None:
            logger.info("\n" + "=" * 95)
            logger.info(" 📋 FIRST RUN: BASELINE VERSION")
            logger.info("=" * 95)
            logger.info(f" File {current_file.name} saved as BASELINE version.")
            logger.info(" Delta analysis impossible — no previous version for comparison.")
            logger.info(" Run the script again after the next MPC update!")
            logger.info("=" * 95)
            
            # Still run full analysis on baseline
            self._run_full_analysis(current_file)
            return
        
        # Step 3: Load both files
        logger.info("\n" + "=" * 95)
        logger.info(" DELTA ANALYSIS MODE")
        logger.info("=" * 95)
        logger.info(f" OLD file: {previous_file.name}")
        logger.info(f" NEW file: {current_file.name}")
        
        old_objects = self.loader.load(str(previous_file))
        new_objects = self.loader.load(str(current_file))
        
        # Step 4: Compute delta
        delta = self.delta_analyzer.compute_delta(old_objects, new_objects)
        
        # Step 5: Print report
        self.delta_analyzer.print_delta_report(delta)
        
        # Step 6: Visualize delta
        if delta['added']:
            delta_viz_path = f"delta_analysis_{datetime.now().strftime('%Y%m%d')}.png"
            self.delta_analyzer.visualize_delta(delta, delta_viz_path)
        
        # Step 7: Export new objects to CSV
        if delta['added']:
            self._export_new_objects(delta, current_file)
    
    def _find_previous_file(self, current_file: Path) -> Optional[Path]:
        """Find the most recent file BEFORE the current one."""
        files_with_dates = []
        
        # Check all MPCORB_*.DAT files
        for f in Path(".").glob("MPCORB_*.DAT"):
            if f == current_file:
                continue
            try:
                date_str = f.stem.replace("MPCORB_", "")
                file_date = datetime.strptime(date_str, "%Y-%m-%d")
                files_with_dates.append((f, file_date))
            except ValueError:
                continue
        
        # Check plain MPCORB.DAT
        plain = Path("MPCORB.DAT")
        if plain.exists() and plain != current_file:
            mtime = datetime.fromtimestamp(plain.stat().st_mtime)
            files_with_dates.append((plain, mtime))
        
        if not files_with_dates:
            return None
        
        # Sort by date and return most recent
        files_with_dates.sort(key=lambda x: x[1], reverse=True)
        return files_with_dates[0][0]
    
    def _run_full_analysis(self, filepath: Path):
        """Run full analysis on baseline file (first run)."""
        logger.info(f"\n[*] Running full analysis on {filepath.name}...")
        objects = self.loader.load(str(filepath))
        
        operator = CPHAMOperator(self.config)
        results = {}
        for code, obj in tqdm(objects.items(), desc="C-PHAM analysis", unit="obj"):
            results[code] = operator.apply(obj)
        
        # Compute distribution
        sn_dist: Dict[int, int] = {}
        for res in results.values():
            sn_dist[res.S_n] = sn_dist.get(res.S_n, 0) + 1
        
        total = len(results)
        logger.info("\n" + "=" * 95)
        logger.info(" BASELINE Sₙ DISTRIBUTION (for future comparisons)")
        logger.info("=" * 95)
        for sn in sorted(sn_dist.keys()):
            pct = sn_dist[sn] / total * 100
            logger.info(f"  Sₙ={sn:2d}: {sn_dist[sn]:>10,} objects ({pct:6.2f}%)")
        logger.info("=" * 95)
        
        # Save baseline report
        report_path = f"baseline_report_{datetime.now().strftime('%Y%m%d')}.txt"
        with open(report_path, 'w') as f:
            f.write(f"IT³ C-PHAM v2.3 BASELINE REPORT\n")
            f.write(f"File: {filepath.name}\n")
            f.write(f"Date: {datetime.now().isoformat()}\n")
            f.write(f"Total objects: {total:,}\n\n")
            f.write("S_n distribution:\n")
            for sn in sorted(sn_dist.keys()):
                pct = sn_dist[sn] / total * 100
                f.write(f"  S_n={sn}: {sn_dist[sn]} ({pct:.2f}%)\n")
        logger.info(f"[✓] Baseline report saved: {report_path}")
    
    def _export_new_objects(self, delta: Dict, current_file: Path):
        """Export new objects to CSV for further analysis."""
        date_str = datetime.now().strftime('%Y%m%d')
        csv_path = f"NEW_objects_{date_str}.csv"
        
        with open(csv_path, 'w', newline='', encoding='utf-8') as f:
            writer = csv.writer(f)
            writer.writerow([
                'MPC_Code', 'Name', 'Type', 'a (AU)', 'e', 'i (deg)',
                'omega (deg)', 'Omega (deg)', 'Q (AU)',
                'S_n', 'S_k', 'S_h', 'R_bowl (AU)'
            ])
            for code, data in sorted(delta['new_analyzed'].items()):
                obj = data['object']
                res = data['result']
                writer.writerow([
                    code, obj.name, obj.obj_type,
                    f"{obj.a:.7f}", f"{obj.e:.7f}", f"{obj.i:.5f}",
                    f"{obj.omega:.5f}", f"{obj.Omega:.5f}",
                    f"{res.Q:.4f}",
                    res.S_n, res.S_k, res.S_h, f"{res.R_bowl:.4f}"
                ])
        
        logger.info(f"[✓] New objects exported: {csv_path} ({len(delta['new_analyzed']):,} rows)")


# ============================================================================
# ENTRY POINT
# ============================================================================
def main():
    pipeline = IT3AnalysisPipeline()
    pipeline.run()


if __name__ == "__main__":
    main()