#!/usr/bin/env python3
"""
Table Query - Data Access Layer
================================
Abstracts data sources (SQLite, CSV, etc.) for graph module.

Provides adapter pattern for different data sources while maintaining
consistent interface for plotting functions.

Features:
- Abstract DataAdapter interface
- SQLiteAdapter for database files
- CSVAdapter for CSV files
- Column utilities and labels
- Data alignment for drift comparison

Author: Kynsight
Version: 1.0.0
"""

from __future__ import annotations
from abc import ABC, abstractmethod
from typing import List, Dict, Tuple, Optional, Any
from dataclasses import dataclass
from pathlib import Path
import sqlite3

import numpy as np


# =============================================================================
# Data Structures
# =============================================================================

@dataclass
class SessionInfo:
    """Session metadata."""
    session_id: str
    created_at: str
    description: str
    run_count: int


@dataclass
class RunInfo:
    """Run metadata."""
    session_id: str
    session_name: str
    run_number: int
    sample_count: int
    start_time_ns: int
    end_time_ns: int
    duration_ms: float


@dataclass
class TelemetryData:
    """Decoded telemetry data (source-agnostic)."""
    session_id: str
    session_name: str
    run_no: int
    
    # Time axes
    t_ns: Optional[np.ndarray] = None
    time_ms: Optional[np.ndarray] = None
    
    # UART decoded data
    motor_current: Optional[np.ndarray] = None
    encoder_value: Optional[np.ndarray] = None
    relative_encoder_value: Optional[np.ndarray] = None
    v24_pec_diff: Optional[np.ndarray] = None
    pwm: Optional[np.ndarray] = None
    
    # I2C decoded data
    i2c_raw14: Optional[np.ndarray] = None
    i2c_zero_raw14: Optional[np.ndarray] = None
    i2c_delta_raw14: Optional[np.ndarray] = None
    i2c_angle_deg: Optional[np.ndarray] = None
    i2c_zero_angle_deg: Optional[np.ndarray] = None
    
    # Derived data
    angular_velocity: Optional[np.ndarray] = None


# =============================================================================
# Abstract Adapter
# =============================================================================

class DataAdapter(ABC):
    """
    Abstract data source adapter.
    
    Implement this interface for each data source type (SQLite, CSV, etc.)
    All adapters provide the same interface for loading telemetry data.
    """
    
    @abstractmethod
    def connect(self) -> bool:
        """
        Connect to data source.
        
        Returns:
            True on success, False on failure
        """
        pass
    
    @abstractmethod
    def close(self):
        """Close connection to data source."""
        pass
    
    @abstractmethod
    def get_sessions(self) -> List[SessionInfo]:
        """
        Get all sessions from data source.
        
        Returns:
            List of SessionInfo objects
        """
        pass
    
    @abstractmethod
    def get_runs(self, session_id: str) -> List[RunInfo]:
        """
        Get all runs for a specific session.
        
        Args:
            session_id: Session identifier
        
        Returns:
            List of RunInfo objects
        """
        pass
    
    @abstractmethod
    def load_run_data(self, session_id: str, run_no: int) -> Optional[TelemetryData]:
        """
        Load telemetry data for one run.
        
        Args:
            session_id: Session identifier
            run_no: Run number
        
        Returns:
            TelemetryData object or None on error
        """
        pass


# =============================================================================
# SQLite Adapter
# =============================================================================

