#!/usr/bin/env python3

from argparse import ArgumentParser
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from secrets import token_bytes
from sys import argv, stdin, stderr
from time import time
from typing import Any, NoReturn, cast
from .data_loader import BundledDataLoader
from .helpers import (
    config, sanitize_filename, validate_path_security, ensure_config_directories
)
from .loaders import SpreadLoader, InvocationLoader
from .version import version

import ast
import json
import os
import re


class DeckLoader:
    """Handles loading and management of tarot deck configurations."""

    def resolve_deck_path(self, filename: str) -> str | None:
        """Resolve deck filename using search order with security validation from
            current directory and ~/.config/tarot-oracle/decks/, returning None if
            not found or invalid.
        """
        safe_filename = sanitize_filename(filename)
        if safe_filename is None:
            return None

        conf = config()
        search_paths = [
            Path.cwd() / safe_filename,
            Path.cwd() / f"{safe_filename}.json",
            Path(conf.path("decks")) / safe_filename,
            Path(conf.path("decks")) / f"{safe_filename}.json"
        ]

        for path in search_paths:
            if path.exists() and path.is_file():
                resolved = path.resolve()
                validate_path_security(resolved, filename)
                return str(resolved)
        return None

    @staticmethod
    def load_deck_config(path: str) -> dict:
        """Load and validate JSON deck configuration."""
        try:
            with open(path, 'r', encoding='utf-8') as f:
                deck_config = json.load(f)

            # Basic validation
            if not isinstance(deck_config, dict):
                raise ValueError(f"Deck configuration must be a JSON object: {path}")

            # Validate required top-level fields
            if 'name' not in deck_config:
                raise ValueError(f"Deck configuration must include 'name' field: {path}")

            return deck_config

        except json.JSONDecodeError as e:
            raise ValueError(f"Invalid JSON in deck file: {e} (file: {path})")
        except FileNotFoundError:
            raise ValueError(f"Deck file not found: {path}")
        except ValueError:
            raise
        except Exception as e:
            raise ValueError(f"Error loading deck file: {e} (file: {path})")

    def list_available_decks(self) -> list[dict[str, str]]:
        """Scan ~/.config/tarot-oracle/decks/ and return deck metadata."""
        conf = config()
        decks_dir = Path(conf.path("decks"))

        if not decks_dir.exists() or not decks_dir.is_dir():
            return []

        decks = []

        # Find all .json files in decks directory
        for json_file in decks_dir.glob("*.json"):
            try:
                deck_config = DeckLoader.load_deck_config(str(json_file))
                decks.append({
                    "filename": json_file.name,
                    "name": deck_config.get("name", "Unnamed Deck"),
                    "description": deck_config.get(
                        "description", "No description available"
                    )
                })
            except Exception:
                # Skip invalid deck files
                continue

        # Sort by filename for consistent ordering
        decks.sort(key=lambda x: x["filename"])
        return decks

    def load_deck(self, deck_name: str) -> "Deck":
        """Load deck by name using search order. Returns loaded deck instance,
            raises ValueError if not found.
        """
        deck_path = self.resolve_deck_path(deck_name)
        if deck_path is None:
            raise ValueError(f"Deck '{deck_name}' not found in search paths")

        return Deck(deck_path)

    def save_deck(self, source_path: str) -> str:
        """Save deck from CWD to config directory. Validates deck config,
            prompts for overwrite confirmation if exists. Returns saved path.
        """
        conf = config()
        source = Path(source_path).resolve()

        if not source.exists():
            raise ValueError(f"Source file not found: {source_path}")

        # Load and validate deck config
        deck_config = self.load_deck_config(str(source))

        # Use filename stem as deck name
        safe_name = sanitize_filename(source.stem)
        if safe_name is None:
            raise ValueError("Invalid deck name")

        decks_dir = Path(conf.path("decks"))
        decks_dir.mkdir(parents=True, exist_ok=True)

        target_path = decks_dir / f"{safe_name}.json"

        # Check for existing file
        if target_path.exists():
            print(f"Deck '{safe_name}.json' already exists in config directory.")
            response = input("Overwrite? (y/N): ").strip().lower()
            if response != 'y':
                print("Save cancelled.")
                raise KeyboardInterrupt()

        # Copy validated config
        try:
            with open(target_path, 'w', encoding='utf-8') as f:
                json.dump(deck_config, f, indent=2)
            return str(target_path)
        except OSError as e:
            raise OSError(f"Failed to save deck: {e}")


