"""
Annotation Evaluation Script

This script processes entity-tagged text data from multiple annotators and computes performance metrics 
(precision, recall, F1, F2) by comparing them to ground truth annotations. It supports multiple annotation 
formats (BIO, span labels) and handles reports generation including mistakes analysis, per-entity type 
breakdowns, and overall statistics.

Main Features:
- Entity extraction and normalization
- Label generation (BIO, span)
- Per-annotator comparison and evaluation
- Metrics computation (precision, recall, F1, F2)
- Report generation in structured text format

The input csv file should have the following columns:
- id: the id of the row
- target_text: the text to be annotated
- target_text_{annotator}_sanitized: the text annotated by the annotator

Arguments:
    --input_csv_path (str): Path to the CSV file containing annotations. The file should include columns identifying the text, entities, and annotator.
    --annotator (str): Name of the annotator whose annotations are to be evaluated.
    --skip_entities (List[str], optional): List of entity types to skip during evaluation (e.g., EMAIL_ADDRESS, PHONE_NUMBER).

Example usage:
    python evaluate_annotations.py --input_csv_path path/to/file.csv --annotator cybe4sent1nel0 --skip_entities EMAIL_ADDRESS PHONE_NUMBER


"""
import pandas as pd
import re
import os
from typing import Dict, List, Set, Tuple
from collections import defaultdict
from datetime import datetime
import argparse

# Define valid entity types
VALID_ENTITIES = {
    'PERSON', 'PHONE_NUMBER', 'LOCATION', 'CREDIT_CARD', 'CRYPTO', 'IBAN_CODE',
    'IP_ADDRESS', 'EMAIL_ADDRESS', 'URL', 'DATE_TIME', 'NIF', 'MEDICAL_LICENSE',
    'US_SSN', 'US_BANK_NUMBER', 'US_DRIVER_LICENSE', 'US_ITIN', 'US_PASSPORT',
    'ORGANIZATION', 'ADDRESS', 'NRP', 'DNI', 'NIE', 'IBAN', 'EUROPEAN_BANK_ACCOUNT'
}

# ============ DATA NORMALIZATION FUNCTIONS ============

def find_entities_with_positions(text: str, skip_entities: Set[str] = set()) -> List[Tuple[str, int, int, str]]:
    """
    Find entities marked with brackets and their positions in the text.
    Returns: List of (entity_type, start_pos, end_pos, full_tag)
    """
    if not isinstance(text, str) or pd.isna(text):
        return []
    
    entities = []
    valid_entities = VALID_ENTITIES - skip_entities
    pattern = r'\[({})\]'.format('|'.join(valid_entities))
    
    for match in re.finditer(pattern, text):
        entity_type = match.group(1)
        if entity_type not in skip_entities:  
            start = match.start()
            end = match.end()
            full_tag = match.group(0)
            entities.append((entity_type, start, end, full_tag))
    
    return sorted(entities, key=lambda x: x[1])

def generate_span_labels(text: str, entities: List[Tuple[str, int, int, str]]) -> str:
    """
    Generate span labels in format: start:end:entity_type|start:end:entity_type
    """
    if not isinstance(text, str) or pd.isna(text) or not entities:
        return ""
    
    spans = []
    for entity_type, start, end, _ in entities:
        spans.append(f"{start}:{end}:{entity_type}")
    
    return "|".join(spans)

def generate_bio_labels(text: str, entities: List[Tuple[str, int, int, str]]) -> str:
    """
    Generate BIO labels for each character in the text
    """
    if not isinstance(text, str) or pd.isna(text):
        return ""
    
    # Initialize all positions as O (Outside)
    bio_labels = ['O'] * len(text)
    
    # Mark entity positions
    for entity_type, start, end, _ in entities:
        # Mark B (Beginning)
        if start < len(bio_labels):
            bio_labels[start] = f"B-{entity_type}"
        
        # Mark I (Inside) for the rest of the entity
        for i in range(start + 1, end):
            if i < len(bio_labels):
                bio_labels[i] = f"I-{entity_type}"
    
    return "".join(bio_labels)

def normalize_annotations(df: pd.DataFrame, annotator_config: Dict[str, Dict[str, str]], skip_entities: Set[str] = set()) -> pd.DataFrame:
    """
    Normalize annotations for ground truth and all annotators.
    """
    # First normalize ground truth
    ground_truth_entities = df['target_text'].apply(lambda x: find_entities_with_positions(x, skip_entities))
    df['span_labels'] = df.apply(lambda row: generate_span_labels(row['target_text'], ground_truth_entities[row.name]), axis=1)
    df['mbert_bio_labels'] = df.apply(lambda row: generate_bio_labels(row['target_text'], ground_truth_entities[row.name]), axis=1)
    
    # Then normalize each annotator's data
    for annotator, config in annotator_config.items():
        target_col = config['target_text']
        if target_col not in df.columns:
            print(f"Warning: Column {target_col} not found for annotator {annotator}")
            continue
            
        # Fill NaN values with empty string to avoid errors
        df[target_col] = df[target_col].fillna("")
            
        # Generate entities and labels
        annotator_entities = df[target_col].apply(lambda x: find_entities_with_positions(x, skip_entities))
        df[f'span_labels_{annotator}'] = df.apply(
            lambda row: generate_span_labels(row[target_col], annotator_entities[row.name]), 
            axis=1
        )
        df[f'mbert_bio_labels_{annotator}'] = df.apply(
            lambda row: generate_bio_labels(row[target_col], annotator_entities[row.name]),
            axis=1
        )
    
    return df

