# File: src/mcp_servers/noveler/tools/check_grammar_tool.py
# Purpose: Provide a FastMCP adapter for Noveler's grammar analysis workflows.
# Context: Invoked by MCP servers to run grammar checks on manuscripts while
#          maintaining compatibility with legacy dict-based request formats.

"""Grammar-check MCP tool adapter for Noveler.

Purpose:
    Wrap the domain-level grammar analysis service in an MCP-compatible tool,
    exposing consistent schemas, validation, and response structures.
Context:
    Loaded by the MCP server to support SPEC-MCP-001 granular services and
    legacy CLI test harnesses that call into this module directly.
Side Effects:
    Reads manuscript files from disk, logs execution details, and may utilise
    caching services for manuscript retrieval.
"""
import hashlib
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any

from mcp_servers.noveler.domain.entities.mcp_tool_base import MCPToolBase, ToolIssue, ToolRequest, ToolResponse
from noveler.domain.services.grammar_analysis_service import GrammarAnalysisService
from noveler.domain.value_objects.grammar_finding import GrammarFinding
from noveler.infrastructure.adapters.path_service_adapter import create_path_service
from noveler.infrastructure.caching.file_cache_service import get_file_cache_service
from noveler.infrastructure.logging.unified_logger import get_logger


class CheckGrammarTool(MCPToolBase):
    """FastMCP tool that detects grammar issues and suggests fixes.

    Purpose:
        Surface the grammar analysis workflow to MCP clients with consistent metadata.
    Side Effects:
        Utilises domain services that read manuscript files and log diagnostics.
    """

    def __init__(self) -> None:
        """Initialise the grammar tool with metadata and analysis backend.

        Purpose:
            Register the tool and prepare the domain analysis service instance.

        Args:
            None.

        Returns:
            None.
        Side Effects:
            Instantiates `GrammarAnalysisService`.
        """
        super().__init__(
            tool_name="check_grammar",
            tool_description="文法・誤字脱字チェック"
        )
        self._analysis_service = GrammarAnalysisService()

    def get_input_schema(self) -> dict[str, Any]:
        """Return the JSON schema describing grammar-check inputs.

        Purpose:
            Allow MCP hosts to validate request payloads before execution.

        Args:
            None.

        Returns:
            dict[str, Any]: Schema including optional file path and check types.
        Side Effects:
            None.
        """
        schema = self._get_common_input_schema()

        # grammar特有のパラメータを追加
        schema["properties"].update({
            "file_path": {
                "type": "string",
                "description": "直接ファイルパス指定（episode_numberより優先）",
            },
            "check_types": {
                "type": "array",
                "items": {
                    "type": "string",
                    "enum": ["typo", "particle_error", "notation_inconsistency", "punctuation"]
                },
                "description": "チェックする文法問題のタイプ（省略時は全て）"
            }
        })

        return schema

    def execute(self, request: ToolRequest | dict[str, Any]) -> ToolResponse | dict[str, Any]:
        """Execute the grammar analysis workflow.

        Purpose:
            Analyse manuscript content and return issues/score metadata.

        Args:
            request: Either a `ToolRequest` or legacy dict payload (backwards compatibility).

        Returns:
            ToolResponse | dict[str, Any]: Structured response or dict (legacy mode).
        Side Effects:
            Reads manuscript files, logs activity, and may raise errors on missing files.

        Raises:
            ValueError: When the requested episode is not found.
        """
        expects_dict = isinstance(request, dict)
        tool_request = self._coerce_request(request)

        # 統一ロギングシステムを使用
        logger = get_logger(__name__)

        start_time = time.time()
        logger.info("文法チェック開始: エピソード%s", tool_request.episode_number)

        try:
            self._validate_request(tool_request)

            # 直接ファイル指定があれば優先 + パス/ハッシュ
            episode_content, file_path, file_hash = self._resolve_episode_content(tool_request)
            if not episode_content:
                # 空のエピソードの場合は満点を返す
                logger.info("エピソード%sは空のため、満点を返却", tool_request.episode_number)
                response = self._create_response(True, 100.0, [], start_time)
                return self._finalize_response(response, expects_dict)

            # チェックタイプの決定
            check_types = self._get_check_types(tool_request)
            logger.debug("チェックタイプ: %s", check_types)

            analysis_result = self._analysis_service.analyze(episode_content, check_types=check_types)
            issues = self._convert_findings(
                analysis_result.issues,
                episode_content.split("\n"),
                file_path,
                file_hash,
            )
            score = analysis_result.score

            logger.info("文法チェック完了: スコア=%.1f, 問題数=%d", score, len(issues))
            resp = self._create_response(True, score, issues, start_time)
            if file_path:
                resp.metadata["file_path"] = str(file_path)
            if file_hash:
                resp.metadata["file_hash"] = file_hash
            # PathServiceのフォールバック可視化
            self._apply_fallback_metadata(resp)
            return self._finalize_response(resp, expects_dict)

        except FileNotFoundError as e:
            error_msg = f"Episode {tool_request.episode_number} not found"
            logger.exception(error_msg)
            raise ValueError(error_msg) from e
        except Exception:
            logger.exception("文法チェックエラー")
            response = self._create_response(False, 0.0, [], start_time)
            return self._finalize_response(response, expects_dict)

    def _load_episode_content(self, episode_number: int, project_name: str | None) -> str:
        """Load episode content via the base implementation (override for clarity).

        Purpose:
            Reuse the base helper while keeping the override anchored for documentation.

        Args:
            episode_number: Episode identifier to load.
            project_name: Optional project name override.

        Returns:
            str: Manuscript content.
        Side Effects:
            Reads manuscript files via infrastructure services.
        """
        return super()._load_episode_content(episode_number, project_name)

    def _get_check_types(self, request: ToolRequest) -> list[str]:
        """Determine which grammar check types to execute.

        Purpose:
            Honour user-supplied check type filters or fall back to all types.

        Args:
            request: ToolRequest containing optional `check_types`.

        Returns:
            list[str]: Grammar check categories to run.
        Side Effects:
            None.
        """
        if request.additional_params and "check_types" in request.additional_params:
            return request.additional_params["check_types"]

        # デフォルトは全てのタイプをチェック
        return ["typo", "particle_error", "notation_inconsistency", "punctuation"]

    def _convert_findings(
        self,
        findings: list[GrammarFinding],
        lines: list[str],
        file_path: Path | None,
        file_hash: str | None,
    ) -> list[ToolIssue]:
        """Transform domain grammar findings into tool issues.

        Purpose:
            Convert domain-level findings into MCP-friendly issue structures.

        Args:
            findings: Grammar findings returned by the analysis service.
            lines: Manuscript lines used to compute per-line hashes.
            file_path: Optional path associated with the manuscript.
            file_hash: Hash of the analysed content.

        Returns:
            list[ToolIssue]: Normalised issues suitable for MCP responses.
        Side Effects:
            Computes line hashes for reproducibility.
        """
        reason_map = {
            "typo": "TYPO",
            "particle_error": "PARTICLE_ERROR",
            "notation_inconsistency": "NOTATION_INCONSISTENCY",
            "punctuation": "GRAMMAR_PUNCTUATION",
        }

        tool_issues: list[ToolIssue] = []
        for finding in findings:
            ln_hash = None
            if finding.line_number and 1 <= finding.line_number <= len(lines):
                line_text = lines[finding.line_number - 1].strip()
                ln_hash = hashlib.sha256(line_text.encode("utf-8")).hexdigest()
            base = f"{file_hash}:{finding.issue_type}:{finding.line_number}:{ln_hash or ''}"
            iid = hashlib.sha256(base.encode("utf-8")).hexdigest()[:12]
            tool_issues.append(
                ToolIssue(
                    type=finding.issue_type,
                    severity=finding.severity,
                    message=finding.message,
                    line_number=finding.line_number,
                    suggestion=finding.suggestion,
                    file_path=str(file_path) if file_path else None,
                    line_hash=ln_hash,
                    reason_code=reason_map.get(finding.issue_type, finding.issue_type.upper()),
                    issue_id=f"grammar-{iid}",
                )
            )
        return tool_issues

    def _resolve_episode_content(self, request: ToolRequest) -> tuple[str, Path | None, str | None]:
        """Resolve content, file path, and hash based on request parameters.

        Purpose:
            Support content overrides, direct file references, and standard episode loading.

        Args:
            request: ToolRequest with optional additional parameters.

        Returns:
            tuple[str, Path | None, str | None]: Content, resolved file path, and content hash.
        Side Effects:
            May read manuscript files from disk and log path-service fallbacks.
        """
        ap = request.additional_params or {}
        file_path: Path | None = None

        if isinstance(ap.get("content"), str):
            content = ap.get("content") or ""
            if isinstance(ap.get("file_path"), str):
                candidate = Path(ap["file_path"]).expanduser()
                file_path = candidate if candidate.exists() else None
        elif isinstance(ap.get("file_path"), str):
            candidate = Path(ap["file_path"]).expanduser()
            file_path = candidate if candidate.exists() else None
            content = candidate.read_text(encoding="utf-8") if candidate.exists() else ""
        else:
            try:
                content = self._load_episode_content(request.episode_number, request.project_name)
            except FileNotFoundError:
                content = ""
            try:
                path_service = create_path_service()
                cache_service = get_file_cache_service()
                manuscript_dir = path_service.get_manuscript_dir()
                file_path = cache_service.get_episode_file_cached(manuscript_dir, request.episode_number)
                self._ps_collect_fallback(path_service)
            except Exception:
                file_path = None

        file_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() if content else None
        return content, file_path, file_hash

    def _coerce_request(self, request: ToolRequest | dict[str, Any]) -> ToolRequest:
        """Coerce legacy dict requests into `ToolRequest` objects.

        Purpose:
            Maintain backwards compatibility with older test harnesses.

        Args:
            request: Either a `ToolRequest` or dict payload.

        Returns:
            ToolRequest: Normalised request instance.
        Side Effects:
            None.

        Raises:
            TypeError: When the incoming request type is unsupported.
        """
        if isinstance(request, ToolRequest):
            return request
        if isinstance(request, dict):
            episode_number = int(request.get("episode_number", 0) or 0)
            project_name = request.get("project_name")
            additional_params = {
                key: value
                for key, value in request.items()
                if key not in {"episode_number", "project_name"}
            }
            return ToolRequest(
                episode_number=episode_number,
                project_name=project_name,
                additional_params=additional_params or None,
            )
        msg = f"Unsupported request type: {type(request)!r}"
        raise TypeError(msg)

    def _finalize_response(self, response: ToolResponse, as_dict: bool) -> ToolResponse | dict[str, Any]:
        """Return either a ToolResponse or legacy dict representation.

        Purpose:
            Preserve compatibility with callers expecting dict responses.

        Args:
            response: ToolResponse produced by the execution workflow.
            as_dict: Whether to convert the response into a dictionary.

        Returns:
            ToolResponse | dict[str, Any]: Original response or legacy dict form.
        Side Effects:
            None.
        """
        if not as_dict:
            return response

        data = asdict(response)
        data.setdefault("issues", [])
        data.setdefault("metadata", {})
        return data