class SQLiteAdapter(DataAdapter):
    """
    SQLite database adapter.
    
    Works with decoded_telemetry table schema.
    """
    
    def __init__(self, db_path: str):
        """
        Initialize SQLite adapter.
        
        Args:
            db_path: Path to SQLite database file
        """
        self.db_path = db_path
        self._conn: Optional[sqlite3.Connection] = None
    
    def connect(self) -> bool:
        """Open database connection."""
        try:
            self._conn = sqlite3.connect(self.db_path)
            self._conn.row_factory = sqlite3.Row
            return True
        except Exception as e:
            print(f"[SQLite ERROR] Failed to connect: {e}")
            return False
    
    def close(self):
        """Close database connection."""
        if self._conn:
            self._conn.close()
            self._conn = None
    
    def get_sessions(self) -> List[SessionInfo]:
        """Get all sessions from database."""
        if not self._conn:
            return []
        
        try:
            cursor = self._conn.cursor()
            
            cursor.execute("""
                SELECT 
                    s.session_id,
                    s.created_at,
                    s.description,
                    COUNT(DISTINCT t.run_no) as run_count
                FROM sessions s
                LEFT JOIN telemetry_decoded t ON s.session_id = t.session_id
                GROUP BY s.session_id
                ORDER BY s.created_at DESC
            """)
            
            sessions = []
            for row in cursor.fetchall():
                sessions.append(SessionInfo(
                    session_id=row['session_id'],
                    created_at=row['created_at'] or '',
                    description=row['description'] or '',
                    run_count=row['run_count'] or 0
                ))
            
            return sessions
        
        except Exception as e:
            print(f"[SQLite ERROR] Failed to get sessions: {e}")
            return []
    
    def get_runs(self, session_id: str) -> List[RunInfo]:
        """Get all runs for a session."""
        if not self._conn:
            return []
        
        try:
            cursor = self._conn.cursor()
            
            cursor.execute("""
                SELECT
                    session_id,
                    session_name,
                    run_no,
                    COUNT(*) as sample_count,
                    MIN(t_ns) as start_time_ns,
                    MAX(t_ns) as end_time_ns
                FROM telemetry_decoded
                WHERE session_id = ?
                GROUP BY session_id, run_no, session_name
                ORDER BY run_no
            """, (session_id,))

            runs = []
            for row in cursor.fetchall():
                duration_ns = row['end_time_ns'] - row['start_time_ns']
                duration_ms = duration_ns / 1_000_000.0

                runs.append(RunInfo(
                    session_id=row['session_id'],
                    session_name=row['session_name'],
                    run_number=row['run_no'],
                    sample_count=row['sample_count'],
                    start_time_ns=row['start_time_ns'],
                    end_time_ns=row['end_time_ns'],
                    duration_ms=duration_ms
                ))
            
            return runs
        
        except Exception as e:
            print(f"[SQLite ERROR] Failed to get runs: {e}")
            return []
    
    def load_run_data(self, session_id: str, run_no: int) -> Optional[TelemetryData]:
        """Load telemetry data for one run."""
        if not self._conn:
            return None
        
        try:
            cursor = self._conn.cursor()
            
            cursor.execute("""
                SELECT
                    session_name,
                    t_ns,
                    time_ms,
                    motor_current,
                    encoder_value,
                    relative_encoder_value,
                    v24_pec_diff,
                    pwm,
                    i2c_raw14,
                    i2c_zero_raw14,
                    i2c_delta_raw14,
                    i2c_angle_deg,
                    i2c_zero_angle_deg,
                    angular_velocity
                FROM telemetry_decoded
                WHERE session_id = ? AND run_no = ?
                ORDER BY t_ns
            """, (session_id, run_no))

            rows = cursor.fetchall()

            if not rows:
                return None

            # Get session_name from first row
            session_name = rows[0]['session_name']

            # Extract columns
            data = TelemetryData(
                session_id=session_id,
                session_name=session_name,
                run_no=run_no,
                t_ns=self._extract_column(rows, 't_ns', dtype=np.int64),
                time_ms=self._extract_column(rows, 'time_ms', dtype=np.int64),
                motor_current=self._extract_column(rows, 'motor_current', dtype=np.float32),
                encoder_value=self._extract_column(rows, 'encoder_value', dtype=np.float32),
                relative_encoder_value=self._extract_column(rows, 'relative_encoder_value', dtype=np.float32),
                v24_pec_diff=self._extract_column(rows, 'v24_pec_diff', dtype=np.float32),
                pwm=self._extract_column(rows, 'pwm', dtype=np.float32),
                i2c_raw14=self._extract_column(rows, 'i2c_raw14', dtype=np.float32),
                i2c_zero_raw14=self._extract_column(rows, 'i2c_zero_raw14', dtype=np.float32),
                i2c_delta_raw14=self._extract_column(rows, 'i2c_delta_raw14', dtype=np.float32),
                i2c_angle_deg=self._extract_column(rows, 'i2c_angle_deg', dtype=np.float32),
                i2c_zero_angle_deg=self._extract_column(rows, 'i2c_zero_angle_deg', dtype=np.float32),
                angular_velocity=self._extract_column(rows, 'angular_velocity', dtype=np.float32)
            )
            
            return data
        
        except Exception as e:
            print(f"[SQLite ERROR] Failed to load run data: {e}")
            return None
    
    def _extract_column(self, rows: List, column: str, dtype=np.float32) -> Optional[np.ndarray]:
        """
        Extract column from rows, handling NULL values.
        
        Returns None if all values are NULL.
        """
        values = [row[column] for row in rows]
        
        # Check if any non-NULL values
        if all(v is None for v in values):
            return None
        
        # Replace None with NaN
        values = [v if v is not None else np.nan for v in values]
        
        return np.array(values, dtype=dtype)