# ============ METRICS CALCULATION FUNCTIONS ============

def calculate_metrics(df: pd.DataFrame, annotator_config: Dict[str, Dict[str, str]], skip_entities: Set[str] = set()) -> Dict:
    """
    Calculate metrics comparing ground truth with annotators
    """
    stats = {
        'total_rows': len(df),
        'entity_counts': defaultdict(lambda: defaultdict(int)),
        'metrics_per_annotator': defaultdict(dict),
        'metrics_per_entity_type': defaultdict(lambda: defaultdict(dict)),
        'mistakes': defaultdict(list)
    }
    
    # First calculate ground truth entities once for all annotators
    all_true_entities = []
    for idx, row in df.iterrows():
        ground_truth = find_entities_with_positions(row['target_text'], skip_entities)
        # Store entities with row index for exact matching
        for entity in ground_truth:
            all_true_entities.append((idx, entity[0], entity[1], entity[2]))
            stats['entity_counts']['ground_truth'][entity[0]] += 1
    
    true_set = set(all_true_entities)
    total_ground_truth = len(true_set)
    
    # Process each annotator
    for annotator, config in annotator_config.items():
        target_col = config['target_text']
        if target_col not in df.columns:
            print(f"Warning: Column {target_col} not found in the dataset")
            continue
        
        # Collect predicted entities
        all_pred_entities = []
        
        # Process each row
        for idx, row in df.iterrows():
            pred_entities = find_entities_with_positions(row[target_col], skip_entities)
            
            # Store entities with row index for exact matching
            for entity in pred_entities:
                all_pred_entities.append((idx, entity[0], entity[1], entity[2]))
                stats['entity_counts'][annotator][entity[0]] += 1
            
            # Record mistakes
            ground_truth = [e for e in all_true_entities if e[0] == idx]
            gt_set = {(e[1], e[2], e[3]) for e in ground_truth}
            pred_set = {(e[0], e[1], e[2]) for e in pred_entities}
            
            if gt_set != pred_set:
                false_positives = list(pred_set - gt_set)
                false_negatives = list(gt_set - pred_set)
                
                if false_positives or false_negatives:
                    stats['mistakes'][annotator].append({
                        'id': row.get('id', idx),
                        'text': row['target_text'],
                        'annotated_text': row[target_col],
                        'ground_truth': list(gt_set),
                        'prediction': list(pred_set),
                        'false_positives': false_positives,
                        'false_negatives': false_negatives
                    })
        
        # Calculate overall metrics
        pred_set = set(all_pred_entities)
        
        tp = len(true_set & pred_set)
        fp = len(pred_set - true_set)
        fn = len(true_set - pred_set)
        
        precision = tp / len(pred_set) if pred_set else 0
        recall = tp / len(true_set) if true_set else 0
        f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0
        f2 = 5 * (precision * recall) / (4 * precision + recall) if (precision + recall) > 0 else 0
        
        stats['metrics_per_annotator'][annotator] = {
            'true_positives': tp,
            'false_positives': fp,
            'false_negatives': fn,
            'precision': precision,
            'recall': recall,
            'f1_score': f1,
            'f2_score': f2,
            'total_entities': total_ground_truth  # Use the same ground truth count for all annotators
        }
        
        # Calculate per-entity type metrics
        for entity_type in VALID_ENTITIES - skip_entities:  # Only evaluate non-skipped entities
            true_type = {e for e in true_set if e[1] == entity_type}
            pred_type = {e for e in pred_set if e[1] == entity_type}
            
            if not true_type and not pred_type:
                continue
            
            tp_type = len(true_type & pred_type)
            fp_type = len(pred_type - true_type)
            fn_type = len(true_type - pred_type)
            
            precision_type = tp_type / len(pred_type) if pred_type else 0
            recall_type = tp_type / len(true_type) if true_type else 0
            f1_type = 2 * (precision_type * recall_type) / (precision_type + recall_type) if (precision_type + recall_type) > 0 else 0
            f2_type = 5 * (precision_type * recall_type) / (4 * precision_type + recall_type) if (precision_type + recall_type) > 0 else 0
            
            if tp_type > 0 or fp_type > 0 or fn_type > 0:
                stats['metrics_per_entity_type'][annotator][entity_type] = {
                    'true_positives': tp_type,
                    'false_positives': fp_type,
                    'false_negatives': fn_type,
                    'precision': precision_type,
                    'recall': recall_type,
                    'f1_score': f1_type,
                    'f2_score': f2_type,
                    'total_entities': len(true_type)
                }
    
    return stats

