"""
coherence_analyzer.py - Coherence Contract for Musical Generation

This module analyzes generated tracks and songs for coherence metrics,
providing a quantitative assessment of how well a generation holds together.

Key metrics:
- Track budget adherence
- Core vs optional track ratio
- Sample pack consistency
- Tonal/key consistency
- Motif/pattern reuse across sections
- Redundant layer detection
"""

import json
import logging
import re
import time
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple

logger = logging.getLogger("CoherenceAnalyzer")

NOTE_TO_SEMITONE = {
    "c": 0,
    "c#": 1,
    "db": 1,
    "d": 2,
    "d#": 3,
    "eb": 3,
    "e": 4,
    "f": 5,
    "f#": 6,
    "gb": 6,
    "g": 7,
    "g#": 8,
    "ab": 8,
    "a": 9,
    "a#": 10,
    "bb": 10,
    "b": 11,
}

ENHARMONIC_EQUIV = {
    "db": "c#",
    "eb": "d#",
    "gb": "f#",
    "ab": "g#",
    "bb": "a#",
}


@dataclass
class TrackBudgetMetric:
    """Tracks adherence to track count budget."""
    total: int
    budget: int = 12
    status: str = "OK"
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            "total": self.total,
            "budget": self.budget,
            "status": self.status,
            "overage": max(0, self.total - self.budget)
        }


@dataclass
class CoreVsOptionalMetric:
    """Analyzes core vs optional track ratio (target: 70/30)."""
    core: int
    optional: int
    
    @property
    def ratio(self) -> float:
        total = self.core + self.optional
        if total == 0:
            return 0.0
        return round(self.core / total, 2)
    
    @property
    def status(self) -> str:
        if self.ratio >= 0.7:
            return "OK"
        elif self.ratio >= 0.55:
            return "NEEDS_IMPROVEMENT"
        return "POOR"
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            "core": self.core,
            "optional": self.optional,
            "ratio": self.ratio,
            "target_ratio": 0.7,
            "status": self.status
        }


@dataclass
class SamePackMetric:
    """Measures consistency in sample pack usage (target: >60%)."""
    main_pack: Optional[str]
    samples_from_pack: int
    total: int
    
    @property
    def ratio(self) -> float:
        if self.total == 0:
            return 0.0
        return round(self.samples_from_pack / self.total, 2)
    
    @property
    def status(self) -> str:
        if self.ratio >= 0.6:
            return "OK"
        elif self.ratio >= 0.4:
            return "NEEDS_IMPROVEMENT"
        return "POOR"
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            "main_pack": self.main_pack,
            "samples_from_pack": self.samples_from_pack,
            "total": self.total,
            "ratio": self.ratio,
            "target_ratio": 0.6,
            "status": self.status
        }


@dataclass
class TonalConsistencyMetric:
    """Checks key compatibility across samples."""
    key: str
    deviations: int
    total_samples: int
    
    @property
    def status(self) -> str:
        if self.total_samples == 0:
            return "OK"
        deviation_ratio = self.deviations / self.total_samples
        if deviation_ratio <= 0.1:
            return "OK"
        elif deviation_ratio <= 0.25:
            return "NEEDS_IMPROVEMENT"
        return "POOR"
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            "key": self.key,
            "deviations": self.deviations,
            "total_samples": self.total_samples,
            "deviation_rate": round(self.deviations / max(1, self.total_samples), 2),
            "status": self.status
        }


@dataclass
class MotifReuseMetric:
    """Measures pattern/motif reuse across sections."""
    main_motif: Optional[str]
    sections_using_it: int
    total_sections: int
    
    @property
    def coverage(self) -> float:
        if self.total_sections == 0:
            return 0.0
        return round(self.sections_using_it / self.total_sections, 2)
    
    @property
    def status(self) -> str:
        if self.total_sections == 0:
            return "OK"
        cov = self.coverage
        if cov >= 0.6:
            return "OK"
        elif cov >= 0.4:
            return "NEEDS_IMPROVEMENT"
        return "POOR"
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            "main_motif": self.main_motif,
            "sections_using_it": self.sections_using_it,
            "total_sections": self.total_sections,
            "coverage": self.coverage,
            "status": self.status
        }


@dataclass
class SectionThemeMetric:
    """Checks theme continuity across sections."""
    theme_mutations: int
    total_transitions: int
    
    @property
    def status(self) -> str:
        if self.total_transitions == 0:
            return "OK"
        mutation_rate = self.theme_mutations / self.total_transitions
        # Some mutation is good, too much is chaotic
        if 0.2 <= mutation_rate <= 0.6:
            return "OK"
        elif mutation_rate < 0.2:
            return "NEEDS_IMPROVEMENT"  # Too static
        return "POOR"  # Too chaotic
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            "theme_mutations": self.theme_mutations,
            "total_transitions": self.total_transitions,
            "mutation_rate": round(self.theme_mutations / max(1, self.total_transitions), 2),
            "status": self.status
        }


@dataclass
class RedundantLayerMetric:
    """Detects redundant/similar layers."""
    count: int
    redundant_groups: List[Dict[str, Any]] = field(default_factory=list)
    
    @property
    def status(self) -> str:
        if self.count == 0:
            return "OK"
        elif self.count <= 2:
            return "NEEDS_IMPROVEMENT"
        return "POOR"
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            "count": self.count,
            "redundant_groups": self.redundant_groups[:3],  # Top 3
            "status": self.status
        }