@dataclass
class Card:
    """Represents a single tarot card with metadata and keywords. Stores
        classification (major/minor), suit, value, and interpretation
        keywords. Supports upright and reversed positions. Reversal state
        set during reading.
    """
    name: str
    card_type: str  # 'major' or 'minor'
    suit: str|None  # W, C, S, P or None for major arcana
    value: str  # Roman numeral for major, numeric/letter for minor
    keywords: str  # Card interpretation keywords
    reversed_keywords: str|None = None  # Optional reversed card meanings
    is_reversed: bool = False  # Set during shuffling

    def get_keywords(self) -> str:
        """Get appropriate keywords based on reversal state."""
        if self.is_reversed and self.reversed_keywords:
            return self.reversed_keywords
        return self.keywords

    def get_notation_code(self) -> str:
        """Get raw card notation without formatting."""
        if self.card_type == 'major':
            return self.value  # Roman numeral or 0 for Fool
        else:
            return f"{self.suit}{self.value}"  # e.g., W3, CK, PN

    def get_notation(self) -> str:
        """Get card notation with reversal formatting (7 characters wide)."""
        code = self.get_notation_code()
        if self.is_reversed:
            # [↓XVI] = 7 chars: [ + ↓ + 4 chars + ]
            return f"[↓{code:<4}]"
        else:
            # [ XVI ] = 7 chars: [ + space + 4 chars + ]
            return f"[ {code:<4}]"


class DeterministicRNG:
    """Simple linear congruential generator for deterministic random numbers."""

    def __init__(self, seed: int) -> None:
        self.state = seed

    def next_int(self, max_value: int) -> int:
        """Generate next pseudo-random integer."""
        self.state = (self.state * 1103515245 + 12345) & 0x7fffffff
        return self.state % max_value


class Deck:
    """Tarot deck with card loading and shuffling functionality. Loads
        standard 78-card deck or custom JSON configurations. Provides
        deterministic shuffling with optional reversed card assignment.
    """

    @staticmethod
    def load_deck_by_name(deck_name: str) -> dict[str, Any]:
        """Load deck configuration by name from bundled data or user
            files. Searches bundled decks first, then user config
            directory. Returns deck config dict or raises ValueError if
            not found.
        """
        safe_name = sanitize_filename(deck_name)
        if safe_name is None:
            raise ValueError(f"Invalid deck name: '{deck_name}'")

        bundled_deck = BundledDataLoader.load_deck(safe_name)
        if bundled_deck:
            return bundled_deck

        deck_loader = DeckLoader()
        deck_path = deck_loader.resolve_deck_path(safe_name)
        if deck_path:
            return DeckLoader.load_deck_config(deck_path)

        bundled_decks = BundledDataLoader.list_decks()
        conf = config()
        error_msg = f"Deck '{deck_name}' not found.\n\n"
        error_msg += f"Available bundled decks: {', '.join(bundled_decks)}\n\n"
        error_msg += f"Searched paths:\n"
        error_msg += f"  Bundled decks: {safe_name}\n"
        error_msg += f"  ./{safe_name}\n"
        error_msg += f"  ./{safe_name}.json\n"
        error_msg += f"  {conf.path('decks')}/{safe_name}\n"
        error_msg += f"  {conf.path('decks')}/{safe_name}.json"
        raise ValueError(error_msg)

    def __init__(
            self, deck_path: str | None = None,
            deck_config: dict[str, Any] | None = None
        ) -> None:
        self.cards = []
        self.shuffled = []

        if deck_config:
            self.cards = self._load_deck_from_config(deck_config)
        elif deck_path:
            deck_config = DeckLoader.load_deck_config(deck_path)
            self.cards = self._load_deck_from_config(deck_config)
        else:
            bundled_deck = BundledDataLoader.load_deck("rider-waite")
            if bundled_deck:
                self.cards = self._load_deck_from_config(bundled_deck)
            else:
                raise ValueError("Default deck 'rider-waite' not found in bundled data")

    @staticmethod
    def _load_deck_from_config(deck_config: dict[str, Any]) -> list[Card]:
        """Load deck from configuration dictionary.

            Supports 'cards' array format where each card has:
            - name: Card display name
            - card_type: 'major' or 'minor'
            - suit: W, C, S, P or null for major arcana
            - value: Roman numeral for major, letter/number for minor
            - keywords: Card interpretation keywords
            - reversed_keywords: Optional reversed meanings

            Returns list of Card objects or raises ValueError if invalid.
        """
        if 'cards' not in deck_config:
            raise ValueError(f"Deck configuration must include 'cards' field")

        cards_list = deck_config['cards']
        if not isinstance(cards_list, list):
            raise ValueError(f"Deck 'cards' must be a list")

        cards = []
        for i, card_data in enumerate(cards_list):
            if not isinstance(card_data, dict):
                print(
                    f"Warning: Skipped card at index {i} - expected dict, "
                    f"got {type(card_data).__name__}", file=stderr
                )
                continue

            name = cast(str, card_data.get('name'))
            card_type = cast(str, card_data.get('card_type'))
            suit = card_data.get('suit')
            value = cast(str, card_data.get('value'))
            keywords = cast(str, card_data.get('keywords'))
            reversed_keywords = card_data.get('reversed_keywords')

            if not all([name, card_type, value, keywords]):
                missing = []
                if not name:
                    missing.append("'name'")
                if not card_type:
                    missing.append("'card_type'")
                if not value:
                    missing.append("'value'")
                if not keywords:
                    missing.append("'keywords'")
                card_label = name if name else f"index {i}"
                print(
                    f"Warning: Skipped card '{card_label}' - missing required"
                    f" fields: {', '.join(missing)}", file=stderr
                )
                continue

            if card_type not in ('major', 'minor'):
                print(
                    f"Warning: Skipped card '{name}' (index {i}) - invalid"
                    f" card_type '{card_type}', must be 'major' or 'minor'",
                    file=stderr
                )
                continue

            card = Card(
                name=name,
                card_type=card_type,
                suit=suit,
                value=value,
                keywords=keywords,
                reversed_keywords=reversed_keywords
            )
            cards.append(card)

        if not cards:
            raise ValueError("No valid cards found in deck configuration")

        return cards

    def shuffle_and_assign_reversals(
            self, rng: DeterministicRNG, allow_reversed: bool = False
        ) -> None:
        """Shuffle the deck using Fisher-Yates algorithm and assign reversals."""
        self.shuffled = self.cards.copy()
        n = len(self.shuffled)

        # Fisher-Yates shuffle
        for i in range(n - 1, 0, -1):
            j = rng.next_int(i + 1)
            self.shuffled[i], self.shuffled[j] = self.shuffled[j], self.shuffled[i]

        # Assign reversals during shuffle
        if allow_reversed:
            for card in self.shuffled:
                card.is_reversed = (rng.next_int(2) == 1)  # 50% chance

    def shuffle(self, rng: DeterministicRNG) -> None:
        """Shuffle the deck using Fisher-Yates algorithm."""
        self.shuffle_and_assign_reversals(rng, allow_reversed=False)

    def draw_cards(self, count: int) -> list[Card]:
        """Draw specified number of cards from shuffled deck."""
        if not self.shuffled:
            raise ValueError("Deck must be shuffled before drawing cards")
        return self.shuffled[:count]