# ============ REPORT GENERATION FUNCTIONS ============

def generate_overall_report(stats: Dict, output_dir: str, input_file: str, annotator_config: Dict[str, Dict[str, str]], skip_entities: Set[str] = set()):
    """Generate overall statistics report"""
    with open(os.path.join(output_dir, 'overall_report.txt'), 'w') as f:
        f.write("=== Overall Annotation Statistics ===\n\n")
        
        # Add input file information
        f.write(f"Input File: {input_file}\n")
        
        # Add information about skipped entities
        if skip_entities:
            f.write(f"\nExcluded Entity Types: {', '.join(sorted(skip_entities))}\n")
        
        # Add annotator configuration information
        f.write("\nAnnotator Configurations:\n")
        for annotator, config in annotator_config.items():
            f.write(f"\n{annotator}:\n")
            for key, value in config.items():
                f.write(f"  {key}: {value}\n")
        f.write("\n" + "=" * 50 + "\n\n")
        
        f.write(f"Total rows analyzed: {stats['total_rows']}\n\n")
        
        f.write("Ground Truth Entity Counts:\n")
        for entity_type, count in sorted(stats['entity_counts']['ground_truth'].items()):
            f.write(f"[{entity_type}]: {count}\n")
        
        f.write("\nAnnotator Entity Counts:\n")
        for annotator in stats['entity_counts']:
            if annotator != 'ground_truth':
                f.write(f"\n{annotator}:\n")
                for entity_type, count in sorted(stats['entity_counts'][annotator].items()):
                    f.write(f"[{entity_type}]: {count}\n")

def generate_entity_report(stats: Dict, output_dir: str, annotator_names: List[str], skip_entities: Set[str] = set()):
    """Generate per-entity type performance report"""
    with open(os.path.join(output_dir, 'entity_performance.txt'), 'w') as f:
        f.write("=== Entity Type Performance by Annotator ===\n\n")
        
        # Add information about skipped entities
        if skip_entities:
            f.write(f"Note: The following entity types were excluded from evaluation:\n")
            f.write(f"{', '.join(sorted(skip_entities))}\n\n")
            f.write("=" * 50 + "\n\n")
        
        for annotator in annotator_names:
            if annotator in stats['metrics_per_entity_type']:
                f.write(f"\n{annotator.upper()}:\n")
                for entity_type in sorted(VALID_ENTITIES - skip_entities):
                    if entity_type in stats['metrics_per_entity_type'][annotator]:
                        metrics = stats['metrics_per_entity_type'][annotator][entity_type]
                        f.write(f"\n  {entity_type}:\n")
                        f.write(f"    Precision: {metrics['precision']:.4f}\n")
                        f.write(f"    Recall: {metrics['recall']:.4f}\n")
                        f.write(f"    F1 Score: {metrics['f1_score']:.4f}\n")
                        f.write(f"    F2 Score: {metrics['f2_score']:.4f}\n")
                        f.write(f"    True Positives: {metrics['true_positives']}\n")
                        f.write(f"    False Positives: {metrics['false_positives']}\n")
                        f.write(f"    False Negatives: {metrics['false_negatives']}\n")

def generate_mistakes_report(stats: Dict, output_dir: str, annotator_names: List[str], skip_entities: Set[str] = set()):
    """Generate detailed mistakes report"""
    with open(os.path.join(output_dir, 'mistakes.txt'), 'w') as f:
        f.write("=== Detailed Mistakes Analysis ===\n\n")
        
        # Add information about skipped entities
        if skip_entities:
            f.write(f"Note: The following entity types were excluded from evaluation:\n")
            f.write(f"{', '.join(sorted(skip_entities))}\n\n")
            f.write("=" * 50 + "\n\n")
        
        for annotator in annotator_names:
            if annotator in stats['mistakes'] and stats['mistakes'][annotator]:
                f.write(f"\n{annotator.upper()} Mistakes ({len(stats['mistakes'][annotator])} total):\n")
                for mistake in stats['mistakes'][annotator]:
                    f.write(f"\nExample {mistake['id']}:\n")
                    f.write(f"Original text: {mistake['text']}\n")
                    f.write(f"Annotated text: {mistake['annotated_text']}\n")
                    
                    if mistake['false_negatives']:
                        f.write("\nMissed entities (should have been anonymized):\n")
                        for entity_type, start, end in mistake['false_negatives']:
                            f.write(f"- {entity_type} at position {start}-{end}\n")
                    
                    if mistake['false_positives']:
                        f.write("\nIncorrect anonymizations:\n")
                        for entity_type, start, end in mistake['false_positives']:
                            f.write(f"- {entity_type} at position {start}-{end}\n")
                    
                    f.write("-" * 80 + "\n")
            else:
                f.write(f"\n{annotator.upper()}: No mistakes found\n")