# =============================================================================
# CSV Adapter
# =============================================================================

class CSVAdapter(DataAdapter):
    """
    CSV file adapter.
    
    Expected CSV format:
    session_id,session_name,run_no,t_ns,time_ms,motor_current,encoder_value,...
    
    Single CSV file containing all sessions and runs.
    """
    
    def __init__(self, csv_path: str):
        """
        Initialize CSV adapter.
        
        Args:
            csv_path: Path to CSV file
        """
        self.csv_path = csv_path
        self._df = None  # pandas DataFrame
    
    def connect(self) -> bool:
        """Load CSV file."""
        try:
            import pandas as pd
            self._df = pd.read_csv(self.csv_path)
            
            # Validate required columns
            required = ['session_id', 'session_name', 'run_no']
            missing = [col for col in required if col not in self._df.columns]
            
            if missing:
                print(f"[CSV ERROR] Missing required columns: {missing}")
                return False
            
            return True
        
        except Exception as e:
            print(f"[CSV ERROR] Failed to load CSV: {e}")
            return False
    
    def close(self):
        """Release DataFrame."""
        self._df = None
    
    def get_sessions(self) -> List[SessionInfo]:
        """Get all sessions from CSV."""
        if self._df is None:
            return []
        
        try:
            # Group by session_id
            session_groups = self._df.groupby('session_id')
            
            sessions = []
            for session_id, group in session_groups:
                run_count = group['run_no'].nunique()
                
                # Try to get created_at if column exists
                created_at = ''
                if 'created_at' in group.columns and len(group) > 0:
                    created_at = str(group['created_at'].iloc[0])
                
                sessions.append(SessionInfo(
                    session_id=str(session_id),
                    created_at=created_at,
                    description='',  # Not in CSV
                    run_count=run_count
                ))
            
            return sessions
        
        except Exception as e:
            print(f"[CSV ERROR] Failed to get sessions: {e}")
            return []
    
    def get_runs(self, session_id: str) -> List[RunInfo]:
        """Get all runs for a session."""
        if self._df is None:
            return []
        
        try:
            # Filter by session
            session_df = self._df[self._df['session_id'] == session_id]
            
            # Group by run_no
            run_groups = session_df.groupby('run_no')
            
            runs = []
            for run_no, group in run_groups:
                sample_count = len(group)
                
                # Get session_name
                session_name = str(group['session_name'].iloc[0]) if 'session_name' in group.columns else ''
                
                # Get time info
                if 't_ns' in group.columns:
                    start_time_ns = int(group['t_ns'].min())
                    end_time_ns = int(group['t_ns'].max())
                    duration_ms = (end_time_ns - start_time_ns) / 1_000_000.0
                else:
                    start_time_ns = 0
                    end_time_ns = 0
                    duration_ms = 0.0
                
                runs.append(RunInfo(
                    session_id=session_id,
                    session_name=session_name,
                    run_number=int(run_no),
                    sample_count=sample_count,
                    start_time_ns=start_time_ns,
                    end_time_ns=end_time_ns,
                    duration_ms=duration_ms
                ))
            
            # Sort by run_number
            runs.sort(key=lambda r: r.run_number)
            
            return runs
        
        except Exception as e:
            print(f"[CSV ERROR] Failed to get runs: {e}")
            return []
    
    def load_run_data(self, session_id: str, run_no: int) -> Optional[TelemetryData]:
        """Load telemetry data for one run."""
        if self._df is None:
            return None
        
        try:
            # Filter by session and run
            run_df = self._df[
                (self._df['session_id'] == session_id) & 
                (self._df['run_no'] == run_no)
            ]
            
            if len(run_df) == 0:
                return None
            
            # Sort by timestamp
            if 't_ns' in run_df.columns:
                run_df = run_df.sort_values('t_ns')
            
            # Get session_name
            session_name = str(run_df['session_name'].iloc[0]) if 'session_name' in run_df.columns else ''
            
            # Extract columns
            data = TelemetryData(
                session_id=session_id,
                session_name=session_name,
                run_no=run_no,
                t_ns=self._extract_column_csv(run_df, 't_ns', dtype=np.int64),
                time_ms=self._extract_column_csv(run_df, 'time_ms', dtype=np.int64),
                motor_current=self._extract_column_csv(run_df, 'motor_current', dtype=np.float32),
                encoder_value=self._extract_column_csv(run_df, 'encoder_value', dtype=np.float32),
                relative_encoder_value=self._extract_column_csv(run_df, 'relative_encoder_value', dtype=np.float32),
                v24_pec_diff=self._extract_column_csv(run_df, 'v24_pec_diff', dtype=np.float32),
                pwm=self._extract_column_csv(run_df, 'pwm', dtype=np.float32),
                i2c_raw14=self._extract_column_csv(run_df, 'i2c_raw14', dtype=np.float32),
                i2c_zero_raw14=self._extract_column_csv(run_df, 'i2c_zero_raw14', dtype=np.float32),
                i2c_delta_raw14=self._extract_column_csv(run_df, 'i2c_delta_raw14', dtype=np.float32),
                i2c_angle_deg=self._extract_column_csv(run_df, 'i2c_angle_deg', dtype=np.float32),
                i2c_zero_angle_deg=self._extract_column_csv(run_df, 'i2c_zero_angle_deg', dtype=np.float32),
                angular_velocity=self._extract_column_csv(run_df, 'angular_velocity', dtype=np.float32)
            )
            
            return data
        
        except Exception as e:
            print(f"[CSV ERROR] Failed to load run data: {e}")
            return None
    
    def _extract_column_csv(self, df, column: str, dtype=np.float32) -> Optional[np.ndarray]:
        """
        Extract column from DataFrame, handling missing columns and NaN.
        
        Returns None if column doesn't exist or all values are NaN.
        """
        if column not in df.columns:
            return None
        
        values = df[column].values
        
        # Check if all NaN
        if np.all(np.isnan(values.astype(float, errors='ignore'))):
            return None
        
        return values.astype(dtype)


