#!/usr/bin/env python3

"""Domain.entities.episode
Where: Domain entity representing an episode.
What: Encapsulates episode metadata, content, and status.
Why: Provides a core aggregate for episode-related operations.
"""

from __future__ import annotations

"""エピソードエンティティ

Unitテスト (tests/unit/domain/plot_episode/entities/test_episode.py) に合わせた
ビジネスルールを実装する。
"""

from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any

from noveler.domain.exceptions import DomainException
from noveler.domain.value_objects.episode_number import EpisodeNumber
from noveler.domain.value_objects.episode_title import EpisodeTitle
from noveler.domain.value_objects.project_time import ProjectTimezone, project_now
from noveler.domain.value_objects.quality_score import QualityScore
from noveler.domain.value_objects.word_count import WordCount

# JSTタイムゾーン (プロジェクト共通)
JST = ProjectTimezone.jst().timezone


class EpisodeStatus(Enum):
    """エピソードステータス"""

    UNWRITTEN = "unwritten"
    DRAFT = "draft"
    IN_PROGRESS = "in_progress"
    COMPLETED = "completed"
    REVIEWED = "reviewed"
    PUBLISHED = "published"
    ARCHIVED = "archived"


@dataclass
class Episode:
    """エピソードエンティティ"""

    number: EpisodeNumber
    title: EpisodeTitle
    content: str
    target_words: WordCount = field(default_factory=lambda: WordCount(2000))
    status: EpisodeStatus = field(default=EpisodeStatus.DRAFT)
    tags: list[str] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)
    version: int = field(default=1)

    word_count: WordCount = field(init=False)
    _quality_score: QualityScore | None = field(default=None, init=False, repr=False)

    created_at: datetime = field(init=False)
    updated_at: datetime = field(init=False)
    completed_at: datetime | None = field(default=None, init=False)
    published_at: datetime | None = field(default=None, init=False)
    archived_at: datetime | None = field(default=None, init=False)

    # 外部責務 (DI用)
    _publisher: Any | None = field(default=None, init=False, repr=False)
    _quality_handler: Any | None = field(default=None, init=False, repr=False)
    _metadata_handler: Any | None = field(default=None, init=False, repr=False)

    _previous_status_before_archive: EpisodeStatus | None = field(default=None, init=False, repr=False)

    def __post_init__(self) -> None:
        """
        Initialize the episode entity after dataclass creation.

        Purpose:
            Normalize field types (EpisodeNumber, EpisodeTitle, WordCount),
            validate business invariants, and set audit timestamps.

        Raises:
            DomainException: If invariants are violated.

        Side Effects:
            Update episode fields with normalized types, set created_at and updated_at.
        """
        # 型補正
        if isinstance(self.number, int):
            object.__setattr__(self, "number", EpisodeNumber(self.number))
        if isinstance(self.title, str):
            object.__setattr__(self, "title", EpisodeTitle(self.title))
        if isinstance(self.target_words, int):
            object.__setattr__(self, "target_words", WordCount(self.target_words))

        if isinstance(self.tags, Iterable) and not isinstance(self.tags, list):
            object.__setattr__(self, "tags", list(self.tags))
        object.__setattr__(self, "tags", [tag for tag in self.tags if isinstance(tag, str)])
        object.__setattr__(self, "metadata", dict(self.metadata))

        self._validate_invariants()

        now = project_now().datetime.astimezone(JST)
        object.__setattr__(self, "created_at", now)
        object.__setattr__(self, "updated_at", now)
        self.word_count = WordCount(len(self.content))

    # ------------------------------------------------------------------
    # 基本操作
    # ------------------------------------------------------------------
    def _validate_invariants(self) -> None:
        """
        Validate episode entity business invariants.

        Purpose:
            Enforce domain rules: positive episode number, non-empty title,
            and positive target word count.

        Raises:
            DomainException: If any invariant is violated.

        Side Effects:
            None (validation only).
        """
        if self.number.value <= 0:
            raise DomainException("エピソード番号は1以上である必要があります", {"episode_number": self.number.value})
        if not self.title.value.strip():
            raise DomainException("タイトルは空にできません", {"title": self.title.value})
        if self.target_words.value <= 0:
            raise DomainException("目標文字数は1以上である必要があります", {"target_words": self.target_words.value})

    def _touch(self) -> None:
        """
        Update the episode's modification timestamp.

        Purpose:
            Record that the episode was modified by setting updated_at to now.

        Side Effects:
            Update updated_at attribute to current JST timestamp.
        """
        object.__setattr__(self, "updated_at", project_now().datetime.astimezone(JST))

    # ------------------------------------------------------------------
    # 執筆フロー
    # ------------------------------------------------------------------
    def start_writing(self) -> None:
        """
        Transition the episode to IN_PROGRESS status.

        Purpose:
            Mark episode as being actively written, preventing modifications
            to already published or archived episodes.

        Raises:
            DomainException: If episode is published or archived.

        Side Effects:
            Set status to IN_PROGRESS and update modification timestamp.
        """
        if self.status in {EpisodeStatus.PUBLISHED, EpisodeStatus.ARCHIVED}:
            raise DomainException("公開済みのエピソードは編集できません", {"status": self.status.value})
        self.status = EpisodeStatus.IN_PROGRESS
        self._touch()

    def update_content(self, content: str) -> None:
        """
        Update the episode content and word count.

        Purpose:
            Replace episode text with new content, enforce non-empty rule,
            and auto-transition from UNWRITTEN to DRAFT status.

        Args:
            content: New episode text (must be non-empty).

        Raises:
            DomainException: If episode is published or content is empty.

        Side Effects:
            Update content, word_count, version, possibly status, and timestamp.
        """
        if self.status == EpisodeStatus.PUBLISHED:
            raise DomainException("公開済みのエピソードは編集できません")
        if not content or not content.strip():
            raise DomainException("内容は空にできません")

        self.content = content
        self.word_count = WordCount(len(content))
        self.version += 1
        if self.status == EpisodeStatus.UNWRITTEN:
            self.status = EpisodeStatus.DRAFT
        self._touch()

    def complete(self) -> None:
        """
        Mark the episode as COMPLETED.

        Purpose:
            Transition episode from draft to completed status, preventing
            completion of published episodes and empty episodes.

        Raises:
            DomainException: If published or content is empty.

        Side Effects:
            Set status to COMPLETED, set completed_at timestamp, update modified_at.
        """
        if self.status == EpisodeStatus.PUBLISHED:
            raise DomainException("公開済みのエピソードは再完成できません")
        if not self.content.strip():
            raise DomainException("内容が空のエピソードは完成できません")

        self.status = EpisodeStatus.COMPLETED
        self.completed_at = project_now().datetime.astimezone(JST)
        self._touch()

    def review(self) -> None:
        """
        Transition the episode to REVIEWED status.

        Purpose:
            Allow review only of completed episodes, providing a
            gate before publication workflow.

        Raises:
            DomainException: If episode is not in COMPLETED status.

        Side Effects:
            Set status to REVIEWED and update modification timestamp.
        """
        if self.status != EpisodeStatus.COMPLETED:
            raise DomainException("完成したエピソードのみレビュー可能です")
        self.status = EpisodeStatus.REVIEWED
        self._touch()

    # ------------------------------------------------------------------
    # 品質・公開管理
    # ------------------------------------------------------------------
    def set_quality(self, handler: Any) -> None:
        """
        Set the quality evaluation handler for this episode.

        Purpose:
            Inject dependency for quality checking operations.

        Args:
            handler: Quality evaluation service instance.

        Side Effects:
            Store handler reference in _quality_handler.
        """
        self._quality_handler = handler

    def set_publisher(self, handler: Any) -> None:
        """
        Set the publication handler for this episode.

        Purpose:
            Inject dependency for publication operations.

        Args:
            handler: Publication service instance.

        Side Effects:
            Store handler reference in _publisher.
        """
        self._publisher = handler

    def set_metadata_handler(self, handler: Any) -> None:
        """
        Set the metadata handler for this episode.

        Purpose:
            Inject dependency for metadata processing.

        Args:
            handler: Metadata service instance.

        Side Effects:
            Store handler reference in _metadata_handler.
        """
        self._metadata_handler = handler

    def set_quality_score(self, score: float | QualityScore) -> None:
        """
        Set or update the episode's quality score.

        Purpose:
            Record quality evaluation result, enforcing range 0-100
            and preventing scores on unwritten episodes.

        Args:
            score: Quality score as float or QualityScore value object.

        Raises:
            DomainException: If unwritten, or score out of range.

        Side Effects:
            Update _quality_score and modification timestamp.
        """
        if self.status == EpisodeStatus.UNWRITTEN:
            raise DomainException("未執筆のエピソードに品質スコアは設定できません")

        if isinstance(score, QualityScore):
            quality = score
        else:
            if not isinstance(score, int | float):
                raise DomainException("品質スコアは数値である必要があります", {"score": score})
            numeric = float(score)
            if not 0 <= numeric <= 100:
                raise DomainException("品質スコアは0から100の範囲である必要があります", {"score": score})
            quality = QualityScore(round(numeric))

        self._quality_score = quality
        self._touch()

    @property
    def quality_score(self) -> QualityScore | None:
        """
        Get the current quality score.

        Purpose:
            Retrieve the quality evaluation result if present.

        Returns:
            QualityScore object or None if not yet evaluated.

        Side Effects:
            None (read-only property).
        """
        return self._quality_score

    @quality_score.setter
    def quality_score(self, score: float | QualityScore) -> None:
        """
        Set quality score via property assignment.

        Purpose:
            Provide alternative syntax for setting quality score.

        Args:
            score: Quality score as float or QualityScore value object.

        Side Effects:
            Delegates to set_quality_score method.
        """
        self.set_quality_score(score)

    def can_publish(self) -> bool:
        """
        Check if the episode meets all publication preconditions.

        Purpose:
            Determine if episode is ready to be published without
            raising exceptions (validation check, not transaction).

        Returns:
            True if episode satisfies all publication requirements.

        Side Effects:
            None (read-only check).
        """
        if self.status not in {EpisodeStatus.COMPLETED, EpisodeStatus.REVIEWED}:
            return False
        if not self.content.strip():
            return False
        if self.word_count.value < 1000:
            return False
        if not self._quality_score or self._quality_score.value < 70:
            return False
        return True

    def publish(self) -> None:
        """
        Publish the episode and record publication timestamp.

        Purpose:
            Execute the publication transition, enforcing status and
            quality preconditions to prevent invalid state changes.

        Raises:
            DomainException: If status is invalid or publication criteria not met.

        Side Effects:
            Set status to PUBLISHED, set published_at timestamp, update modified_at.
        """
        if self.status not in {EpisodeStatus.COMPLETED, EpisodeStatus.REVIEWED}:
            raise DomainException("完成またはレビュー済みのエピソードのみ公開できます")
        if not self.can_publish():
            raise DomainException("公開条件を満たしていません")

        self.status = EpisodeStatus.PUBLISHED
        self.published_at = project_now().datetime.astimezone(JST)
        self._touch()

    # ------------------------------------------------------------------
    # 品質チェック
    # ------------------------------------------------------------------
    def completion_percentage(self) -> float:
        """
        Calculate episode writing progress as a percentage.

        Purpose:
            Compute completion ratio based on target word count,
            capping at 100% and rounding to one decimal place.

        Returns:
            Completion percentage (0.0 to 100.0).

        Side Effects:
            None (read-only computation).
        """
        target = self.target_words.value
        if target <= 0:
            return 0.0
        percentage = (self.word_count.value / target) * 100
        percentage = min(percentage, 100.0)
        return round(percentage, 1)

    def is_ready_for_quality_check(self) -> bool:
        """
        Check if the episode is ready for quality evaluation.

        Purpose:
            Determine if episode meets minimum requirements for quality check
            (not unwritten, has content, meets minimum word count).

        Returns:
            True if episode is eligible for quality assessment.

        Side Effects:
            None (read-only check).
        """
        if self.status == EpisodeStatus.UNWRITTEN:
            return False
        if not self.content.strip():
            return False
        return self.word_count.value >= 1000

    def get_quality_check_issues(self) -> list[str]:
        """
        Identify issues preventing quality check eligibility.

        Purpose:
            Collect all quality check precondition violations to help
            users understand what prevents quality assessment.

        Returns:
            List of issue descriptions (empty if all checks pass).

        Side Effects:
            None (read-only inspection).
        """
        issues: list[str] = []
        if not self.content.strip():
            issues.append("内容が空です")
        if self.word_count.value < 1000:
            issues.append("1000文字未満です")
        if self.status == EpisodeStatus.UNWRITTEN:
            issues.append("未執筆状態です")
        return issues

    # ------------------------------------------------------------------
    # タグ / メタデータ
    # ------------------------------------------------------------------
    def add_tag(self, tag: str) -> None:
        """
        Add a tag to the episode, preventing duplicates.

        Purpose:
            Maintain episode classification/categorization via tags,
            automatically deduplicating and normalizing whitespace.

        Args:
            tag: Tag string to add.

        Raises:
            DomainException: If tag is empty or whitespace-only.

        Side Effects:
            Append normalized tag to tags list and update modification timestamp.
        """
        if not tag or not str(tag).strip():
            raise DomainException("タグは空にできません")
        normalized = str(tag).strip()
        if normalized not in self.tags:
            self.tags.append(normalized)
        self._touch()

    def remove_tag(self, tag: str) -> None:
        """
        Remove a tag from the episode if it exists.

        Purpose:
            Maintain episode tags by removing specific classification
            without raising error if tag is not present.

        Args:
            tag: Tag string to remove.

        Side Effects:
            Remove normalized tag from tags list and update timestamp (if found).
        """
        normalized = str(tag).strip()
        if normalized in self.tags:
            self.tags.remove(normalized)
            self._touch()

    def set_metadata(self, key: str, value: Any) -> None:
        """
        Set or update a metadata key-value pair.

        Purpose:
            Store arbitrary episode-related metadata, enforcing non-empty keys.

        Args:
            key: Metadata key (must be non-empty).
            value: Metadata value (any type).

        Raises:
            DomainException: If key is empty or whitespace-only.

        Side Effects:
            Update metadata dict and modification timestamp.
        """
        if not key or not str(key).strip():
            raise DomainException("メタデータのキーは空にできません")
        self.metadata[str(key).strip()] = value
        self._touch()

    def get_metadata(self, key: str, default: Any | None = None) -> Any | None:
        """
        Retrieve a metadata value by key.

        Purpose:
            Access arbitrary episode metadata with optional default value.

        Args:
            key: Metadata key to look up.
            default: Value to return if key not found.

        Returns:
            Metadata value or default if key not found.

        Side Effects:
            None (read-only access).
        """
        return self.metadata.get(key, default)

    # ------------------------------------------------------------------
    # アーカイブ
    # ------------------------------------------------------------------
    def archive(self) -> None:
        """
        Archive the episode, preserving its current status for restoration.

        Purpose:
            Remove episode from active view while preserving data and
            previous status information for potential restoration.

        Raises:
            DomainException: If episode is already published.

        Side Effects:
            Set status to ARCHIVED, record archived_at timestamp, save previous status.
        """
        if self.status == EpisodeStatus.PUBLISHED:
            raise DomainException("公開済みのエピソードはアーカイブできません")
        if self.status == EpisodeStatus.ARCHIVED:
            return

        self._previous_status_before_archive = self.status
        self.status = EpisodeStatus.ARCHIVED
        self.archived_at = project_now().datetime.astimezone(JST)
        self._touch()

    def restore_from_archive(self) -> None:
        """
        Restore archived episode to its previous active status.

        Purpose:
            Recover archived episodes by restoring their pre-archive status,
            defaulting to DRAFT if no previous status was recorded.

        Raises:
            DomainException: If episode is not currently archived.

        Side Effects:
            Update status to previous value, clear archived_at, update timestamp.
        """
        if self.status != EpisodeStatus.ARCHIVED:
            raise DomainException("アーカイブされていないエピソードは復元できません")

        previous = self._previous_status_before_archive or EpisodeStatus.DRAFT
        self.status = previous
        self.archived_at = None
        self._previous_status_before_archive = None
        self._touch()

    # ------------------------------------------------------------------
    # 統計
    # ------------------------------------------------------------------
    def get_writing_statistics(self) -> dict[str, Any]:
        """
        Calculate comprehensive writing statistics for the episode.

        Purpose:
            Provide detailed metrics including progress, daily rate,
            quality score, and publication readiness for reporting.

        Returns:
            Dict with keys: word_count, target_words, completion_percentage,
            version, writing_days, words_per_day, status, quality_score,
            tags, is_publishable.

        Side Effects:
            None (read-only computation).
        """
        today = project_now().datetime.astimezone(JST)
        days = max(1, (today.date() - self.created_at.date()).days + 1)
        words_per_day = self.word_count.value / days if days else 0

        return {
            "word_count": self.word_count.value,
            "target_words": self.target_words.value,
            "completion_percentage": self.completion_percentage(),
            "version": self.version,
            "writing_days": days,
            "words_per_day": round(words_per_day, 2),
            "status": self.status.value,
            "quality_score": self._quality_score.value if self._quality_score else None,
            "tags": list(self.tags),
            "is_publishable": self.can_publish(),
        }

    # ------------------------------------------------------------------
    # Python Magic Methods
    # ------------------------------------------------------------------
    def __eq__(self, other: object) -> bool:
        """
        Check equality based on episode number.

        Purpose:
            Define equality semantics: two episodes are equal if their
            episode numbers match (business identity).

        Args:
            other: Object to compare.

        Returns:
            True if other is Episode with same number.

        Side Effects:
            None.
        """
        if not isinstance(other, Episode):
            return False
        return self.number.value == other.number.value

    def __hash__(self) -> int:
        """
        Compute hash based on episode number.

        Purpose:
            Enable episode storage in sets and dicts using
            episode number as the hash key.

        Returns:
            Integer hash of the episode number.

        Side Effects:
            None.
        """
        return hash(self.number.value)

    def __str__(self) -> str:
        """
        Convert episode to human-readable string.

        Purpose:
            Provide concise text representation for logging and display.

        Returns:
            String like "Episode(5)".

        Side Effects:
            None.
        """
        return f"Episode({self.number.value})"

    def __repr__(self) -> str:
        """
        Generate developer-friendly representation.

        Purpose:
            Provide detailed string representation for debugging.

        Returns:
            Full episode details as repr string.

        Side Effects:
            None.
        """
        return (
            f"Episode(number={self.number.value}, title='{self.title.value}', status={self.status.value}, word_count={self.word_count.value})"
        )


