"""Terminal text rendering of plan diffs."""

import os
import sys
from collections import Counter
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from itertools import groupby
from typing import TypeGuard

from dagshund import (
    DagshundError,
    DiffState,
    FieldChange,
    Plan,
    ResourceChange,
    ResourceChanges,
    ResourceChangesByType,
    ResourceKey,
    ResourceType,
    action_to_diff_state,
    detect_changes,
    is_resource_changes,
    parse_resource_key,
)


def _is_field_changes(value: object) -> TypeGuard[dict[str, FieldChange]]:
    """Narrow the untyped 'changes' value from a resource entry so iteration preserves key/value types."""
    return isinstance(value, dict)


# ANSI color codes
RESET = "\033[0m"
_BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
RED = "\033[31m"
YELLOW = "\033[33m"
_CYAN = "\033[36m"


@dataclass(frozen=True, slots=True)
class _ActionConfig:
    display: str
    color: str
    symbol: str
    show_field_changes: bool = False


# Action vocabulary is duplicated in two other locations:
# - types.py: action_to_diff_state() match statement
# - js/src/types/plan-schema.ts: knownActionTypes (Zod schema)
_ACTIONS: dict[str, _ActionConfig] = {
    "": _ActionConfig("unchanged", DIM, " "),
    "create": _ActionConfig("create", GREEN, "+"),
    "delete": _ActionConfig("delete", RED, "-"),
    "update": _ActionConfig("update", YELLOW, "~", show_field_changes=True),
    "recreate": _ActionConfig("recreate", YELLOW, "~", show_field_changes=True),
    "resize": _ActionConfig("resize", YELLOW, "~", show_field_changes=True),
    "update_id": _ActionConfig("update_id", YELLOW, "~", show_field_changes=True),
    "skip": _ActionConfig("unchanged", DIM, " "),
}

_DEFAULT_ACTION = _ActionConfig("unknown", RESET, "?")

_DANGEROUS_ACTIONS = frozenset({"delete", "recreate"})

_STATEFUL_RESOURCE_WARNINGS: dict[str, str] = {
    "catalogs": "all schemas, tables, and volumes in this catalog will be lost",
    "schemas": "all tables, views, and volumes in this schema will be lost",
    "volumes": "all files in this volume will be lost",
    "registered_models": "all model versions will be lost",
}


def _supports_color() -> bool:
    """Check if the terminal supports color output.

    Precedence: NO_COLOR (any value) > FORCE_COLOR (non-zero) > isatty().
    NO_COLOR spec: presence disables color regardless of value.
    FORCE_COLOR convention: "0" means unset, any other value forces color on.
    """
    if "NO_COLOR" in os.environ:
        return False
    force = os.environ.get("FORCE_COLOR", "")
    if force and force != "0":
        return True
    return sys.stdout.isatty()


def _colorize(text: str, color: str, *, use_color: bool) -> str:
    if not use_color:
        return text
    return f"{color}{text}{RESET}"


def _action_config(action: str) -> _ActionConfig:
    """Return the display config for an action string."""
    return _ACTIONS.get(action, _DEFAULT_ACTION)


def _format_value(value: object) -> str:
    """Format a value for display, truncating long strings."""
    match value:
        case None:
            return "null"
        case str() if len(value) > 80:
            return f'"{value[:77]}..."'
        case str():
            return f'"{value}"'
        case bool():
            return "true" if value else "false"
        case int() | float():
            return str(value)
        case dict():
            return f"{{{len(value)} fields}}"
        case list():
            return f"[{len(value)} items]"
        case _:
            return repr(value)


def _render_field_change(field_name: str, change: FieldChange, *, use_color: bool) -> str | None:
    """Render a single field-level change, or None if unchanged."""
    action = str(change.get("action", ""))
    if action_to_diff_state(action) == DiffState.UNCHANGED:
        return None

    field_config = _action_config(action)
    prefix = f"      {field_config.symbol} {field_name}"
    match ("old" in change, "new" in change):
        case (True, True):
            suffix = f": {_format_value(change['old'])} -> {_format_value(change['new'])}"
        case (False, True):
            suffix = f": {_format_value(change['new'])}"
        case (True, False):
            suffix = f": {_format_value(change['old'])}"
        case _:
            suffix = ""
    return _colorize(f"{prefix}{suffix}", field_config.color, use_color=use_color)


def _render_resource(
    key: ResourceKey,
    entry: ResourceChange,
    *,
    use_color: bool,
) -> Iterator[str]:
    """Render a single resource entry as lines of text."""
    action = entry.get("action", "")
    action_config = _action_config(action)
    resource_type, resource_name = parse_resource_key(key)

    label = f"  ({action_config.display})" if action_to_diff_state(action) != DiffState.UNCHANGED else ""
    header = f"  {action_config.symbol} {resource_type}/{resource_name}{label}"
    yield _colorize(header, action_config.color, use_color=use_color)

    changes = entry.get("changes", {})
    if _is_field_changes(changes) and changes and action_config.show_field_changes:
        for field_name, change in sorted(changes.items()):
            if not isinstance(change, dict):
                continue
            rendered = _render_field_change(field_name, change, use_color=use_color)
            if rendered is not None:
                yield rendered


def _count_by_action(entries: ResourceChanges) -> dict[_ActionConfig, int]:
    """Count resources grouped by action config."""
    return dict(Counter(_action_config(entry.get("action", "")) for entry in entries.values()))


def _print_header(plan: Plan, *, use_color: bool) -> None:
    """Print the plan version header line."""
    cli_version = plan.get("cli_version", "unknown")
    plan_version = plan.get("plan_version", "?")
    print(
        _colorize(
            f"dagshund plan (v{plan_version}, cli {cli_version})",
            _BOLD,
            use_color=use_color,
        )
    )
    print()


