"""Animation sidecar loading, SVG target scanning, and validation."""

from __future__ import annotations

import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET

from .drawingml_utils import SVG_NS

try:
    from pptx_animations import ANIMATIONS, TRANSITIONS
except ImportError:
    ANIMATIONS = {}
    TRANSITIONS = {}


_NON_VISUAL_TAGS = frozenset(('defs', 'title', 'desc', 'metadata', 'style'))
_CHROME_ID_TOKENS = frozenset({
    'background', 'bg',
    'decoration', 'decorations', 'decor',
    'header', 'footer',
    'chrome', 'watermark',
    'pagenumber', 'pagenum',
    'page-number',
})


@dataclass(frozen=True)
class GroupTarget:
    """Top-level SVG group available for PowerPoint animation anchoring."""

    slide: str
    group_id: str
    order: int
    chrome: bool = False


def _tag_name(elem: ET.Element) -> str:
    return elem.tag.replace(f'{{{SVG_NS}}}', '')


def is_chrome_id(elem_id: str | None) -> bool:
    """Return whether a group id represents static slide chrome."""
    if not elem_id:
        return False
    lower = elem_id.lower()
    compact = lower.replace('-', '').replace('_', '')
    if compact in _CHROME_ID_TOKENS:
        return True
    tokens = re.split(r'[-_]', lower)
    return any(t in _CHROME_ID_TOKENS for t in tokens if t)


def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]:
    """Scan one SVG for top-level visible group ids and anonymous groups."""
    root = ET.parse(str(svg_path)).getroot()
    targets: list[GroupTarget] = []
    anonymous_groups: list[str] = []
    visual_index = 0

    for child in root:
        tag = _tag_name(child)
        if tag in _NON_VISUAL_TAGS:
            continue
        visual_index += 1
        if tag != 'g':
            continue
        group_id = child.get('id')
        if not group_id:
            anonymous_groups.append(f'{svg_path.stem}: top-level group #{visual_index}')
            continue
        targets.append(
            GroupTarget(
                slide=svg_path.stem,
                group_id=group_id,
                order=visual_index,
                chrome=is_chrome_id(group_id),
            )
        )

    return targets, anonymous_groups


def scan_project_targets(project_path: Path) -> tuple[dict[str, list[GroupTarget]], list[str]]:
    """Scan ``svg_output/*.svg`` for animation targets."""
    svg_dir = project_path / 'svg_output'
    targets_by_slide: dict[str, list[GroupTarget]] = {}
    anonymous_groups: list[str] = []
    if not svg_dir.is_dir():
        return targets_by_slide, [f'svg_output directory not found: {svg_dir}']

    for svg_path in sorted(svg_dir.glob('*.svg')):
        targets, anonymous = scan_svg_targets(svg_path)
        targets_by_slide[svg_path.stem] = targets
        anonymous_groups.extend(anonymous)

    return targets_by_slide, anonymous_groups


def default_config_path(project_path: Path) -> Path:
    return project_path / 'animations.json'


def load_animation_config(project_path: Path, config_path: str | None = None) -> dict[str, Any] | None:
    """Load optional animation config; return ``None`` when absent."""
    if config_path:
        path = Path(config_path)
    else:
        path = default_config_path(project_path)
    if config_path and not path.is_absolute():
        path = project_path / path
    if not path.exists():
        return None

    with open(path, 'r', encoding='utf-8') as f:
        data = json.load(f)
    if not isinstance(data, dict):
        raise ValueError(f'Animation config must be a JSON object: {path}')
    if data.get('version', 1) != 1:
        raise ValueError(f'Unsupported animation config version: {data.get("version")}')
    return data


def _valid_animation_effect(effect: str) -> bool:
    return effect == 'none' or effect in ANIMATIONS or effect in ('auto', 'mixed', 'random')


def _valid_transition_effect(effect: str) -> bool:
    return effect == 'none' or effect in TRANSITIONS