class SpreadRenderer:
    """Renders tarot spreads in terminal ASCII, JSON, and semantic legend
        formats. Supports matrix and linear layouts with position-based
        formatting and reversal notation. Outputs card notation with
        keywords for display or programmatic consumption.
    """

    @staticmethod
    def format_card_simple(card: Card) -> str:
        """Format a card as simple bracket notation with 8-char width."""
        return f"{card.get_notation()}"  # Use enhanced notation with reversal support

    @staticmethod
    def render_spread(cards: list[Card], spread_layout: list[list[int]]) -> str:
        """Render the complete spread with simple notation."""
        # Check if this is a matrix layout (more than one row)
        is_matrix = len(spread_layout) > 1

        if is_matrix:
            # Handle as matrix
            flat_positions = []
            for row in spread_layout:
                for pos in row:
                    flat_positions.append(pos)

            unique_positions = sorted(set(pos for pos in flat_positions if pos > 0))
            position_to_card = {}
            for i, position in enumerate(unique_positions):
                if i < len(cards):
                    position_to_card[position] = cards[i]

            rows = []
            for row in spread_layout:
                row_strings = []
                for position in row:
                    if position == 0:
                        row_strings.append(" " * 7)  # 7 spaces for empty position
                    else:
                        row_strings.append(
                            SpreadRenderer.format_card_simple(
                                position_to_card[position]
                            )
                        )
                rows.append((" " * 3).join(row_strings))  # 3 spaces between cards
            return "\n\n".join(rows)  # Single blank line between rows
        else:
            # Handle as linear - extract the single row
            linear_layout = spread_layout[0] if spread_layout else []
            unique_positions = sorted(set(pos for pos in linear_layout if pos > 0))
            position_to_card = {}
            for i, position in enumerate(unique_positions):
                if i < len(cards):
                    position_to_card[position] = cards[i]

            card_strings = []
            for position in linear_layout:
                if position == 0:
                    card_strings.append("        ")  # 8 spaces for empty position
                else:
                    card_strings.append(
                        SpreadRenderer.format_card_simple(position_to_card[position])
                    )

            return " ".join(card_strings)  # Single space between cards

    @staticmethod
    def render_legend(cards: list[Card], include_keywords: bool = False) -> str:
        """Render the legend for drawn cards in drawing order."""
        if not cards:
            return ""

        legend_lines = ["Legend:"]
        for card in cards:
            if card.card_type == 'major':
                type_name = "Major Arcana"
            else:
                suit_names = {'W': 'Wands', 'C': 'Cups', 'S': 'Swords', 'P': 'Pentacles'}
                type_name = suit_names[card.suit or '']

            legend_line = f"{card.get_notation()} - {card.name} ({type_name})"
            if include_keywords:
                legend_line += f": {card.get_keywords()}"
            legend_lines.append(legend_line)

        return "\n".join(legend_lines)

    @staticmethod
    def render_spread_json(
            cards: list[Card], spread_layout: list[list[int]]
        ) -> list[list[str]]:
        """Render spread as JSON matrix mirroring ASCII format."""
        # Check if this is a matrix layout (more than one row)
        is_matrix = len(spread_layout) > 1

        if is_matrix:
            # Handle as matrix - return 2D array
            flat_positions = []
            for row in spread_layout:
                for pos in row:
                    flat_positions.append(pos)

            unique_positions = sorted(set(pos for pos in flat_positions if pos > 0))
            position_to_card = {}
            for i, position in enumerate(unique_positions):
                if i < len(cards):
                    position_to_card[position] = cards[i]

            rows = []
            for row in spread_layout:
                row_strings = []
                for position in row:
                    if position == 0:
                        row_strings.append("    ")  # 4 spaces for empty position
                    else:
                        row_strings.append(position_to_card[position].get_notation())
                rows.append(row_strings)
            return rows
        else:
            # Handle as linear - return single row array
            linear_layout = spread_layout[0] if spread_layout else []
            unique_positions = sorted(set(pos for pos in linear_layout if pos > 0))
            position_to_card = {}
            for i, position in enumerate(unique_positions):
                if i < len(cards):
                    position_to_card[position] = cards[i]

            card_strings = []
            for position in linear_layout:
                if position == 0:
                    card_strings.append("    ")  # 7 spaces for empty position
                else:
                    card_strings.append(position_to_card[position].get_notation())

            return [card_strings]  # Return as single row for consistency

    @staticmethod
    def render_legend_json(
            cards: list[Card], include_keywords: bool = True
        ) -> list[dict[str, Any]]:
        """Render legend as structured list of card dictionaries."""
        if not cards:
            return []

        legend_data = []
        for card in cards:
            if card.card_type == 'major':
                type_name = "Major Arcana"
            else:
                suit_names = {'W': 'Wands', 'C': 'Cups', 'S': 'Swords', 'P': 'Pentacles'}
                type_name = suit_names[card.suit or '']

            card_dict = {
                "notation": card.get_notation(),
                "name": card.name,
                "type": type_name
            }

            if include_keywords:
                card_dict["keywords"] = card.get_keywords()

            legend_data.append(card_dict)

        return legend_data

    @staticmethod
    def render_json(
            cards: list[Card], spread_layout: list[list[int]],
            include_legend: bool = True
        ) -> dict[str, Any]:
        """Render complete reading as JSON structure."""
        matrix = SpreadRenderer.render_spread_json(cards, spread_layout)
        result: dict[str, Any] = {"spread": matrix}

        if include_legend:
            result["legend"] = SpreadRenderer.render_legend_json(
                cards, include_keywords=True
            )

        return result

    @staticmethod
    def render_semantic_legend(
            cards: list[Card], layout: list[list[int]],
            semantics: list[list[str]]|None = None,
            semantic_groups: dict[str, str]|None = None,
            include_keywords: bool = False
        ) -> str:
        """Render legend with semantic groupings."""
        adapter = SemanticAdapter(layout, cards, semantics, semantic_groups)
        return adapter.render_semantic_legend(include_keywords)

    @staticmethod
    def render_descriptions(cards: list[Card]) -> str:
        """Render card descriptions for drawn cards."""
        if not cards:
            return ""

        description_lines = ["Card Keywords:"]
        for card in cards:
            description_lines.append(f"{card.get_notation()} {card.get_keywords()}")

        return "\n".join(description_lines)