@dataclass
class HarmonicCoverageMetric:
    """Measures harmonic content coverage across the song to detect empty gaps.
    
    Analyzes the union of active harmonic support across:
    - HARMONY_PIANO_MIDI (mandatory MIDI hook)
    - AUDIO KEYS SUPPORT
    - AUDIO SYNTH LOOP
    - AUDIO SYNTH PEAK
    - Any other harmonic layer
    
    Target: max_harmonic_gap_beats <= 8
    """
    coverage_ratio: float  # 0.0-1.0 ratio of song covered by harmonic content
    max_gap_beats: float  # Maximum continuous gap in beats
    total_harmonic_beats: float  # Total beats covered by harmonic layers
    song_length_beats: float  # Total song length in beats
    gap_locations: List[Dict[str, Any]] = field(default_factory=list)  # Locations of gaps > 8 beats
    
    @property
    def status(self) -> str:
        """Evaluate coverage status based on targets."""
        if self.max_gap_beats <= 8 and self.coverage_ratio >= 0.85:
            return "OK"
        elif self.max_gap_beats <= 16 and self.coverage_ratio >= 0.70:
            return "NEEDS_IMPROVEMENT"
        return "POOR"
    
    @property
    def has_critical_gaps(self) -> bool:
        """True if there are gaps exceeding the 8 beat threshold."""
        return self.max_gap_beats > 8
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            "coverage_ratio": round(self.coverage_ratio, 3),
            "max_gap_beats": round(self.max_gap_beats, 2),
            "total_harmonic_beats": round(self.total_harmonic_beats, 2),
            "song_length_beats": round(self.song_length_beats, 2),
            "gap_locations": self.gap_locations[:5],  # Top 5 gaps
            "status": self.status,
            "has_critical_gaps": self.has_critical_gaps,
            "target_max_gap": 8,
            "target_coverage": 0.85
        }


@dataclass
class BusArchitectureMetric:
    """Validates bus architecture for proper mixing and routing.
    
    T108-T112: Bus architecture verification:
    - DRUM BUS exists and has devices
    - BASS BUS exists and has devices
    - MUSIC BUS exists and has devices
    - SPACE (A-MCP SPACE) exists with reverb
    - Returns A-D have appropriate devices
    """
    drums_bus_present: bool
    bass_bus_present: bool
    music_bus_present: bool
    space_bus_present: bool
    return_a_has_device: bool
    return_b_has_device: bool
    return_c_has_device: bool
    return_d_has_device: bool
    issues: List[str] = field(default_factory=list)
    
    @property
    def score(self) -> int:
        """Returns score from 0-100."""
        base_score = 0
        # Each bus presence = 15 points (60%)
        if self.drums_bus_present:
            base_score += 15
        if self.bass_bus_present:
            base_score += 15
        if self.music_bus_present:
            base_score += 15
        if self.space_bus_present:
            base_score += 15
        # Each return device = 10 points (40%)
        if self.return_a_has_device:
            base_score += 10
        if self.return_b_has_device:
            base_score += 10
        if self.return_c_has_device:
            base_score += 10
        if self.return_d_has_device:
            base_score += 10
        return min(100, base_score)
    
    @property
    def status(self) -> str:
        """Returns status based on score."""
        if self.score >= 85:
            return "GOOD"
        elif self.score >= 60:
            return "MODERATE"
        return "POOR"
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            "drums_bus_present": self.drums_bus_present,
            "bass_bus_present": self.bass_bus_present,
            "music_bus_present": self.music_bus_present,
            "space_bus_present": self.space_bus_present,
            "return_a_has_device": self.return_a_has_device,
            "return_b_has_device": self.return_b_has_device,
            "return_c_has_device": self.return_c_has_device,
            "return_d_has_device": self.return_d_has_device,
            "issues": self.issues,
            "score": self.score,
            "status": self.status
        }


@dataclass
class CoherenceReport:
    """Complete coherence report for a generation."""
    session_id: str
    track_budget: TrackBudgetMetric
    core_vs_optional: CoreVsOptionalMetric
    same_pack_ratio: SamePackMetric
    tonal_consistency: TonalConsistencyMetric
    motif_reuse: MotifReuseMetric
    section_theme_consistency: SectionThemeMetric
    redundant_layers: RedundantLayerMetric
    harmonic_coverage: HarmonicCoverageMetric
    bus_architecture: Optional[BusArchitectureMetric] = None
    overall_coherence_score: float = 0.0
    verdict: str = ""
    timestamp: float = 0.0
    
    def to_dict(self) -> Dict[str, Any]:
        result = {
            "session_id": self.session_id,
            "track_budget": self.track_budget.to_dict(),
            "core_vs_optional": self.core_vs_optional.to_dict(),
            "same_pack_ratio": self.same_pack_ratio.to_dict(),
            "tonal_consistency": self.tonal_consistency.to_dict(),
            "motif_reuse": self.motif_reuse.to_dict(),
            "section_theme_consistency": self.section_theme_consistency.to_dict(),
            "redundant_layers": self.redundant_layers.to_dict(),
            "harmonic_coverage": self.harmonic_coverage.to_dict(),
            "overall_coherence_score": self.overall_coherence_score,
            "verdict": self.verdict,
            "timestamp": self.timestamp
        }
        # T115: Include bus_architecture in report
        if self.bus_architecture is not None:
            result["bus_architecture"] = self.bus_architecture.to_dict()
        return result
    
    def to_json(self, indent: int = 2) -> str:
        return json.dumps(self.to_dict(), indent=indent, default=str)