def _resource_type_of(entry: tuple[ResourceKey, ResourceChange]) -> ResourceType:
    """Extract the resource type from a (key, change) pair."""
    return parse_resource_key(entry[0])[0]


def _group_by_resource_type(resources: ResourceChanges) -> ResourceChangesByType:
    """Group plan entries by their resource type (jobs, schemas, etc.)."""
    sorted_entries = sorted(resources.items(), key=_resource_type_of)
    grouped = groupby(sorted_entries, key=_resource_type_of)
    return {resource_type: dict(group) for resource_type, group in grouped}


def _filter_resources(
    entries: ResourceChanges,
    *,
    visible_states: frozenset[DiffState] | None = None,
    resource_filter: Callable[[ResourceKey, ResourceChange], bool] | None = None,
) -> ResourceChanges:
    """Return entries matching both the diff state set and the resource filter predicate."""
    result = entries
    if visible_states is not None:
        result = {k: v for k, v in result.items() if action_to_diff_state(v.get("action", "")) in visible_states}
    if resource_filter is not None:
        result = {k: v for k, v in result.items() if resource_filter(k, v)}
    return result


def _format_group_header(resource_type: ResourceType, total: int, visible: int) -> str:
    """Format group header: 'type (N)' or 'type (visible/total)' when filtered."""
    count = f"({visible}/{total})" if visible != total else f"({total})"
    return f"  {resource_type} {count}"


def _print_resource_groups(
    resource_groups: ResourceChangesByType,
    *,
    use_color: bool,
    visible_states: frozenset[DiffState] | None = None,
    resource_filter: Callable[[ResourceKey, ResourceChange], bool] | None = None,
) -> None:
    """Print each resource type group with its entries."""
    for resource_type, entries in resource_groups.items():  # already sorted by _group_by_resource_type
        visible = _filter_resources(entries, visible_states=visible_states, resource_filter=resource_filter)
        if not visible:
            continue

        header = _format_group_header(resource_type, len(entries), len(visible))
        print(_colorize(header, _CYAN + _BOLD, use_color=use_color))
        for key, entry in sorted(visible.items()):
            for line in _render_resource(key, entry, use_color=use_color):
                print(line)
        print()


def _format_action_count(cfg: _ActionConfig, count: int, *, use_color: bool) -> str:
    """Format a single action count like '+3 create' with color."""
    return _colorize(f"{cfg.symbol}{count} {cfg.display}", cfg.color, use_color=use_color)


def _print_summary(
    resources: ResourceChanges,
    *,
    use_color: bool,
    visible_states: frozenset[DiffState] | None = None,
    resource_filter: Callable[[ResourceKey, ResourceChange], bool] | None = None,
) -> None:
    """Print the action count summary line, filtered to visible states when provided."""
    filtered = _filter_resources(resources, visible_states=visible_states, resource_filter=resource_filter)
    sorted_counts = sorted(_count_by_action(filtered).items(), key=lambda item: item[0].display)
    parts = ", ".join(_format_action_count(cfg, count, use_color=use_color) for cfg, count in sorted_counts)
    if parts:
        print(f"  {parts}")


def _collect_warnings(
    resources: ResourceChanges,
    *,
    visible_states: frozenset[DiffState] | None = None,
    resource_filter: Callable[[ResourceKey, ResourceChange], bool] | None = None,
) -> list[str]:
    """Collect warning messages for dangerous actions on stateful resources."""
    warnings: list[str] = []
    for key, entry in sorted(resources.items()):
        action = entry.get("action", "")
        if action not in _DANGEROUS_ACTIONS:
            continue
        if visible_states is not None and action_to_diff_state(action) not in visible_states:
            continue
        if resource_filter is not None and not resource_filter(key, entry):
            continue
        resource_type, resource_name = parse_resource_key(key)
        risk = _STATEFUL_RESOURCE_WARNINGS.get(resource_type)
        if risk is None:
            continue
        action_display = _action_config(action).display
        warnings.append(f"{resource_type}/{resource_name} will be {action_display}d — {risk}")
    return warnings


def _print_warnings(warnings: list[str], *, use_color: bool) -> None:
    """Print data-loss warnings below the summary line."""
    print()
    print(_colorize("  Dangerous Actions:", RED + _BOLD, use_color=use_color))
    for warning in warnings:
        print(_colorize(f"  \u26a0 {warning}", RED, use_color=use_color))


def render_text(
    plan: Plan,
    *,
    visible_states: frozenset[DiffState] | None = None,
    filter_query: str | None = None,
) -> None:
    """Render colored diff summary to terminal."""
    resources = plan.get("plan", {})
    if not is_resource_changes(resources):
        raise DagshundError("plan must be an object")
    if not resources:
        raise DagshundError("plan is empty")

    resource_filter = None
    if filter_query:
        from dagshund.filter import build_query_predicate

        resource_filter = build_query_predicate(filter_query)

    use_color = _supports_color()
    _print_header(plan, use_color=use_color)

    if not detect_changes(resources):
        print(
            _colorize(
                f"  No changes ({len(resources)} resources unchanged)",
                DIM,
                use_color=use_color,
            )
        )
        return

    _print_resource_groups(
        _group_by_resource_type(resources),
        use_color=use_color,
        visible_states=visible_states,
        resource_filter=resource_filter,
    )
    _print_summary(resources, use_color=use_color, visible_states=visible_states, resource_filter=resource_filter)

    warnings = _collect_warnings(resources, visible_states=visible_states, resource_filter=resource_filter)
    if warnings:
        _print_warnings(warnings, use_color=use_color)