class SemanticAdapter:
    """Maps cards to semantic meanings based on spread position."""

    def __init__(self, layout: list[list[int]], cards: list[Card],
                 semantics: list[list[str]]|None = None,
                 semantic_groups: dict[str, str]|None = None) -> None:
        """Initialize adapter with layout, cards, semantics matrix, and optional
            semantic_groups for variable placeholder resolution.
        """
        self.layout = layout
        self.cards = cards
        self.semantic_groups = semantic_groups or {}
        self.semantics = self._process_semantics(semantics) if semantics else []

    def resolve_variables(self, text: str) -> str:
        """Replace variable placeholders like ${cap} with semantic_group
            definitions.
        """
        for var_name, definition in self.semantic_groups.items():
            placeholder = f"${{{var_name}}}"
            text = text.replace(placeholder, definition)
        return text

    def _process_semantics(self, semantics: list[list[str]]) -> list[list[str]]:
        """Resolve variable placeholders in semantics."""
        resolved = []
        for row in semantics:
            resolved_row = []
            for cell in row:
                if isinstance(cell, str):
                    resolved_cell = self.resolve_variables(cell)
                else:
                    resolved_cell = cell
                resolved_row.append(resolved_cell)
            resolved.append(resolved_row)
        return resolved

    def _get_semantic_for_position(self, position: int) -> str|None:
        """Find semantic value for a given position in layout."""
        if not self.semantics:
            return None

        for row_idx, layout_row in enumerate(self.layout):
            for col_idx, pos in enumerate(layout_row):
                if pos == position:
                    semantic = self.semantics[row_idx][col_idx]
                    return semantic if semantic else None
        return None

    def _build_card_index_to_semantic(self) -> dict[int, str]:
        """Map card drawing order to semantic meanings."""
        flat_positions = []
        for row in self.layout:
            for pos in row:
                flat_positions.append(pos)

        unique_positions = sorted(set(pos for pos in flat_positions if pos > 0))

        card_index_to_semantic = {}
        for card_idx, position in enumerate(unique_positions):
            semantic = self._get_semantic_for_position(position)
            if semantic:
                card_index_to_semantic[card_idx] = semantic

        return card_index_to_semantic

    def _group_cards_by_semantic(self) -> dict[str, list[Card]]:
        """Group cards by semantic meanings."""
        card_index_to_semantic = self._build_card_index_to_semantic()
        semantic_to_cards = {}

        for card_idx, card in enumerate(self.cards):
            semantic = card_index_to_semantic.get(card_idx, "General Information")
            if semantic not in semantic_to_cards:
                semantic_to_cards[semantic] = []
            semantic_to_cards[semantic].append(card)

        return semantic_to_cards

    def _format_card_line(self, card: Card, include_keywords: bool) -> str:
        """Format individual card line for legend."""
        if card.card_type == 'major':
            type_name = "Major Arcana"
        else:
            suit_names = {'W': 'Wands', 'C': 'Cups', 'S': 'Swords', 'P': 'Pentacles'}
            type_name = suit_names[card.suit or '']

        legend_line = f"  {card.get_notation()} - {card.name} ({type_name})"
        if include_keywords:
            legend_line += f": {card.get_keywords()}"

        return legend_line

    def render_semantic_legend(self, include_keywords: bool = False) -> str:
        """Render legend with semantic groupings."""
        semantic_groups = self._group_cards_by_semantic()

        if not self.cards:
            return ""

        lines = []

        # Process semantic groups (alphabetical order for simplicity)
        semantic_keys = [k for k in semantic_groups.keys() if k != "General Information"]
        semantic_keys.sort()

        # Add first semantic group without leading newline
        if semantic_keys:
            lines.append(f"{semantic_keys[0]}:")
            for card in semantic_groups[semantic_keys[0]]:
                lines.append(self._format_card_line(card, include_keywords))

        # Add remaining semantic groups with leading newlines
        for semantic in semantic_keys[1:]:
            lines.append(f"\n{semantic}:")
            for card in semantic_groups[semantic]:
                lines.append(self._format_card_line(card, include_keywords))

        # Add General Information last if it exists
        if "General Information" in semantic_groups:
            if lines:
                lines.append(f"\nGeneral Information:")
            else:
                lines.append(f"General Information:")
            for card in semantic_groups["General Information"]:
                lines.append(self._format_card_line(card, include_keywords))

        return "\n".join(lines)

    def get_guidance(self, semantic_config: dict[str, Any]|None) -> list[str]:
        """Get guidance array from semantic config if available."""
        if not semantic_config or 'guidance' not in semantic_config:
            return []

        guidance = []
        for guidance_text in semantic_config['guidance']:
            resolved = self.resolve_variables(guidance_text)
            if resolved:
                guidance.append(f"- {resolved}")
        return guidance