class EpisodeFactory:
    """
    Factory for creating Episode entities with various initialization patterns.

    Purpose:
        Provide standardized methods for constructing episodes with different
        initial states (new, from template) while maintaining domain invariants.

    Side Effects:
        None (factory is stateless).
    """

    @staticmethod
    def create_new_episode(number: int, title: str, target_words: int = 3000) -> Episode:
        """
        Create a new unwritten episode with default settings.

        Purpose:
            Factory method to instantiate a fresh episode with standard
            initialization, starting in UNWRITTEN status with empty content.

        Args:
            number: Episode sequence number (>0).
            title: Episode title (non-empty).
            target_words: Target writing length (default 3000).

        Returns:
            New Episode entity in UNWRITTEN status.

        Side Effects:
            None (factory method, no side effects).
        """
        return Episode(
            number=EpisodeNumber(number),
            title=EpisodeTitle(title),
            content="",
            target_words=WordCount(target_words),
            status=EpisodeStatus.UNWRITTEN,
        )

    @staticmethod
    def create_from_template(template: dict[str, Any]) -> Episode:
        """
        Create episode from template dictionary.

        Purpose:
            Instantiate episode from external configuration (dict),
            applying default values for optional fields.

        Args:
            template: Dict with keys: number, title, content (optional),
                target_words (optional, default 3000), status (optional),
                tags (optional), metadata (optional).

        Returns:
            New Episode entity initialized from template.

        Side Effects:
            None (factory method, no side effects).
        """
        tags = template.get("tags", [])
        metadata = template.get("metadata", {})
        episode = Episode(
            number=EpisodeNumber(template["number"]),
            title=EpisodeTitle(template["title"]),
            content=template.get("content", ""),
            target_words=WordCount(template.get("target_words", 3000)),
            status=EpisodeStatus(template.get("status", EpisodeStatus.UNWRITTEN.value)),
            tags=list(tags) if tags else [],
            metadata=dict(metadata) if metadata else {},
        )
        return episode