# =============================================================================
# Column Utilities
# =============================================================================

def get_available_columns() -> List[str]:
    """
    Get list of all plottable columns.
    
    Returns:
        List of column names
    """
    return [
        't_ns',
        'time_ms',
        'motor_current',
        'encoder_value',
        'relative_encoder_value',
        'v24_pec_diff',
        'pwm',
        'i2c_raw14',
        'i2c_zero_raw14',
        'i2c_delta_raw14',
        'i2c_angle_deg',
        'i2c_zero_angle_deg',
        'angular_velocity'
    ]


def get_column_label(column: str) -> str:
    """
    Get human-readable label for column.
    
    Args:
        column: Column name
    
    Returns:
        Human-readable label
    """
    labels = {
        't_ns': 'Time (ns)',
        'time_ms': 'Time (ms)',
        'motor_current': 'Motor Current',
        'encoder_value': 'Encoder Value',
        'relative_encoder_value': 'Relative Encoder',
        'v24_pec_diff': 'V24 PEC Diff',
        'pwm': 'PWM',
        'i2c_raw14': 'I2C Raw (14-bit)',
        'i2c_zero_raw14': 'I2C Zero Raw',
        'i2c_delta_raw14': 'I2C Delta Raw',
        'i2c_angle_deg': 'Angle (degrees)',
        'i2c_zero_angle_deg': 'Zero Angle (degrees)',
        'angular_velocity': 'Angular Velocity'
    }
    return labels.get(column, column)