class TarotDivination:
    """Main orchestrator for tarot divination readings. Handles deck
        management, card drawing with cryptographic seeds, and spread
        processing. Returns structured output for ASCII display or JSON
        export with semantic groupings.
    """

    def __init__(
            self, deck_path: str | None = None,
            deck_config: dict[str, Any] | None = None
        ) -> None:
        self.deck = Deck(deck_path=deck_path, deck_config=deck_config)

    def create_seed(
            self, timestamp: str, question: str, invocation: str|None = None,
            random_bytes: int = 0
        ) -> int:
        """Create seed from timestamp, question, optional invocation, and
            random bytes.
        """
        seed_data = f"{timestamp}{question}"
        if invocation:
            seed_data += f"|{invocation}"  # Use | as separator
        if random_bytes > 0:
            random_suffix = token_bytes(random_bytes).hex()
            seed_data += random_suffix

        return int.from_bytes(sha256(seed_data.encode()).digest(), 'little')

    def _normalize_spread_layout(
            self, spread_layout: list[list[int]] | list[int]
        ) -> tuple[list[list[int]], int]:
        """Normalize spread layout to matrix format and return (layout,
            card_count).
        """
        # Handle both list[int] and list[list[int]] cases
        if not spread_layout:
            normalized_layout: list[list[int]] = []
        elif isinstance(spread_layout[0], int):
            # This is a linear layout like [1, 2, 3], convert to [[1, 2, 3]]
            # Type assertion: we know this is list[int] due to isinstance check
            linear_layout = cast(list[int], spread_layout)
            normalized_layout = [linear_layout]
        else:
            # Already in matrix format
            # Type assertion: we know this is list[list[int]] due to isinstance check
            normalized_layout = cast(list[list[int]], spread_layout)

        needed_cards = len([pos for row in normalized_layout for pos in row if pos > 0])
        return normalized_layout, needed_cards

    def draw_cards_for_reading(
            self, seed: int, spread_layout: list[list[int]] | list[int],
            allow_reversed: bool = False
        ) -> list[Card]:
        """Draw cards for reading using explicit seed - pure deterministic function."""
        rng = DeterministicRNG(seed)
        self.deck.shuffle_and_assign_reversals(rng, allow_reversed)

        _, needed_cards = self._normalize_spread_layout(spread_layout)
        return self.deck.draw_cards(needed_cards)

    def perform_reading_json(
            self, question: str, spread_layout: list[list[int]] | list[int],
            invocation: str|None = None, random_bytes: int = 0,
            allow_reversed: bool = False, include_legend: bool = True
        ) -> dict[str, Any]:
        """Perform tarot reading and return JSON-serializable data."""
        # Create seed and draw cards (reuse existing logic)
        timestamp = str(int(time()))
        seed = self.create_seed(timestamp, question, invocation, random_bytes)
        drawn_cards = self.draw_cards_for_reading(seed, spread_layout, allow_reversed)

        # Generate JSON structure
        json_data = SpreadRenderer.render_json(
            drawn_cards, spread_layout, include_legend
        )

        # Add metadata
        json_data.update({
            "question": question,
            "spread_type": str(spread_layout),
            "timestamp": timestamp,
            "seed": seed,
            "allow_reversed": allow_reversed,
            "invocation": invocation
        })

        return json_data

    def perform_reading(
            self, question: str, spread_input: str, invocation: str|None = None,
            random_bytes: int = 0, allow_reversed: bool = False,
            show_descriptions: bool = True
        ) -> tuple[str, str]:
        """Perform tarot reading with semantic groupings and return tuple of
            (spread_display, legend_display).
        """
        # Resolve spread with semantic configuration
        layout, semantic_config = resolve_spread(spread_input)

        # Create seed
        timestamp = str(int(time()))
        seed = self.create_seed(timestamp, question, invocation, random_bytes)

        # Draw cards
        drawn_cards = self.draw_cards_for_reading(seed, layout, allow_reversed)

        # Format output
        normalized_layout, _ = self._normalize_spread_layout(layout)
        spread_display = SpreadRenderer.render_spread(drawn_cards, normalized_layout)

        # Get semantics matrix
        if semantic_config and 'semantics' in semantic_config:
            semantics_matrix = semantic_config['semantics']
        else:
            semantics_matrix = None

        # Get semantic_groups from semantic_config
        semantic_groups = semantic_config.get('semantic_groups') \
            if semantic_config else None

        # Use semantic adapter for legend
        adapter = SemanticAdapter(
            normalized_layout, drawn_cards, semantics_matrix, semantic_groups
        )
        legend_display = adapter.render_semantic_legend(
            include_keywords=show_descriptions
        )

        # Add guidance if available (custom spreads only)
        guidance = adapter.get_guidance(semantic_config) if semantic_config else []
        if guidance:
            legend_display += "\n\n## Interpretive Guidance\n" + "\n".join(guidance)

        return spread_display, legend_display