def generate_metrics_report(stats: Dict, output_dir: str, annotator_names: List[str], skip_entities: Set[str] = set()):
    """Generate overall metrics report"""
    with open(os.path.join(output_dir, 'metrics.txt'), 'w') as f:
        f.write("=== Overall Metrics by Annotator ===\n\n")
        
        # Add information about skipped entities
        if skip_entities:
            f.write(f"Note: The following entity types were excluded from evaluation:\n")
            f.write(f"{', '.join(sorted(skip_entities))}\n\n")
            f.write("=" * 50 + "\n\n")
        
        for annotator in annotator_names:
            if annotator in stats['metrics_per_annotator']:
                metrics = stats['metrics_per_annotator'][annotator]
                f.write(f"\n{annotator.upper()}:\n")
                f.write(f"  Total Entities in Ground Truth: {metrics['total_entities']}\n")
                f.write(f"  True Positives: {metrics['true_positives']}\n")
                f.write(f"  False Positives: {metrics['false_positives']}\n")
                f.write(f"  False Negatives: {metrics['false_negatives']}\n")
                f.write(f"  Precision: {metrics['precision']:.4f}\n")
                f.write(f"  Recall: {metrics['recall']:.4f}\n")
                f.write(f"  F1 Score: {metrics['f1_score']:.4f}\n")
                f.write(f"  F2 Score: {metrics['f2_score']:.4f}\n")

def get_output_dir(base_dir: str) -> str:
    """Create and return the output directory name with date and sequence number in the same directory as the input file"""
    # Get the directory of the input file
    
    base_name = f"output_metrics_{datetime.now().strftime('%Y%m%d')}"
    counter = 1
    while True:
        dir_name = os.path.join(base_dir, f"{base_name}_{counter}")
        if not os.path.exists(dir_name):
            os.makedirs(dir_name)
            return dir_name
        counter += 1

# ============ MAIN EXECUTION ============

def main():
    parser = argparse.ArgumentParser(description="Annotator Evaluation Script")
    parser.add_argument('--input_csv_path', type=str, required=True, help='Path to input CSV file')
    parser.add_argument('--annotator', type=str, required=True, help='Annotator used to generate the input CSV file, options: cybe4sent1nel0, privateAI')
    parser.add_argument('--skip_entities', type=str, nargs='+', default=[], help='List of entity types to skip in evaluation')
    args = parser.parse_args()

    # Convert skip_entities to a set for faster lookups
    skip_entities = set(args.skip_entities)
    
    # Validate skip_entities
    invalid_entities = skip_entities - VALID_ENTITIES
    if invalid_entities:
        raise ValueError(f"Invalid entities to skip: {invalid_entities}. Valid entities are: {VALID_ENTITIES}")

    df = pd.read_csv(args.input_csv_path, sep=";")

    ANNOTATOR_CONFIG = {
            args.annotator: {
                'target_text': f'target_text_{args.annotator}_sanitized',
                'span_labels': f'span_labels_{args.annotator}_sanitized',
                'mbert_bio_labels': f'mbert_bio_labels_{args.annotator}_sanitized'
        }
    }
  
 
    print("Normalizing annotations...")
    df = normalize_annotations(df, ANNOTATOR_CONFIG, skip_entities)
        
    print("Calculating metrics...")
    stats = calculate_metrics(df, ANNOTATOR_CONFIG, skip_entities)
        
    # Determine output directory
    base_dir = os.path.dirname(os.path.abspath(args.input_csv_path))
    dir_annotator = os.path.join(base_dir, args.annotator)
    print(dir_annotator)
    output_dir = get_output_dir(dir_annotator)
    print(output_dir)

    print("Generating reports...")
    generate_overall_report(stats, output_dir, args.input_csv_path, ANNOTATOR_CONFIG, skip_entities)
    generate_entity_report(stats, output_dir, list(ANNOTATOR_CONFIG.keys()), skip_entities)
    generate_mistakes_report(stats, output_dir, list(ANNOTATOR_CONFIG.keys()), skip_entities)
    generate_metrics_report(stats, output_dir, list(ANNOTATOR_CONFIG.keys()), skip_entities)
        
    print(f"\nAnalysis complete. Reports have been generated in {output_dir}/")
    if skip_entities:
        print(f"Note: The following entities were excluded from evaluation: {', '.join(sorted(skip_entities))}")


if __name__ == "__main__":
    main()