class CoherenceAnalyzer:
    """
    Analyzes generation manifests for coherence metrics.
    
    Usage:
        analyzer = CoherenceAnalyzer()
        report = analyzer.analyze_manifest(manifest_dict)
        print(report.to_json())
    """
    
    # Core roles that are essential for any track
    CORE_ROLES = {
        'kick', 'bass', 'clap', 'snare_fill', 'hat_closed'
    }
    
    # Roles that are typically optional/texture
    OPTIONAL_ROLES = {
        'atmos', 'riser', 'impact', 'reverse_fx', 'vocal',
        'ride', 'crash', 'tom_fill', 'perc', 'top_loop'
    }
    TECHNICAL_ROLES = {'sc_trigger'}
    ROLE_SLOT_MAP = {
        'kick': 'kick',
        'clap': 'clap_snare',
        'snare': 'clap_snare',
        'snare_fill': 'clap_snare',
        'hat': 'hats',
        'hat_closed': 'hats',
        'hat_open': 'hats',
        'top_loop': 'tops',
        'perc': 'percussion',
        'perc_loop': 'percussion',
        'perc_alt': 'percussion',
        'bass': 'bass',
        'sub_bass': 'bass',
        'bass_loop': 'bass',
        'chords': 'harmony',
        'pad': 'harmony',
        'lead': 'lead_hook',
        'pluck': 'lead_hook',
        'arp': 'lead_hook',
        'counter': 'lead_hook',
        'stab': 'lead_hook',
        'synth_loop': 'lead_hook',
        'synth_peak': 'lead_hook',
        'vocal': 'vocal',
        'vocal_chop': 'vocal',
        'vocal_loop': 'vocal',
        'vocal_peak': 'vocal',
        'vocal_build': 'vocal',
        'vocal_shot': 'vocal',
        'impact': 'transition_fx',
        'impact_fx': 'transition_fx',
        'crash_fx': 'transition_fx',
        'fill_fx': 'transition_fx',
        'snare_roll': 'transition_fx',
        'riser': 'transition_fx',
        'reverse_fx': 'transition_fx',
        'downlifter': 'transition_fx',
        'noise_sweep': 'transition_fx',
        'atmos': 'atmos',
        'atmos_fx': 'atmos',
        'drone': 'atmos',
    }
    CORE_SLOTS = {'kick', 'clap_snare', 'hats', 'bass', 'harmony', 'lead_hook'}
    
    def __init__(self, track_budget: int = 12):
        self.track_budget = track_budget
        self.logger = logging.getLogger("CoherenceAnalyzer")
    
    def analyze_manifest(self, manifest: Dict[str, Any]) -> CoherenceReport:
        """
        Analyze a generation manifest and return a coherence report.
        
        Args:
            manifest: Generation manifest dict from server.py
            
        Returns:
            CoherenceReport with all metrics calculated
        """
        session_id = manifest.get("session_id", "unknown")
        
        # Calculate all metrics
        track_budget = self._analyze_track_budget(manifest)
        core_optional = self._analyze_core_vs_optional(manifest)
        pack_ratio = self._analyze_same_pack_ratio(manifest)
        tonal = self._analyze_tonal_consistency(manifest)
        motif = self._analyze_motif_reuse(manifest)
        theme = self._analyze_section_theme(manifest)
        redundant = self._analyze_redundant_layers(manifest)
        harmonic_coverage = self._analyze_harmonic_coverage(manifest)
        
        # Calculate overall score (weighted average)
        score = self._calculate_overall_score(
            track_budget, core_optional, pack_ratio,
            tonal, motif, theme, redundant, harmonic_coverage
        )
        
        # Generate verdict
        verdict = self._generate_verdict(score, track_budget, core_optional, redundant, harmonic_coverage)
        
        report = CoherenceReport(
            session_id=session_id,
            track_budget=track_budget,
            core_vs_optional=core_optional,
            same_pack_ratio=pack_ratio,
            tonal_consistency=tonal,
            motif_reuse=motif,
            section_theme_consistency=theme,
            redundant_layers=redundant,
            harmonic_coverage=harmonic_coverage,
            overall_coherence_score=score,
            verdict=verdict,
            timestamp=float(manifest.get("timestamp", time.time()) or time.time()),
        )
        
        self.logger.info(
            "Coherence report for %s: score=%.1f/10, verdict=%s, harmonic_coverage=%.0f%%, max_gap=%.1f beats",
            session_id, score, verdict, harmonic_coverage.coverage_ratio * 100, harmonic_coverage.max_gap_beats
        )
        
        return report

    def _normalize_key(self, value: Any) -> str:
        text = str(value or "").strip().lower()
        if not text:
            return ""
        text = text.replace("minor", "m").replace("major", "")
        text = text.replace(" min", "m").replace(" maj", "")
        text = text.replace("_", "").replace("-", "").replace(" ", "")
        text = text.replace("♭", "b").replace("♯", "#")
        mode = "m" if text.endswith("m") else ""
        note = text[:-1] if mode else text
        note = ENHARMONIC_EQUIV.get(note, note)
        return f"{note}{mode}"

    def _split_key(self, value: Any) -> Tuple[str, str]:
        normalized = self._normalize_key(value)
        if not normalized:
            return "", ""
        if normalized.endswith("m"):
            return normalized[:-1], "minor"
        return normalized, "major"

    def _extract_key_from_text(self, text: Any) -> str:
        lowered = str(text or "").lower()
        patterns = [
            r"([a-g])([#b]?)[ _-]?(?:min|minor|m)(?:\b|_)",
            r"([a-g])([#b]?)[ _-]?(?:maj|major)(?:\b|_)",
            r"\b([a-g])([#b]?m)(?:\b|_)",
        ]
        for pattern in patterns:
            match = re.search(pattern, lowered)
            if not match:
                continue
            return self._normalize_key("".join(match.groups()))
        return ""

    def _logical_items(self, manifest: Dict[str, Any]) -> List[Dict[str, Any]]:
        items: List[Dict[str, Any]] = []
        for source_name in ("tracks", "audio_layers", "resample_layers"):
            for item in manifest.get(source_name, []) or []:
                if not isinstance(item, dict):
                    continue
                role = str(item.get("role", "") or "").strip().lower()
                if not role or role in self.TECHNICAL_ROLES:
                    continue
                slot = self.ROLE_SLOT_MAP.get(role)
                if not slot:
                    if role.startswith("vocal"):
                        slot = "vocal"
                    elif role.endswith("_fx"):
                        slot = "transition_fx"
                    else:
                        slot = role
                items.append(
                    {
                        "source": source_name,
                        "role": role,
                        "slot": slot,
                        "name": str(item.get("name", "") or ""),
                        "path": str(
                            item.get("file_path")
                            or item.get("source_path")
                            or item.get("source_file")
                            or ""
                        ),
                    }
                )
        return items
    
    def _analyze_track_budget(self, manifest: Dict[str, Any]) -> TrackBudgetMetric:
        """Calculate track budget adherence using logical musical slots."""
        total = len({item["slot"] for item in self._logical_items(manifest)})
        
        status = "OK" if total <= self.track_budget else "OVER_BUDGET"
        
        return TrackBudgetMetric(
            total=total,
            budget=self.track_budget,
            status=status
        )
    
    def _analyze_core_vs_optional(self, manifest: Dict[str, Any]) -> CoreVsOptionalMetric:
        """Analyze core vs optional track ratio using logical musical slots."""
        logical_slots = {item["slot"] for item in self._logical_items(manifest)}
        core_count = sum(1 for slot in logical_slots if slot in self.CORE_SLOTS)
        optional_count = max(0, len(logical_slots) - core_count)
        
        return CoreVsOptionalMetric(core=core_count, optional=optional_count)
    
    def _analyze_same_pack_ratio(self, manifest: Dict[str, Any]) -> SamePackMetric:
        """Analyze sample pack consistency."""
        audio_layers = manifest.get("audio_layers", [])
        
        if not audio_layers:
            return SamePackMetric(main_pack=None, samples_from_pack=0, total=0)
        
        # Extract sample paths and group by folder
        sample_folders = []
        for layer in audio_layers:
            file_path = layer.get("file_path") or layer.get("source_path", "")
            if file_path:
                folder = str(Path(file_path).parent)
                sample_folders.append(folder)
        
        if not sample_folders:
            return SamePackMetric(main_pack=None, samples_from_pack=0, total=0)
        
        # Find most common folder (main pack)
        folder_counts = Counter(sample_folders)
        main_pack, max_count = folder_counts.most_common(1)[0]
        
        return SamePackMetric(
            main_pack=main_pack,
            samples_from_pack=max_count,
            total=len(sample_folders)
        )
    
    def _analyze_tonal_consistency(self, manifest: Dict[str, Any]) -> TonalConsistencyMetric:
        """Analyze key/tone consistency."""
        project_key = manifest.get("key", "") or (manifest.get("musical_theme") or {}).get("key", "")
        audio_layers = manifest.get("audio_layers", [])
        
        if not project_key or not audio_layers:
            return TonalConsistencyMetric(key=project_key or "unknown", deviations=0, total_samples=0)
        
        deviations = 0
        total_with_key = 0
        
        # Check sample keys (if available in metadata)
        for layer in audio_layers:
            sample_key = (
                layer.get("sample_key")
                or layer.get("key", "")
                or self._extract_key_from_text(layer.get("file_path"))
                or self._extract_key_from_text(layer.get("name"))
                or self._extract_key_from_text(layer.get("source"))
            )
            if sample_key:
                total_with_key += 1
                if sample_key != project_key:
                    # Check for related keys (relative major/minor, fifths)
                    if not self._is_compatible_key(project_key, sample_key):
                        deviations += 1
        
        return TonalConsistencyMetric(
            key=project_key,
            deviations=deviations,
            total_samples=total_with_key
        )
    
    def _is_compatible_key(self, key1: str, key2: str) -> bool:
        """Check if two keys are harmonically compatible."""
        note1, mode1 = self._split_key(key1)
        note2, mode2 = self._split_key(key2)
        if not note1 or not note2:
            return True
        if note1 == note2 and mode1 == mode2:
            return True
        pc1 = NOTE_TO_SEMITONE.get(note1)
        pc2 = NOTE_TO_SEMITONE.get(note2)
        if pc1 is None or pc2 is None:
            return True
        if note1 == note2 and mode1 != mode2:
            return True
        if mode1 != mode2:
            if mode1 == "major" and ((pc1 + 9) % 12) == pc2:
                return True
            if mode1 == "minor" and ((pc1 + 3) % 12) == pc2:
                return True
        distance = min((pc1 - pc2) % 12, (pc2 - pc1) % 12)
        if mode1 == mode2 and distance in {5, 7}:
            return True
        return False
    
    def _analyze_motif_reuse(self, manifest: Dict[str, Any]) -> MotifReuseMetric:
        """Analyze pattern/motif reuse across sections."""
        sections = manifest.get("sections", [])
        variant_summary = manifest.get("section_variant_summary", {})
        
        if not sections:
            return MotifReuseMetric(main_motif=None, sections_using_it=0, total_sections=0)
        
        total_sections = len(sections)
        musical_theme = manifest.get("musical_theme") or {}
        theme_variations = list(musical_theme.get("variations_used") or [])
        if theme_variations:
            return MotifReuseMetric(
                main_motif="shared_theme",
                sections_using_it=min(total_sections, len(theme_variations)),
                total_sections=total_sections,
            )
        
        # Get variants used
        variants = variant_summary.get("variants_used", {})
        
        # Find most common melodic variant (main motif)
        melodic_variants = variants.get("melodic", [])
        if not melodic_variants:
            return MotifReuseMetric(main_motif=None, sections_using_it=0, total_sections=total_sections)
        
        # Count motif occurrences
        variant_counts = Counter(melodic_variants)
        main_motif, max_count = variant_counts.most_common(1)[0]
        
        return MotifReuseMetric(
            main_motif=main_motif,
            sections_using_it=max_count,
            total_sections=total_sections
        )
    
    def _analyze_section_theme(self, manifest: Dict[str, Any]) -> SectionThemeMetric:
        """Analyze theme continuity across sections."""
        sections = manifest.get("sections", [])
        
        if len(sections) < 2:
            return SectionThemeMetric(theme_mutations=0, total_transitions=0)
        
        # Count transitions between sections
        total_transitions = max(0, len(sections) - 1)
        
        # Detect theme mutations by looking at variant changes
        mutations = 0
        prev_kind = None
        
        for section in sections:
            kind = section.get("kind", "").lower()
            
            # Check for theme breaks
            if prev_kind and kind != prev_kind:
                # Transition = potential theme mutation
                if kind in ["break", "build"] or prev_kind in ["break", "build"]:
                    mutations += 1
            
            prev_kind = kind
        
        return SectionThemeMetric(
            theme_mutations=mutations,
            total_transitions=total_transitions
        )
    
    def _analyze_redundant_layers(self, manifest: Dict[str, Any]) -> RedundantLayerMetric:
        """Detect redundant/similar layers."""
        items = self._logical_items(manifest)
        redundant_groups = []
        slot_groups = defaultdict(list)
        for item in items:
            slot_groups[item["slot"]].append(item)

        total_redundant = 0
        for slot, slot_items in slot_groups.items():
            path_counts = Counter(item["path"] for item in slot_items if item["path"])
            duplicate_paths = {path: count for path, count in path_counts.items() if count > 1}
            overload = max(0, len(slot_items) - 2)
            duplicate_overage = sum(count - 1 for count in duplicate_paths.values())
            redundant_count = overload + duplicate_overage
            if redundant_count <= 0:
                continue
            redundant_groups.append(
                {
                    "role": slot,
                    "count": len(slot_items),
                    "unique_sources": len(path_counts) if path_counts else 0,
                    "example_sources": list(path_counts.keys())[:2],
                }
            )
            total_redundant += redundant_count
        
        return RedundantLayerMetric(
            count=total_redundant,
            redundant_groups=redundant_groups
        )
    
    def _analyze_harmonic_coverage(self, manifest: Dict[str, Any]) -> HarmonicCoverageMetric:
        """Analyze harmonic content coverage across the song to detect empty gaps.
        
        Evaluates the union of active harmonic support across:
        - HARMONY_PIANO_MIDI (mandatory MIDI hook)
        - AUDIO KEYS SUPPORT
        - AUDIO SYNTH LOOP
        - AUDIO SYNTH PEAK
        - Any other harmonic layer
        
        Returns:
            HarmonicCoverageMetric with coverage ratio and max gap
        """
        # Get song length from sections
        sections = manifest.get("sections", [])
        audio_layers = manifest.get("audio_layers", [])
        midi_hook = manifest.get("mandatory_midi_hook", {})
        
        # Calculate total song length in beats
        song_length_beats = 0.0
        if sections:
            for section in sections:
                if isinstance(section, dict):
                    start = float(section.get("start", 0.0) or 0.0)
                    end = float(section.get("end", start + 16.0) or (start + 16.0))
                    song_length_beats = max(song_length_beats, end)
        
        if song_length_beats == 0:
            # Fallback: estimate from audio layer positions
            for layer in audio_layers:
                if isinstance(layer, dict):
                    positions = layer.get("positions", [])
                    for pos in positions:
                        try:
                            song_length_beats = max(song_length_beats, float(pos) + 16.0)
                        except (TypeError, ValueError):
                            continue
        
        if song_length_beats == 0:
            song_length_beats = 128.0  # Default: ~2 minutes at 128bpm
        
        # Harmonic roles to track
        HARMONIC_ROLES = {
            'chords', 'synth_loop', 'pad', 'lead', 'pluck', 'arp', 'drone', 
            'hook_midi', 'piano', 'keys', 'synth', 'synth_peak'
        }
        HARMONIC_LAYER_NAMES = {
            'harmony_piano_midi', 'audio keys support', 'audio synth loop', 
            'audio synth peak', 'audio piano melody', 'midi_hook', 'harmony',
            'keys', 'piano', 'synth', 'pad'
        }
        
        # Collect all time intervals where harmonic content is active
        harmonic_intervals: List[Tuple[float, float]] = []
        
        # Check MIDI hook coverage
        if isinstance(midi_hook, dict) and midi_hook.get("materialized"):
            hook_track_name = str(midi_hook.get("track_name", "HARMONY_PIANO_MIDI")).lower()
            arrangement_length = float(midi_hook.get("arrangement_length_beats", 0) or 0)
            if arrangement_length > 0:
                # MIDI hook spans from 0 to arrangement_length
                harmonic_intervals.append((0.0, arrangement_length))
        
        # Check audio layers
        for layer in audio_layers:
            if not isinstance(layer, dict):
                continue
            
            role = str(layer.get("role", "") or "").strip().lower()
            layer_name = str(layer.get("name", "") or "").strip().lower()
            
            # Check if this is a harmonic layer
            is_harmonic = (
                role in HARMONIC_ROLES or
                any(h_name in layer_name for h_name in HARMONIC_LAYER_NAMES) or
                "synth" in layer_name or
                "keys" in layer_name or
                "piano" in layer_name or
                "pad" in layer_name or
                "chord" in layer_name
            )
            
            if not is_harmonic:
                continue
            
            # Get positions for this layer
            positions = layer.get("positions", [])
            if not positions:
                continue
            
            # Assume each clip/instance spans ~16 beats (4 bars)
            clip_length = float(layer.get("clip_length_beats", 16.0) or 16.0)
            
            for pos in positions:
                try:
                    start = float(pos)
                    end = start + clip_length
                    harmonic_intervals.append((start, end))
                except (TypeError, ValueError):
                    continue
        
        # Merge overlapping intervals
        if not harmonic_intervals:
            return HarmonicCoverageMetric(
                coverage_ratio=0.0,
                max_gap_beats=song_length_beats,
                total_harmonic_beats=0.0,
                song_length_beats=song_length_beats,
                gap_locations=[{"start": 0.0, "end": song_length_beats, "duration": song_length_beats}]
            )
        
        # Sort by start time
        harmonic_intervals.sort(key=lambda x: x[0])
        
        # Merge overlapping intervals
        merged: List[Tuple[float, float]] = []
        current_start, current_end = harmonic_intervals[0]
        
        for start, end in harmonic_intervals[1:]:
            if start <= current_end:  # Overlapping or adjacent
                current_end = max(current_end, end)
            else:
                merged.append((current_start, current_end))
                current_start, current_end = start, end
        merged.append((current_start, current_end))
        
        # Calculate coverage
        total_harmonic_beats = sum(end - start for start, end in merged)
        coverage_ratio = min(1.0, total_harmonic_beats / song_length_beats)
        
        # Find gaps
        gap_locations = []
        max_gap_beats = 0.0
        
        # Check gap from start to first harmonic
        if merged[0][0] > 0:
            gap_duration = merged[0][0]
            if gap_duration > max_gap_beats:
                max_gap_beats = gap_duration
            gap_locations.append({
                "start": 0.0,
                "end": merged[0][0],
                "duration": gap_duration,
                "section": "intro"
            })
        
        # Check gaps between harmonic sections
        for i in range(len(merged) - 1):
            gap_start = merged[i][1]
            gap_end = merged[i + 1][0]
            gap_duration = gap_end - gap_start
            
            if gap_duration > 0:
                if gap_duration > max_gap_beats:
                    max_gap_beats = gap_duration
                
                # Identify section based on position
                section_name = "middle"
                if gap_start < song_length_beats * 0.25:
                    section_name = "intro/build"
                elif gap_start > song_length_beats * 0.75:
                    section_name = "outro"
                elif song_length_beats * 0.4 < gap_start < song_length_beats * 0.6:
                    section_name = "break"
                
                gap_locations.append({
                    "start": gap_start,
                    "end": gap_end,
                    "duration": gap_duration,
                    "section": section_name
                })
        
        # Check gap from last harmonic to end
        if merged[-1][1] < song_length_beats:
            gap_duration = song_length_beats - merged[-1][1]
            if gap_duration > max_gap_beats:
                max_gap_beats = gap_duration
            gap_locations.append({
                "start": merged[-1][1],
                "end": song_length_beats,
                "duration": gap_duration,
                "section": "outro"
            })
        
        # Sort gaps by duration (largest first)
        gap_locations.sort(key=lambda x: x["duration"], reverse=True)
        
        return HarmonicCoverageMetric(
            coverage_ratio=coverage_ratio,
            max_gap_beats=max_gap_beats,
            total_harmonic_beats=total_harmonic_beats,
            song_length_beats=song_length_beats,
            gap_locations=gap_locations
        )
    
    def _analyze_bus_architecture(self, manifest: Dict[str, Any]) -> BusArchitectureMetric:
        """Analyze bus architecture for proper mixing and routing.
        
        T113-T114: Bus architecture verification from manifest data.
        
        Returns:
            BusArchitectureMetric with bus presence and device status
        """
        buses = manifest.get("buses", {})
        returns = manifest.get("returns", {})
        
        # Check bus tracks from manifest
        bus_tracks = manifest.get("bus_tracks", [])
        return_tracks = manifest.get("return_tracks", [])
        
        # Default values
        drums_bus_present = False
        bass_bus_present = False
        music_bus_present = False
        space_bus_present = False
        return_a_has_device = False
        return_b_has_device = False
        return_c_has_device = False
        return_d_has_device = False
        issues = []
        
        # BUS_PATTERNS for matching
        DRUM_PATTERNS = ["drum", "drums", "drum bus"]
        BASS_PATTERNS = ["bass", "bass bus"]
        MUSIC_PATTERNS = ["music", "music bus"]
        SPACE_PATTERNS = ["space", "mcp space", "a-mcp space"]
        
        # Check bus tracks
        for bus_track in bus_tracks:
            if not isinstance(bus_track, dict):
                continue
            track_name = str(bus_track.get("name", "") or "").lower()
            has_devices = bool(bus_track.get("devices", []))
            
            if any(pattern in track_name for pattern in DRUM_PATTERNS):
                drums_bus_present = True
                if not has_devices:
                    issues.append("DRUM BUS has no devices")
            elif any(pattern in track_name for pattern in BASS_PATTERNS):
                bass_bus_present = True
                if not has_devices:
                    issues.append("BASS BUS has no devices")
            elif any(pattern in track_name for pattern in MUSIC_PATTERNS):
                music_bus_present = True
                if not has_devices:
                    issues.append("MUSIC BUS has no devices")
            elif any(pattern in track_name for pattern in SPACE_PATTERNS):
                space_bus_present = True
                if not has_devices:
                    issues.append("SPACE (A-MCP SPACE) has no devices")
        
        # Check return tracks
        RETURN_PATTERNS = {
            "A": ["reverb", "a-reverb"],
            "B": ["delay", "b-delay"],
            "C": ["chorus", "c-chorus"],
            "D": ["spatial", "d-spatial"],
        }
        
        for ret_track in return_tracks:
            if not isinstance(ret_track, dict):
                continue
            track_name = str(ret_track.get("name", "") or "").lower()
            has_device = bool(ret_track.get("devices", []))
            
            for ret_key, patterns in RETURN_PATTERNS.items():
                if any(pattern in track_name for pattern in patterns):
                    if ret_key == "A":
                        return_a_has_device = True if has_device else False
                        if not has_device:
                            issues.append("Return A (Reverb) has no device")
                    elif ret_key == "B":
                        return_b_has_device = True if has_device else False
                        if not has_device:
                            issues.append("Return B (Delay) has no device")
                    elif ret_key == "C":
                        return_c_has_device = True if has_device else False
                        if not has_device:
                            issues.append("Return C (Chorus) has no device")
                    elif ret_key == "D":
                        return_d_has_device = True if has_device else False
                        if not has_device:
                            issues.append("Return D (Spatial) has no device")
        
        # Missing buses
        if not drums_bus_present:
            issues.append("Missing DRUM BUS")
        if not bass_bus_present:
            issues.append("Missing BASS BUS")
        if not music_bus_present:
            issues.append("Missing MUSIC BUS")
        if not space_bus_present:
            issues.append("Missing SPACE (A-MCP SPACE) bus")
        
        return BusArchitectureMetric(
            drums_bus_present=drums_bus_present,
            bass_bus_present=bass_bus_present,
            music_bus_present=music_bus_present,
            space_bus_present=space_bus_present,
            return_a_has_device=return_a_has_device,
            return_b_has_device=return_b_has_device,
            return_c_has_device=return_c_has_device,
            return_d_has_device=return_d_has_device,
            issues=issues
        )
    
    def _calculate_overall_score(
        self,
        track_budget: TrackBudgetMetric,
        core_optional: CoreVsOptionalMetric,
        pack_ratio: SamePackMetric,
        tonal: TonalConsistencyMetric,
        motif: MotifReuseMetric,
        theme: SectionThemeMetric,
        redundant: RedundantLayerMetric,
        harmonic_coverage: HarmonicCoverageMetric
    ) -> float:
        """
        Calculate overall coherence score (0-10 scale).
        
        Weights:
        - Core vs Optional: 20%
        - Same Pack Ratio: 15%
        - Tonal Consistency: 15%
        - Motif Reuse: 15%
        - Section Theme: 10%
        - Track Budget: 10%
        - Harmonic Coverage: 15% (NEW)
        """
        scores = []
        
        # Track budget (0-10, penalize overage)
        if track_budget.status == "OK":
            budget_score = 10.0
        else:
            overage_pct = track_budget.total / max(1, track_budget.budget)
            budget_score = max(0, 10 - (overage_pct - 1) * 10)
        scores.append((budget_score, 0.10))
        
        # Core vs Optional (target >0.7)
        if core_optional.ratio >= 0.7:
            core_score = 10.0
        else:
            core_score = (core_optional.ratio / 0.7) * 10
        scores.append((core_score, 0.20))
        
        # Same Pack Ratio (target >0.6)
        if pack_ratio.ratio >= 0.6:
            pack_score = 10.0
        else:
            pack_score = (pack_ratio.ratio / 0.6) * 10
        scores.append((pack_score, 0.15))
        
        # Tonal Consistency (fewer deviations = better)
        if tonal.status == "OK":
            tonal_score = 10.0
        elif tonal.status == "NEEDS_IMPROVEMENT":
            tonal_score = 7.0
        else:
            tonal_score = 4.0
        scores.append((tonal_score, 0.15))
        
        # Motif Reuse (more coverage = better)
        if motif.total_sections > 0:
            motif_score = (motif.sections_using_it / motif.total_sections) * 10
        else:
            motif_score = 5.0
        scores.append((motif_score, 0.15))
        
        # Section Theme (balanced mutation rate)
        if theme.status == "OK":
            theme_score = 10.0
        elif theme.status == "NEEDS_IMPROVEMENT":
            theme_score = 6.0
        else:
            theme_score = 3.0
        scores.append((theme_score, 0.10))
        
        # Redundant Layers (fewer = better, but some layering is OK)
        if redundant.count == 0:
            redundant_score = 10.0
        elif redundant.count <= 2:
            redundant_score = 7.0
        else:
            redundant_score = max(0, 10 - redundant.count)
        scores.append((redundant_score, 0.10))
        
        # Harmonic Coverage (target: max_gap <= 8 beats, coverage >= 85%)
        if harmonic_coverage.max_gap_beats <= 8:
            gap_score = 10.0
        elif harmonic_coverage.max_gap_beats <= 16:
            gap_score = 7.0
        else:
            gap_score = max(0, 10 - (harmonic_coverage.max_gap_beats - 16) * 0.5)
        
        # Also consider coverage ratio
        coverage_score = (harmonic_coverage.coverage_ratio / 0.85) * 10 if harmonic_coverage.coverage_ratio < 0.85 else 10.0
        
        # Average of gap and coverage scores
        harmonic_score = (gap_score + coverage_score) / 2
        scores.append((harmonic_score, 0.15))
        
        # Calculate weighted average
        total_weight = sum(weight for _, weight in scores)
        weighted_sum = sum(score * weight for score, weight in scores)
        
        return round(weighted_sum / total_weight, 1) if total_weight > 0 else 0.0
    
    def _generate_verdict(
        self,
        score: float,
        track_budget: TrackBudgetMetric,
        core_optional: CoreVsOptionalMetric,
        redundant: RedundantLayerMetric,
        harmonic_coverage: HarmonicCoverageMetric
    ) -> str:
        """Generate human-readable verdict."""
        if score >= 8.0:
            if track_budget.status == "OVER_BUDGET":
                return "STRONG - Coherent but over budget, consider trimming"
            if harmonic_coverage.has_critical_gaps:
                return "STRONG - Coherent but has harmonic gaps, consider adding sustaining layers"
            return "STRONG - Well-coherent generation with strong identity"
        elif score >= 6.0:
            issues = []
            if core_optional.ratio < 0.6:
                issues.append("too many optional tracks")
            if redundant.count > 2:
                issues.append("redundant layers")
            if track_budget.status == "OVER_BUDGET":
                issues.append("over budget")
            if harmonic_coverage.has_critical_gaps:
                issues.append(f"harmonic gaps (max {harmonic_coverage.max_gap_beats:.0f} beats)")
            
            if issues:
                return f"MIXED - Has identity but {', '.join(issues)}"
            return "MIXED - Decent coherence with room for improvement"
        elif score >= 4.0:
            return "WEAK - Lacks coherence, needs structural fixes"
        else:
            return "POOR - Highly incoherent, needs major revision"