def get_column_groups() -> Dict[str, List[str]]:
    """
    Get columns grouped by category.
    
    Returns:
        Dictionary of category -> list of columns
    """
    return {
        'Time': ['t_ns', 'time_ms'],
        'UART': ['motor_current', 'encoder_value', 'relative_encoder_value', 
                 'v24_pec_diff', 'pwm'],
        'I2C': ['i2c_raw14', 'i2c_zero_raw14', 'i2c_delta_raw14', 
                'i2c_angle_deg', 'i2c_zero_angle_deg'],
        'Derived': ['angular_velocity']
    }


def get_default_columns() -> List[str]:
    """
    Get default selected columns.
    
    Returns:
        List of column names to check by default
    """
    return [
        'time_ms',
        'motor_current',
        'encoder_value',
        'relative_encoder_value',
        'pwm',
        'i2c_angle_deg',
        'angular_velocity'
    ]


# =============================================================================
# Data Alignment
# =============================================================================

def align_data_to_reference(
    data_list: List[TelemetryData],
    x_column: str,
    reference_index: int = 0
) -> List[TelemetryData]:
    """
    Align multiple runs to reference run's X-axis.
    
    Useful for drift comparison when runs have different timestamps
    or sampling rates.
    
    Args:
        data_list: List of TelemetryData objects
        x_column: Column name to use as X-axis
        reference_index: Index of reference run (default: 0)
    
    Returns:
        List of TelemetryData objects aligned to reference
    """
    if reference_index >= len(data_list):
        reference_index = 0
    
    reference = data_list[reference_index]
    ref_x = getattr(reference, x_column, None)
    
    if ref_x is None:
        return data_list
    
    aligned_list = [reference]  # Reference stays as-is
    
    for idx, data in enumerate(data_list):
        if idx == reference_index:
            continue
        
        x_data = getattr(data, x_column, None)
        
        if x_data is None:
            aligned_list.append(data)
            continue
        
        # Create aligned data object
        aligned = TelemetryData(
            session_id=data.session_id,
            session_name=data.session_name,
            run_no=data.run_no
        )
        
        # Interpolate all columns to reference X
        for col in get_available_columns():
            if col == x_column:
                # X column uses reference
                setattr(aligned, col, ref_x)
            else:
                y_data = getattr(data, col, None)
                if y_data is not None and len(y_data) == len(x_data):
                    # Interpolate
                    y_aligned = np.interp(ref_x, x_data, y_data)
                    setattr(aligned, col, y_aligned)
        
        aligned_list.append(aligned)
    
    return aligned_list


# =============================================================================
# Demo
# =============================================================================

if __name__ == "__main__":
    print("Table Query - Data Access Layer")
    print("=" * 60)
    print()
    print("Available Adapters:")
    print("  • SQLiteAdapter - for .db files")
    print("  • CSVAdapter - for .csv files")
    print()
    print("Column Utilities:")
    print(f"  • {len(get_available_columns())} plottable columns")
    print(f"  • {len(get_column_groups())} categories")
    print(f"  • {len(get_default_columns())} default selections")
    print()
    print("Usage:")
    print("  from table_query import SQLiteAdapter, CSVAdapter")
    print("  adapter = SQLiteAdapter('./database/ehinge.db')")
    print("  adapter.connect()")
    print("  sessions = adapter.get_sessions()")
    print("  data = adapter.load_run_data('Session_A', 1)")