def resolve_spread(spread_input: str) -> tuple[list[list[int]], dict[str, Any]|None]:
    """Resolve spread with semantic configuration from alias, custom file, or
        custom matrix. Returns tuple of (layout, semantic_config).
    """
    bundled_spread = BundledDataLoader.load_spread(spread_input)
    if bundled_spread and 'layout' in bundled_spread:
        layout = bundled_spread['layout']
        semantic_config = {k: v for k, v in bundled_spread.items() if k != 'layout'}
        return layout, semantic_config

    # Try to load custom spread
    loader = SpreadLoader()
    custom_spread = loader.load_spread(spread_input)
    if custom_spread and 'layout' in custom_spread:
        layout = custom_spread['layout']
        semantic_config = {k: v for k, v in custom_spread.items() if k != 'layout'}
        return layout, semantic_config

    # Try to parse as custom matrix
    try:
        layout = ast.literal_eval(spread_input)
        return layout, None
    except (ValueError, SyntaxError):
        raise ValueError(
            f"Invalid spread '{spread_input}'. Use aliases: "
            f"{BundledDataLoader.list_spreads()}, custom spread name, or custom"
            " matrix."
        )


def resolve_card_codes(codes: str) -> list[Card]:
    """Resolve CSV card codes to Card objects."""
    if not codes:
        return []

    # Split CSV and clean whitespace
    code_list = [code.strip() for code in codes.split(',') if code.strip()]

    # Create a temporary deck for card lookup
    deck = Deck()
    card_dict = {}

    # Create lookup dictionary
    for card in deck.cards:
        code = card.get_notation_code()
        card_dict[code] = card

    resolved_cards = []
    for code in code_list:
        # Handle reversal notation like [↓XVI]
        is_reversed = False
        if code.startswith('[↓') and code.endswith(']'):
            is_reversed = True
            code = code[2:-1].strip()  # Remove [↓ and ]
        elif code.startswith('[ ') and code.endswith(']'):
            code = code[2:-1].strip()  # Remove [ and ]

        # Clean up common notation issues
        # Convert underscore notation (C_Q) to direct notation (CQ)
        if '_' in code and len(code) == 3 and code[1] == '_':
            code = code[0] + code[2]

        # Look up the card
        if code in card_dict:
            card = card_dict[code]
            # Create a copy and set reversal if needed
            resolved_card = Card(
                card.name, card.card_type, card.suit, card.value, card.keywords,
                card.reversed_keywords, is_reversed
            )
            resolved_cards.append(resolved_card)
        else:
            raise ValueError(
                f"Invalid card code: '{code}'. Valid codes include major arcana "
                " (I, II, etc.) and minor arcana (W3, CQ, SA, PK, etc.)"
            )

    return resolved_cards