def save_coherence_report(report: CoherenceReport, output_dir: Optional[Path] = None) -> Path:
    """
    Save coherence report to disk.
    
    Args:
        report: CoherenceReport to save
        output_dir: Directory to save in (default: ~/.abletonmcp_ai/coherence_reports/)
        
    Returns:
        Path to saved file
    """
    if output_dir is None:
        output_dir = Path.home() / ".abletonmcp_ai" / "coherence_reports"
    
    output_dir.mkdir(parents=True, exist_ok=True)
    
    filename = f"coherence_{report.session_id}_{int(report.timestamp)}.json"
    filepath = output_dir / filename
    
    with open(filepath, 'w', encoding='utf-8') as f:
        f.write(report.to_json())
    
    logger.info("Coherence report saved to %s", filepath)
    return filepath


# Global analyzer instance for reuse
_analyzer_instance: Optional[CoherenceAnalyzer] = None


def get_coherence_analyzer(track_budget: int = 12) -> CoherenceAnalyzer:
    """Get or create global coherence analyzer instance."""
    global _analyzer_instance
    if _analyzer_instance is None:
        _analyzer_instance = CoherenceAnalyzer(track_budget=track_budget)
    return _analyzer_instance


def analyze_generation_coherence(manifest: Dict[str, Any], save_report: bool = True) -> CoherenceReport:
    """
    Convenience function to analyze a generation manifest.
    
    Args:
        manifest: Generation manifest dict
        save_report: Whether to save report to disk
        
    Returns:
        CoherenceReport
    """
    analyzer = get_coherence_analyzer()
    report = analyzer.analyze_manifest(manifest)
    
    if save_report:
        save_coherence_report(report)
    
    return report