def validate_animation_config(
    project_path: Path,
    config: dict[str, Any] | None = None,
    config_path: str | None = None,
) -> list[str]:
    """Validate sidecar references against current ``svg_output``."""
    if config is None:
        config = load_animation_config(project_path, config_path)
    if not config:
        return []

    warnings: list[str] = []
    targets_by_slide, anonymous_groups = scan_project_targets(project_path)
    for item in anonymous_groups:
        warnings.append(f'{item} has no id and cannot be customized in animations.json')

    known_slides = set(targets_by_slide)
    slides = config.get('slides', {})
    if slides and not isinstance(slides, dict):
        return ['animations.json field "slides" must be an object']

    defaults = config.get('defaults', {})
    if isinstance(defaults, dict):
        _validate_scope_effects(defaults, 'defaults', warnings)

    for slide_name, slide_cfg in (slides or {}).items():
        if slide_name not in known_slides:
            warnings.append(f'animations.json references missing slide: {slide_name}')
            continue
        if not isinstance(slide_cfg, dict):
            warnings.append(f'animations.json slide "{slide_name}" must be an object')
            continue
        _validate_scope_effects(slide_cfg, f'slide "{slide_name}"', warnings)

        known_groups = {target.group_id for target in targets_by_slide[slide_name]}
        groups = slide_cfg.get('groups', {})
        if groups and not isinstance(groups, dict):
            warnings.append(f'animations.json slide "{slide_name}" field "groups" must be an object')
            continue
        for group_id, group_cfg in (groups or {}).items():
            if group_id not in known_groups:
                warnings.append(
                    f'animations.json references missing group: {slide_name}/{group_id}'
                )
            if not isinstance(group_cfg, dict):
                warnings.append(f'animations.json group "{slide_name}/{group_id}" must be an object')
                continue
            effect = group_cfg.get('effect')
            if effect is not None and not _valid_animation_effect(str(effect)):
                warnings.append(
                    f'animations.json group "{slide_name}/{group_id}" has unknown effect: {effect}'
                )
    return warnings


def _validate_scope_effects(scope: dict[str, Any], label: str, warnings: list[str]) -> None:
    transition = scope.get('transition', {})
    if isinstance(transition, dict):
        effect = transition.get('effect')
        if effect is not None and not _valid_transition_effect(str(effect)):
            warnings.append(f'animations.json {label} has unknown transition effect: {effect}')
    animation = scope.get('animation', {})
    if isinstance(animation, dict):
        effect = animation.get('effect')
        if effect is not None and not _valid_animation_effect(str(effect)):
            warnings.append(f'animations.json {label} has unknown animation effect: {effect}')


def build_scaffold(project_path: Path) -> dict[str, Any]:
    """Build an editable animation override scaffold from current SVGs.

    Chrome groups are omitted — exporter auto-detects them as ``none`` via
    ``is_chrome_id`` at render time, so listing them in the scaffold is pure
    noise. A ``defaults`` stub is emitted up front to remind the editor that
    deck-wide overrides exist and most pages should inherit them.
    """
    targets_by_slide, _anonymous = scan_project_targets(project_path)
    slides: dict[str, Any] = {}
    for slide_name, targets in targets_by_slide.items():
        groups: dict[str, Any] = {}
        for target in targets:
            if target.chrome:
                continue
            groups[target.group_id] = {}
        slides[slide_name] = {'groups': groups}
    return {
        'version': 1,
        'defaults': {
            'transition': {'effect': 'fade', 'duration': 0.4},
            'animation': {
                'effect': 'auto',
                'duration': 0.4,
                'stagger': 0.5,
                'trigger': 'after-previous',
            },
        },
        'slides': slides,
    }


def build_group_listing(project_path: Path) -> tuple[list[str], list[str]]:
    """Return one compact line per slide: ``<slide>: id1, id2, id3``.

    Chrome groups are excluded — matches ``build_scaffold``'s policy so the
    listing reflects exactly what an editor can override. Returns
    ``(lines, anonymous_warnings)``.
    """
    targets_by_slide, anonymous = scan_project_targets(project_path)
    lines: list[str] = []
    for slide_name, targets in targets_by_slide.items():
        ids = [t.group_id for t in targets if not t.chrome]
        if not ids:
            lines.append(f'{slide_name}: (no animatable groups)')
        else:
            lines.append(f'{slide_name}: {", ".join(ids)}')
    return lines, anonymous


def write_scaffold(
    project_path: Path,
    output_path: str | None = None,
    *,
    force: bool = False,
) -> Path:
    """Write ``animations.json`` scaffold and return its path."""
    if output_path:
        path = Path(output_path)
    else:
        path = default_config_path(project_path)
    if output_path and not path.is_absolute():
        path = project_path / path
    if path.exists() and not force:
        raise FileExistsError(f'Animation config already exists: {path}')

    scaffold = build_scaffold(project_path)
    path.write_text(
        json.dumps(scaffold, ensure_ascii=False, indent=2) + '\n',
        encoding='utf-8',
    )
    return path