def create_parser() -> ArgumentParser:
    """Create command line argument parser."""
    parser = ArgumentParser(description="Tarot divination script")
    parser.add_argument(
        '--version', action='version', version=f'%(prog)s {version()}'
    )
    parser.add_argument(
        "question", nargs='?',
        help="Question for tarot reading (ignored with --lookup, --list-decks, "
        "--export-deck, and --export-spread)"
    )
    parser.add_argument(
        "--lookup", help="Look up card codes (CSV format, e.g., 'I,W3,C_Q,XVII')"
    )
    parser.add_argument("--invocation", help="Custom invocation to influence reading")
    parser.add_argument(
        "--invocation-name", dest="invocation_name",
        help="Load invocation by name (custom file or bundled invocation)"
    )
    parser.add_argument(
        "--invoke", action="store_true",
        help="Use default invocation to influence reading"
    )
    parser.add_argument(
        "--spread", default="3-card",
        help=f"Spread layout (default: 3-card). Available: "
        f"{BundledDataLoader.list_spreads()} or custom matrix"
    )
    parser.add_argument(
        "--random", type=int, default=8,
        help="Add N random bytes to RNG seed for entropy (default: 8)"
    )
    parser.add_argument(
        "--no-keywords", action="store_true",
        help="Hide card keyword descriptions (shown by default)"
    )
    parser.add_argument(
        "--reversed", action="store_true",
        help="Allow cards to appear reversed"
    )
    parser.add_argument(
        "--json", action="store_true",
        help="Output reading in JSON format"
    )
    parser.add_argument("--deck", help="Use custom deck configuration filename")
    parser.add_argument(
        "--list-decks", action="store_true",
        help="List available deck configurations"
    )
    parser.add_argument(
        "--list-spreads", action="store_true",
        help="List available spread configurations"
    )
    parser.add_argument(
        "--list-invocations", action="store_true",
        help="List available invocations (bundled and custom)"
    )
    parser.add_argument(
        "--export-deck", metavar="NAME",
        help="Export a bundled deck to stdout (pipe to file to save)"
    )
    parser.add_argument(
        "--export-spread", metavar="NAME",
        help="Export a bundled spread to stdout (pipe to file to save)"
    )
    parser.add_argument(
        "--show-invocation", metavar="NAME",
        help="Display invocation text by name and exit"
    )
    parser.add_argument(
        "--save-deck", metavar="SOURCE",
        help="Save deck from current directory to config location"
    )
    parser.add_argument(
        "--save-spread", metavar="SOURCE",
        help="Save spread from current directory to config location"
    )
    return parser


def get_invocation_text(args) -> str|None:
    """Get invocation text from command line arguments."""
    if args.invocation_name:
        loader = InvocationLoader()
        invocation = loader.load_invocation(args.invocation_name)
        if invocation:
            return invocation
        bundled = BundledDataLoader.load_invocation(args.invocation_name)
        if bundled:
            return bundled
        raise ValueError(f"Invocation '{args.invocation_name}' not found")
    elif args.invocation:
        return args.invocation
    elif args.invoke:
        return BundledDataLoader.load_invocation("default-hermes-thoth-prometheus")
    else:
        return None