def format_coherence_summary(report: CoherenceReport) -> str:
    """Format coherence report as a concise console summary."""
    lines = [
        f"Coherence Report for {report.session_id}",
        f"  Score: {report.overall_coherence_score}/10",
        f"  Verdict: {report.verdict}",
        "",
        "  Metrics:",
        f"    - Tracks: {report.track_budget.total}/{report.track_budget.budget} ({report.track_budget.status})",
        f"    - Core/Optional: {report.core_vs_optional.ratio:.0%} ratio ({report.core_vs_optional.status})",
        f"    - Pack Consistency: {report.same_pack_ratio.ratio:.0%} ({report.same_pack_ratio.status})",
        f"    - Tonal: {report.tonal_consistency.key}, {report.tonal_consistency.deviations} deviations",
        f"    - Motif Coverage: {report.motif_reuse.coverage:.0%} ({report.motif_reuse.status})",
        f"    - Redundancy: {report.redundant_layers.count} issues ({report.redundant_layers.status})",
        f"    - Harmonic Coverage: {report.harmonic_coverage.coverage_ratio:.0%} ({report.harmonic_coverage.status})",
        f"    - Max Harmonic Gap: {report.harmonic_coverage.max_gap_beats:.1f} beats (target <= 8)",
    ]
    
    return "\n".join(lines)