def main(args=None) -> int:
    """Main CLI interface."""
    parser = create_parser()

    if args is None:
        args = parser.parse_args()
    else:
        args = parser.parse_args(args)

    ensure_config_directories()

    if args.show_invocation:
        loader = InvocationLoader()
        custom_invocation = loader.load_invocation(args.show_invocation)
        if custom_invocation:
            print(custom_invocation)
            return 0
        bundled_invocation = BundledDataLoader.load_invocation(
            args.show_invocation
        )
        if bundled_invocation:
            print(bundled_invocation)
            return 0
        print(
            f"Error: Invocation '{args.show_invocation}' not found",
            file=stderr
        )
        print("Use --list-invocations to see available invocations", file=stderr)
        return 1

    # Handle list-decks mode
    if args.list_decks:
        deck_loader = DeckLoader()
        user_decks = deck_loader.list_available_decks()
        bundled_decks = BundledDataLoader.list_decks()

        print("Bundled decks:")
        for deck_name in bundled_decks:
            print(f"  {deck_name:<20} - {deck_name:<20} - (built-in)")

        if user_decks:
            print("\nUser decks:")
            for deck in user_decks:
                filename = deck['filename']
                name = deck['name']
                description = deck['description']
                print(f"  {filename:<20} - {name:<20} - {description}")

        return 0

    # Handle export-deck mode
    if args.export_deck:
        deck_json = BundledDataLoader.export_deck(args.export_deck)
        if deck_json:
            print(deck_json)
            return 0
        else:
            print(f"Error: Deck '{args.export_deck}' not found")
            return 1

    # Handle export-spread mode
    if args.export_spread:
        spread_json = BundledDataLoader.export_spread(args.export_spread)
        if spread_json:
            print(spread_json)
            return 0
        else:
            print(f"Error: Spread '{args.export_spread}' not found")
            return 1

    # Handle list-spreads mode
    if args.list_spreads:
        loader = SpreadLoader()
        user_spreads = loader.list_spreads()
        bundled_spreads = BundledDataLoader.list_spreads()

        print("Bundled spreads:")
        for spread_name in bundled_spreads:
            print(f"  {spread_name:<20} - (built-in)")

        if user_spreads:
            print("\nUser spreads:")
            for spread in user_spreads:
                filename = spread['filename']
                name = spread['name']
                description = spread['description']
                print(f"  {filename:<20} - {name:<20} - {description}")

        return 0

    # Handle list-invocations mode
    if args.list_invocations:
        loader = InvocationLoader()
        user_invocations = loader.list_invocations()
        bundled_invocations = BundledDataLoader.list_invocations()

        print("Bundled invocations:")
        for inv_name in bundled_invocations:
            print(f"  {inv_name:<20} - (built-in)")

        if user_invocations:
            print("\nUser invocations:")
            for inv in user_invocations:
                filename = inv['filename']
                name = inv['name']
                preview = inv['preview']
                if preview and preview != "Empty invocation file":
                    print(f"  {filename:<20} - {name:<20} - {preview}")
                else:
                    print(f"  {filename:<20} - {name:<20}")

        return 0

    # Handle --save-deck mode
    if args.save_deck:
        deck_loader = DeckLoader()
        try:
            saved_path = deck_loader.save_deck(args.save_deck)
            print(f"Deck saved to: {saved_path}")
            return 0
        except KeyboardInterrupt:
            return 0
        except Exception as e:
            print(f"Error: {e}", file=stderr)
            return 1

    # Handle --save-spread mode
    if args.save_spread:
        spread_loader = SpreadLoader()
        try:
            spread_path = Path(args.save_spread).resolve()

            # Validate path is within project (security)
            cwd_resolved = Path.cwd().resolve()
            try:
                spread_path.relative_to(cwd_resolved)
            except ValueError:
                raise ValueError(
                    "Source file must be in or below current directory"
                )

            with open(spread_path, 'r', encoding='utf-8') as f:
                spread_config = json.load(f)

            # Use filename stem as spread name
            safe_name = sanitize_filename(spread_path.stem)
            if safe_name is None:
                raise ValueError("Invalid spread name")

            saved_path = spread_loader.save_spread(safe_name, spread_config)
            print(f"Spread saved to: {saved_path}")
            return 0
        except KeyboardInterrupt:
            return 0
        except Exception as e:
            print(f"Error: {e}", file=stderr)
            return 1

    # Handle lookup mode
    if args.lookup:
        try:
            cards = resolve_card_codes(args.lookup)
        except ValueError as e:
            print(f"Error: {e}")
            return 1

        if args.json:
            # JSON output for lookup
            json_data = SpreadRenderer.render_legend_json(cards, include_keywords=True)
            print(json.dumps(json_data, indent=2, ensure_ascii=False))
        else:
            # ASCII output for lookup
            legend_display = SpreadRenderer.render_legend(cards, include_keywords=True)
            print(legend_display)

        return 0

    # Resolve deck path if specified
    deck_path = None
    deck_config = None

    if args.deck:
        try:
            deck_config = Deck.load_deck_by_name(args.deck)
        except ValueError as e:
            print(f"Error: {e}")
            return 1

    # Validate that question is provided for reading mode
    if not args.question:
        print(
            "Error: Question is required for tarot reading. Use --lookup to look"
            " up card codes or --list-decks to see available decks."
        )
        return 1

    try:
        spread_layout = resolve_spread(args.spread)
    except ValueError as e:
        print(f"Error: {e}")
        return 1

    # Get invocation from arguments
    invocation = get_invocation_text(args)

    try:
        tarot = TarotDivination(deck_path=deck_path, deck_config=deck_config)
    except ValueError as e:
        print(f"Error loading deck: {e}")
        return 1

    if args.json:
        # JSON output path
        json_data = tarot.perform_reading_json(
            args.question, spread_layout, invocation, args.random, args.reversed,
            include_legend=True
        )
        print(json.dumps(json_data, indent=2, ensure_ascii=False))
    else:
        # ASCII output path
        spread_display, legend_display = tarot.perform_reading(
            args.question, args.spread, invocation, args.random, args.reversed,
            not args.no_keywords
        )
        print(f"Question: {args.question}")
        if invocation:
            print(f"Reading influenced by divine invocation")
        print(f"Spread: {args.spread}\n")
        print(spread_display)
        print("\n" + legend_display)

    return 0


if __name__ == '__main__':
    exit(main())
