#!/usr/bin/env python3
# File: tests/conftest.py
# Purpose: Centralise shared pytest fixtures, hooks, and utilities for Noveler's
#          multi-layer test suite while keeping environment setup deterministic.
# Context: Ensures temporary data scaffolding, logging, and compatibility shims
#          across unit, integration, contract, and E2E tests.
"""統合pytest設定ファイル (pytest 8.x最適化)
全テストカテゴリ（unit/integration/e2e/architecture/contracts）での共通設定
DDD準拠・包括的テスト環境対応
src/noveler/tests とルートtests/の統合版
"""
import os
import secrets
import shutil
import sys
import tempfile
import time
from functools import partial
from pathlib import Path
from textwrap import dedent

import py
from _pytest.tmpdir import TempPathFactory


# Helper functions for external command availability checks
def _has_command(cmd: str) -> bool:
    """Check if external command is available in PATH."""
    return shutil.which(cmd) is not None


def _has_git() -> bool:
    """Check if git command is available."""
    return _has_command("git")


_SYNC_HINT_PREFIXES = ("dropbox", "onedrive", "googledrive", "iclouddrive", "icloud")


def _normalize_sync_token(text: str) -> str:
    """Normalise path components for sync folder detection."""
    return "".join(ch for ch in text.lower() if ch.isalnum())


def _is_probably_synced_folder(path: Path) -> bool:
    """Detect if the project root lives inside a cloud-synced directory."""
    normalized_parts = [_normalize_sync_token(part) for part in path.parts]
    for part in normalized_parts:
        if any(part.startswith(prefix) for prefix in _SYNC_HINT_PREFIXES):
            return True
    return False


def _resolve_test_data_root(project_root: Path) -> Path:
    """Locate the base directory used for pytest-managed test data."""
    override = os.environ.get("NOVELER_TESTDATA_ROOT")
    if override:
        candidate = Path(override).expanduser()
        try:
            return candidate.resolve()
        except Exception:
            return candidate

    default_root = project_root / "temp" / "test_data"

    if os.name == "nt" and _is_probably_synced_folder(project_root):
        base_hint = (
            os.environ.get("NOVELER_LOCAL_TEMP_ROOT")
            or os.environ.get("LOCALAPPDATA")
            or tempfile.gettempdir()
        )
        candidate = Path(base_hint).expanduser() / "Noveler" / "test_data"
        try:
            return candidate.resolve()
        except Exception:
            return candidate

    return default_root


_ORIG_GET_BASETEMP = TempPathFactory.getbasetemp


def _ensure_py_path_exists(path_obj):
    """Ensure the path behind a py.path.local exists before using it."""
    try:
        Path(str(path_obj)).mkdir(parents=True, exist_ok=True)
    except Exception:
        pass
    return path_obj


def _handle_remove_readonly(func, path, exc_info):
    """Retry removal after fixing read-only attributes."""
    try:
        os.chmod(path, 0o700)
    except Exception:
        pass
    try:
        func(path)
    except Exception:
        pass


def _safe_rmtree(target: Path | str, attempts: int = 3, delay: float = 0.2) -> None:
    """Best-effort directory removal resilient to transient Windows locks."""
    path = Path(target)
    if not path.exists():
        return

    remover = partial(shutil.rmtree, onerror=_handle_remove_readonly)
    for attempt in range(1, attempts + 1):
        try:
            remover(path)
            return
        except FileNotFoundError:
            return
        except OSError:
            time.sleep(delay * attempt)

    shutil.rmtree(path, ignore_errors=True)


def _noveler_safe_getbasetemp(factory: TempPathFactory):
    """Wrap pytest's basetemp creation to isolate each xdist worker safely."""

    worker_id = os.environ.get("PYTEST_XDIST_WORKER")
    if worker_id:
        worker_suffix = "".join(ch for ch in worker_id if ch.isalnum() or ch in {"-", "_"})
        worker_suffix = worker_suffix or "worker"
    else:
        worker_suffix = "main"

    preferred_basetemp = TEST_DATA_ROOT / "pytest" / worker_suffix
    preferred_str = os.path.normcase(str(preferred_basetemp))
    given = getattr(factory, "_given_basetemp", None)
    given_str = None
    if given is not None:
        try:
            given_str = os.path.normcase(os.fspath(given))
        except TypeError:
            given_str = os.path.normcase(str(given))
    if given_str != preferred_str:
        factory._given_basetemp = preferred_basetemp
        factory._basetemp = None

    try:
        return _ensure_py_path_exists(_ORIG_GET_BASETEMP(factory))
    except (OSError, PermissionError, AttributeError):
        fallback = preferred_basetemp / f"fallback-{os.getpid()}-{int(time.time()*1000)}"
        fallback.mkdir(parents=True, exist_ok=True)
        try:
            factory._given_basetemp = fallback
            factory._basetemp = None
        except Exception:
            pass
        try:
            return _ensure_py_path_exists(_ORIG_GET_BASETEMP(factory))
        except (OSError, PermissionError, AttributeError):
            # Final fallback: return the fallback path directly
            return py.path.local(str(fallback))


TempPathFactory.getbasetemp = _noveler_safe_getbasetemp  # type: ignore[assignment]
# -----------------------------------------------------------------
# Compatibility shim: support Path.Path("w").open(encoding=...) pattern
# Several legacy tests and utilities rely on this chainable helper.
# This adds a lightweight proxy so that Path objects accept a .Path(...) call
# that returns an object exposing .open(...) with default mode/encoding.
# -----------------------------------------------------------------
try:
    import pathlib as _pathlib
    def _compat_Path(self, mode: str | None = None, encoding: str | None = None, **default_kwargs):
        class _OpenProxy:
            def __init__(self, p: _pathlib.Path, m: str | None, enc: str | None, dkw: dict):
                self._p = p
                self._m = m
                self._enc = enc
                self._dkw = dict(dkw) if dkw else {}
            def open(self, mode: str | None = None, encoding: str | None = None, **kwargs):
                use_mode = mode or self._m or "r"
                use_kwargs = {**self._dkw, **kwargs}
                if encoding is None:
                    encoding = self._enc
                if encoding is not None:
                    use_kwargs["encoding"] = encoding
                return self._p.open(use_mode, **use_kwargs)
        return _OpenProxy(self, mode, encoding, default_kwargs)
    for _cls_name in ("Path", "PosixPath", "WindowsPath"):
        _cls = getattr(_pathlib, _cls_name, None)
        if _cls is not None and not hasattr(_cls, "Path"):
            _cls.Path = _compat_Path
except Exception:
    # Best-effort compatibility; ignore if environment disallows monkeypatching
    pass
import pytest
import yaml

pytest_plugins = ("pytester", "tests._fail_only_stream_plugin")


_DEFAULT_MANUSCRIPT_PATH = Path("40_原稿") / "第001話_スモークテスト.md"
_DEFAULT_MANUSCRIPT_CONTENT = (
    dedent(
        """
        # 第001話 スモークテスト原稿

        朝の柔らかな光が差し込む部屋で、主人公は静かに眼を覚ます。
        旅立ちの気配を感じながら、まだ誰もいない廊下を歩き出す。

        ## 会話
        「いってきます」と主人公が呟くと、家の中に優しい風が流れ込んだ。
        """
    ).strip()
    + "\n"
)


def _seed_default_test_data(root: Path) -> None:
    """Create minimal manuscripts required for smoke-level MCP workflows."""

    target = root / _DEFAULT_MANUSCRIPT_PATH
    target.parent.mkdir(parents=True, exist_ok=True)
    if not target.exists():
        target.write_text(_DEFAULT_MANUSCRIPT_CONTENT, encoding="utf-8")


def _prepare_test_data_root() -> None:
    """Provision the run-scoped test-data root without touching parallel runs."""
    RUNS_ROOT.mkdir(parents=True, exist_ok=True)
    _cleanup_stale_runs(current_run=RUN_ID)
    _remove_legacy_test_data_root()
    TEST_DATA_ROOT.mkdir(parents=True, exist_ok=True)
    _seed_default_test_data(TEST_DATA_ROOT)


def _collect_test_data_leftovers() -> list[Path]:
    """Return non-ignored entries that remain under the test-data root."""
    if not TEST_DATA_ROOT.exists():
        return []
    leftovers: list[Path] = []
    for path in TEST_DATA_ROOT.iterdir():
        if path.name in _TEST_DATA_IGNORE_ENTRIES:
            continue
        if path.is_dir():
            try:
                next(path.iterdir())
            except StopIteration:
                continue
        leftovers.append(path)
    return leftovers


def _assert_test_data_clean() -> None:
    """Fail the session when temporary artefacts leak into the repository root."""
    leftovers = _collect_test_data_leftovers()
    if leftovers:
        sample = ", ".join(path.name for path in leftovers[:5])
        pytest.fail(f"leftover test data detected under {TEST_DATA_ROOT}: {sample}")


def normalise_test_subdir(raw: str) -> str:
    """Remove redundant temp/test_data prefixes while keeping relative order intact."""
    if not raw:
        return raw
    cleaned = raw.replace("\\", "/")
    prefix = "temp/test_data/"
    if cleaned.startswith(prefix):
        cleaned = cleaned[len(prefix):]
    cleaned = cleaned.strip("/\\")
    return cleaned or raw

# -----------------------------------------------------------------
# Soft hint: suggest unified runner when called via raw pytest
# -----------------------------------------------------------------
def pytest_sessionstart(session):
    """Print a one-line hint when tests are invoked bypassing the unified runner.
    This is a soft warning and does not affect test outcomes.
    """
    _prepare_test_data_root()
    is_worker = hasattr(session.config, "workerinput")
    if is_worker:
        return

    import os as _os
    import sys as _sys
    if (_os.getenv("LLM_TEST_RUNNER") or "").strip().lower() in {"1", "true", "yes", "on"}:
        return
    try:
        _sys.stderr.write("[hint] テストは scripts/run_pytest.py または `make test` を使うと環境/出力が統一されます\n")
    except Exception:
        pass
from tests._utils.slow_marker_utils import extract_slow_marker_tokens

# プロジェクトルートをPythonパスに追加
project_root = Path(__file__).parent.parent
src_root = project_root / "src"
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(src_root))
_TEST_DATA_BASE_ROOT = _resolve_test_data_root(project_root)
_RUN_ID_ENV_KEY = "NOVELER_TESTDATA_RUN_ID"


def _resolve_run_id() -> str:
    """Derive a stable run identifier shared across xdist workers."""
    run_id = os.environ.get(_RUN_ID_ENV_KEY)
    if run_id:
        return run_id

    timestamp = time.strftime("%Y%m%d-%H%M%S")
    token = secrets.token_hex(4)
    run_id = f"run-{timestamp}-{os.getpid()}-{token}"
    os.environ[_RUN_ID_ENV_KEY] = run_id
    return run_id


RUN_ID = _resolve_run_id()
RUNS_ROOT = _TEST_DATA_BASE_ROOT / "runs"
TEST_DATA_ROOT = RUNS_ROOT / RUN_ID
LEGACY_TEST_DATA_ROOT = project_root / "temp" / "test_data"
_DEFAULT_RETENTION_HOURS = os.environ.get("NOVELER_TESTDATA_RETENTION_HOURS", "24")
try:
    _RETENTION_HOURS = max(0.0, float(_DEFAULT_RETENTION_HOURS))
except (TypeError, ValueError):
    _RETENTION_HOURS = 24.0
_RETENTION_SECONDS = 0.0 if _RETENTION_HOURS <= 0 else _RETENTION_HOURS * 3600.0
_TEST_DATA_IGNORE_ENTRIES = {".gitignore", ".gitkeep", ".keep"}
_SESSION_EXITSTATUS: int | None = None
_SESSION_FAST_MODE = False
# -----------------------------------------------------------------

def _cleanup_stale_runs(current_run: str | None) -> None:
    """Apply retention policy to historical run directories."""
    if not RUNS_ROOT.exists():
        return
    now = time.time()
    for candidate in RUNS_ROOT.iterdir():
        if not candidate.is_dir():
            continue
        if current_run and candidate.name == current_run:
            continue
        try:
            modified = candidate.stat().st_mtime
        except OSError:
            modified = 0.0
        age = now - modified
        if _RETENTION_SECONDS > 0 and age < _RETENTION_SECONDS:
            continue
        _safe_rmtree(candidate)


def _remove_legacy_test_data_root() -> None:
    """Drop the obsolete project-local temp/test_data root if it remains."""
    if LEGACY_TEST_DATA_ROOT.exists():
        _safe_rmtree(LEGACY_TEST_DATA_ROOT)



@pytest.fixture
def test_data_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
    """Provide an isolated temporary directory for each test.

    Purpose:
        Ensure every test receives a clean filesystem workspace located under pytest's
        managed base temporary directory.

    Args:
        tmp_path_factory: Factory supplied by pytest to create scoped temporary paths.

    Returns:
        Path: Absolute path to the created temporary directory ready for test assets.
    Side Effects:
        Creates the directory; pytest removes it automatically after the test session.
    """
    raw_root = tmp_path_factory.mktemp("test_data", numbered=True)
    return Path(str(raw_root))


@pytest.fixture
def temp_dir_factory(tmp_path_factory: pytest.TempPathFactory):
    """Return a callable that yields pytest-managed temporary directories.

    Args:
        tmp_path_factory: Pytest factory used to create temporary directories scoped to
            the current test session.

    Returns:
        Callable[[str, bool], Path]: Function that mirrors tempfile.mkdtemp semantics
            (prefix, optional numbering) while returning Path objects.
    """
    def _create(prefix: str = "tmp", numbered: bool = True) -> Path:
        root = tmp_path_factory.mktemp(prefix, numbered=numbered)
        return Path(str(root))

    return _create


@pytest.fixture(scope="session", autouse=True)
def _route_tempfile_mkdtemp(tmp_path_factory: pytest.TempPathFactory):
    """Ensure tempfile.mkdtemp uses pytest's temporary directory factory.

    Purpose:
        Keeps all mkdtemp-created directories under pytest's managed base path so they
        are cleaned automatically after the session, even for legacy tests that still
        call tempfile.mkdtemp directly.
    Side Effects:
        Temporarily monkeypatches tempfile.mkdtemp for the duration of the test session.
    """

    import tempfile as _tempfile

    original_mkdtemp = _tempfile.mkdtemp
    managed_root = Path(str(tmp_path_factory.getbasetemp())) / "tempfile_mkdtemp"
    managed_root.mkdir(parents=True, exist_ok=True)

    def _mkdtemp(suffix: str = "", prefix: str = "tmp", dir: str | None = None) -> str:
        if dir is not None:
            return original_mkdtemp(suffix=suffix, prefix=prefix, dir=dir)

        return original_mkdtemp(suffix=suffix, prefix=prefix, dir=str(managed_root))

    _tempfile.mkdtemp = _mkdtemp

    try:
        yield
    finally:
        _tempfile.mkdtemp = original_mkdtemp


# MCP stdout ガードをテスト全体で有効化
# - stdoutはMCP専用、ログはstderrへ（MCP_STDIO_SAFE=1）
# - SDK出力以外のstdout書き込みを検出（MCP_STRICT_STDOUT=1）
# -----------------------------------------------------------------
@pytest.fixture(scope="session", autouse=True)
def enable_mcp_stdout_guard():
    import os as _os
    saved = {k: _os.environ.get(k) for k in ("MCP_STRICT_STDOUT", "MCP_STDIO_SAFE", "PYTHONUNBUFFERED")}
    _os.environ["MCP_STRICT_STDOUT"] = "1"
    # Ensure server-side consoles default to stderr to avoid protocol noise
    _os.environ.setdefault("MCP_STDIO_SAFE", "1")
    _os.environ.setdefault("PYTHONUNBUFFERED", "1")
    try:
        yield
    finally:
        for k, v in saved.items():
            if v is None:
                _os.environ.pop(k, None)
            else:
                _os.environ[k] = v
# -----------------------------------------------------------------
# AsyncIO compatibility for Python 3.11+
# Many legacy calls use asyncio.get_event_loop() in sync context.
# Ensure a default loop is present on MainThread during tests.
# -----------------------------------------------------------------
@pytest.fixture(scope="session", autouse=True)
def ensure_default_event_loop():
    import asyncio as _asyncio
    try:
        _asyncio.get_running_loop()
    except RuntimeError:
        _asyncio.set_event_loop(_asyncio.new_event_loop())


# -----------------------------------------------------------------
# Reset shared path-service cache at function start to avoid cross-test
# contamination between projects/roots (flake prevention)
# Function scope ensures each test gets fresh path service cache + environment
# -----------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_common_path_service_cache():
    """Reset CommonPathService and ConfigManager cache before each test to prevent test isolation issues.

    This fixture performs comprehensive cleanup:
    1. Resets CommonPathService cache
    2. Clears ConfigManager environment variable overrides via clear_test_state()
    3. Ensures clean state for all path and config dependent tests
    """
    # SETUP PHASE: Before test execution
    try:
        from noveler.presentation.shared.shared_utilities import (
            reset_common_path_service as _reset_cps,
        )
        _reset_cps()
    except Exception:
        # Not fatal in environments without presentation layer available
        pass

    # Clear ConfigManager state using new clear_test_state() method
    try:
        from noveler.infrastructure.config.config_manager import get_config_manager
        _cm = get_config_manager()
        
        # Use the new comprehensive clear method if available
        if hasattr(_cm, "clear_test_state"):
            _cm.clear_test_state()
        else:
            # Fallback to manual clearing for backward compatibility
            if hasattr(_cm, "restore_persisted_environment"):
                try:
                    _cm.restore_persisted_environment()
                except Exception:
                    pass
            
            if hasattr(_cm, "_overrides"):
                _cm._overrides = {}
            
            if hasattr(_cm, "_persisted_env_snapshots"):
                _cm._persisted_env_snapshots = {}
                
    except Exception:
        pass

    yield

    # TEARDOWN PHASE: After test execution
    try:
        from noveler.presentation.shared.shared_utilities import (
            reset_common_path_service as _reset_cps,
        )
        _reset_cps()
    except Exception:
        pass

    try:
        from noveler.infrastructure.config.config_manager import get_config_manager
        _cm = get_config_manager()
        
        # Final cleanup: use clear_test_state if available
        if hasattr(_cm, "clear_test_state"):
            _cm.clear_test_state()
        else:
            # Fallback for backward compatibility
            if hasattr(_cm, "restore_persisted_environment"):
                try:
                    _cm.restore_persisted_environment()
                except Exception:
                    pass
            
            if hasattr(_cm, "_overrides"):
                _cm._overrides = {}
            
            if hasattr(_cm, "_persisted_env_snapshots"):
                _cm._persisted_env_snapshots = {}
            
    except Exception:
        pass


def get_test_manuscripts_path(project_root=None):
    """プロジェクト設定に基づくテストデータのmanuscriptsパス取得"""
    try:
        if project_root:
            from pathlib import Path

            from noveler.infrastructure.ai_integration.repositories.yaml_project_config_repository import (
                YamlProjectConfigRepository,
            )
            repository = YamlProjectConfigRepository(Path(project_root))
            path_config = repository.get_path_config(Path(project_root))
            manuscripts_path = path_config.get("manuscripts", "40_原稿")
            return normalise_test_subdir(manuscripts_path)
        # デフォルトまたはフォールバック
        return normalise_test_subdir("40_原稿")
    except Exception:
        # フォールバック
        return normalise_test_subdir("40_原稿")


def get_test_management_path(project_root=None):
    """プロジェクト設定に基づくテストデータのmanagementパス取得"""
    try:
        if project_root:
            from pathlib import Path

            from noveler.infrastructure.ai_integration.repositories.yaml_project_config_repository import (
                YamlProjectConfigRepository,
            )
            repository = YamlProjectConfigRepository(Path(project_root))
            path_config = repository.get_path_config(Path(project_root))
            management_path = path_config.get("management", "50_管理資料")
            return normalise_test_subdir(management_path)
        # デフォルトまたはフォールバック
        return normalise_test_subdir("50_管理資料")
    except Exception:
        # フォールバック
        return normalise_test_subdir("50_管理資料")


def get_cleanup_manager():
    """クリーンアップマネージャー取得（遅延インポート）"""
    try:
        from noveler.infrastructure.shared.test_cleanup_manager import TestCleanupManager
        return TestCleanupManager(project_root)
    except ImportError:
        # フォールバック: 基本クリーンアップ
        class FallbackCleanupManager:
            def __init__(self, project_root=None):
                self.project_root = project_root or Path.cwd()
            def cleanup_test_files(self, test_dir):
                if test_dir.exists():
                    _safe_rmtree(test_dir)
            def cleanup_test_artifacts(self, dry_run=False):
                return {
                    "files_deleted": [],
                    "dirs_deleted": [],
                    "errors": []
                }
        return FallbackCleanupManager(project_root)
# -----------------------------------------------------------------
# Sessionレベルフィクスチャ(重いI/O操作を一度だけ実行)
# -----------------------------------------------------------------
@pytest.fixture(scope="session")
def temp_project_dir(tmp_path_factory: pytest.TempPathFactory):
    """テスト用一時ディレクトリ(sessionスコープ)"""
    temp_path = tmp_path_factory.mktemp("project")
    manuscripts_path = Path(get_test_manuscripts_path())
    management_path = Path(get_test_management_path())
    (temp_path / manuscripts_path).mkdir(parents=True, exist_ok=True)
    (temp_path / management_path).mkdir(parents=True, exist_ok=True)
    (temp_path / management_path / "plots").mkdir(parents=True, exist_ok=True)
    (temp_path / "config").mkdir(exist_ok=True)
    # 追加ディレクトリ（従来のtests/conftest.pyとの互換性）
    (temp_path / "plots").mkdir(exist_ok=True)
    (temp_path / "quality").mkdir(exist_ok=True)
    project_config = {
        "project": {
            "title": "テストプロジェクト",
            "author": "テストユーザー",
            "genre": "ファンタジー",
            "ncode": "N0000XX",
        },
    }
    with (temp_path / "プロジェクト設定.yaml").open("w", encoding="utf-8") as f:
        yaml.dump(project_config, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
    try:
        yield temp_path
    finally:
        _safe_rmtree(temp_path)
@pytest.fixture(scope="session")
def default_project_settings() -> dict[str, object]:
    """デフォルトプロジェクト設定(sessionスコープ)"""
    return {
        "project": {
            "title": "テストプロジェクト",
            "author": "テストユーザー",
            "genre": "ファンタジー",
            "target_audience": "一般",
            "writing_status": "連載中",
            "ncode": "N0000XX",
        },
        "quality": {
            "min_score": 70,
            "target_score": 85,
            "max_warnings": 5,
        },
    }
@pytest.fixture(scope="session")
def test_manuscript_file(temp_project_dir):
    """テスト用原稿ファイル作成(sessionスコープ)"""
    manuscripts_path = get_test_manuscripts_path(temp_project_dir)
    manuscript_dir = temp_project_dir / manuscripts_path
    manuscript_file = manuscript_dir / "第001話_テスト物語.md"
    test_content = """# 第1話 テスト物語
これはテスト用の原稿です。
文章の品質チェックをテストするための内容を含んでいます。
## シーン1
主人公が冒険を始めます。
長い文章でもテストできるように、複数の文を含む段落を用意しています。
## シーン2
展開部分での出来事。
"""
    manuscript_file.write_text(test_content, encoding="utf-8")
    return manuscript_file
@pytest.fixture(scope="session")
def test_config_file(temp_project_dir):
    """テスト用設定ファイル作成(sessionスコープ)"""
    config_dir = temp_project_dir / "config"
    config_file = config_dir / "novel_config.yaml"
    test_config = {
        "system": {
            "project_name": "test_novel",
            "author": "テストユーザー"
        },
        "paths": {
            "manuscripts": get_test_manuscripts_path(),
            "plots": "plots",
            "quality": "quality"
        },
        "defaults": {
            "episode": {
                "target_words": 3000
            }
        }
    }
    config_file.write_text(yaml.dump(test_config, allow_unicode=True), encoding="utf-8")
    return config_file
# -----------------------------------------------------------------
# Packageレベルフィクスチャ(テストパッケージ単位で再利用)
# -----------------------------------------------------------------
@pytest.fixture(scope="package")
def sample_episode_content() -> str:
    """サンプルエピソードコンテンツ(packageスコープ)"""
    return """
# 第001話 テストタイトル
 朝の日差しが部屋に差し込んでくる。私はベッドから身を起こし、大きく伸びをした。
「おはよう、世界」
 今日も新しい一日の始まりだ。窓の外には青い空が広がっている。雲がゆっくりと流れていくのを見ていると、心が落ち着いてくる。
 私はキッチンに向かい、コーヒーを淹れた。香ばしい香りが部屋全体に広がっていく。この習慣を始めてから、毎日が少しだけ特別に感じられるようになった。
 マグカップを手に取り、バルコニーに出る。温かい風が頰を撫でていく。遠くの山々が朝霧に包まれて、幻想的な美しさを醸し出している。
 この平安な朝が、いつまでも続けばいいのに。そんなことを考えながら、私はコーヒーを一口飲んだ。
""".strip()
@pytest.fixture(scope="package")
def sample_yaml_metadata() -> dict[str, object]:
    """サンプルYAMLメタデータ(packageスコープ)"""
    return {
        "episode": {
            "number": 1,
            "title": "テストタイトル",
            "status": "draft",
            "target_words": 3000,
            "created_at": "2025-07-16T10:00:00",
            "tags": ["ファンタジー", "日常"],
        },
        "quality": {
            "last_checked": None,
            "score": None,
            "issues": [],
        },
    }
# -----------------------------------------------------------------
# Functionレベルフィクスチャ(各テストで独立したリソース)
# -----------------------------------------------------------------
@pytest.fixture
def isolated_temp_dir(tmp_path_factory: pytest.TempPathFactory):
    """テスト毎に独立した一時ディレクトリ"""
    temp_path = tmp_path_factory.mktemp("case", numbered=True)
    try:
        yield temp_path
    finally:
        _safe_rmtree(temp_path)
@pytest.fixture
def mock_episode_file(isolated_temp_dir):
    """テスト用エピソードファイル作成"""
    manuscripts_path = get_test_manuscripts_path()
    manuscript_dir = isolated_temp_dir / manuscripts_path
    manuscript_dir.mkdir(parents=True, exist_ok=True)
    episode_file = manuscript_dir / "第001話_テストエピソード.md"
    episode_content = """# 第1話 テストエピソード
テスト用のエピソード内容です。
品質チェック機能をテストするため、様々な文章パターンを含んでいます。
長い文章のテストのために、意図的に複数の要素を含む複雑な構造の文章を作成して、読みやすさや文法の検証ができるようにしています。
短い文。
## 会話シーン
「こんにちは」と彼は言った。
「はい、こんにちは」と彼女は答えた。
"""
    episode_file.write_text(episode_content, encoding="utf-8")
    return episode_file
@pytest.fixture
def cleanup_test_files():
    """テスト後のファイルクリーンアップ"""
    created_files = []
    def track_file(file_path):
        created_files.append(Path(file_path))
    yield track_file
    # テスト後のクリーンアップ
    cleanup_manager = get_cleanup_manager()
    for file_path in created_files:
        try:
            if file_path.is_dir():
                cleanup_manager.cleanup_test_files(file_path)
            elif file_path.exists():
                file_path.unlink()
        except Exception:
            # クリーンアップエラーは無視（テスト結果に影響させない）
            pass
# -----------------------------------------------------------------
# パフォーマンス測定用フィクスチャ
# -----------------------------------------------------------------
@pytest.fixture
def benchmark_episode_content() -> str:
    """ベンチマーク用エピソードコンテンツ"""
    # 3000文字程度のコンテンツを生成
    base_text = "これはベンチマーク用のテストテキストです。"
    return base_text * 100  # 約 3000文字
# -----------------------------------------------------------------
# E2Eテスト専用フィクスチャ
# -----------------------------------------------------------------
@pytest.fixture
def e2e_test_environment(temp_project_dir, test_config_file, test_manuscript_file):
    """E2Eテスト用環境セットアップ"""
    return {
        "project_root": temp_project_dir,
        "config_file": test_config_file,
        "manuscript_file": test_manuscript_file,
        "manuscript_dir": temp_project_dir / get_test_manuscripts_path(temp_project_dir)
    }
# -----------------------------------------------------------------
# Architecture/Integration テスト専用フィクスチャ
# -----------------------------------------------------------------
@pytest.fixture
def mock_dependencies():
    """アーキテクチャテスト用のモック依存関係"""
    from unittest.mock import Mock
    return {
        "logger": Mock(),
        "path_service": Mock(),
        "repository": Mock(),
        "use_case": Mock()
    }
# -----------------------------------------------------------------
# テストマーカー用フック
# -----------------------------------------------------------------
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
    """テストアイテムの動的修正(pytest 8.x最適化) - 高速化版
    - fail-fastモードではパフォーマンステストをスキップ
    - 非同期テストに自動マーカー付与
    - 文字列操作を最適化
    """
    # マーカーを事前作成（キャッシュ効果）
    skip_performance = pytest.mark.skip(reason="Performance tests skipped in fail-fast mode")
    skip_no_git = pytest.mark.skip(reason="git command not available in this environment")
    # maxfailオプションを一度だけチェック
    should_skip_performance = config.getoption("--maxfail") == 1
    git_available = _has_git()
    # 文字列チェック用の高速化
    slow_keywords = {"slow", "heavy", "stress", "benchmark", "performance"}
    for item in items:
        # パフォーマンステストの条件付きスキップ
        if should_skip_performance and "performance" in item.keywords:
            item.add_marker(skip_performance)
        # requires_git マーカーの条件付きスキップ
        if not git_available and "requires_git" in item.keywords:
            item.add_marker(skip_no_git)
        # asyncテストの自動認識
        if "async" in item.name or item.get_closest_marker("asyncio"):
            item.add_marker(pytest.mark.asyncio)
        # ファイルパスベースの自動マーキング（最適化: 一度の文字列変換）
        fspath_str = str(item.fspath)
        if "e2e" in fspath_str:
            item.add_marker(pytest.mark.e2e)
            item.add_marker(pytest.mark.slow)
        elif "integration" in fspath_str:
            item.add_marker(pytest.mark.integration)
        elif "architecture" in fspath_str:
            item.add_marker(pytest.mark.architecture)
        elif "contracts" in fspath_str:
            item.add_marker(pytest.mark.contracts)
        elif "unit" in fspath_str:
            item.add_marker(pytest.mark.unit)
        # 実行時間ベースのマーキング（最適化: set intersection）
        item_words = extract_slow_marker_tokens(item.name)
        if item_words & slow_keywords:
            item.add_marker(pytest.mark.slow)
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
    """テストセッション終了時の自動クリーンアップ
    テスト実行で生成された一時ファイル・サンプルファイルを自動削除。
    """
    global _SESSION_EXITSTATUS, _SESSION_FAST_MODE
    _SESSION_EXITSTATUS = exitstatus
    # Fast cleanup mode for LLM runs: skip heavy post-session cleanup if enabled
    try:
        fast_flag = os.environ.get("LLM_FAST_CLEANUP", "")
        _fast = fast_flag.strip().lower() in {"1", "true", "on", "yes"}
    except Exception:
        _fast = False
    _SESSION_FAST_MODE = _fast

    if exitstatus != 0:
        print()
        print("⚠️ テストに失敗したため、デバッグ用にテストファイルを保持します")
        print("   手動でクリーンアップする場合: python -m noveler.infrastructure.shared.test_cleanup_manager")
        return

    if _fast:
        # Skip heavy cleanup to speed up LLM-facing runs
        return

    try:
        # 自動クリーンアップ実行
        print()
        print("🧹 テスト後クリーンアップを実行中...")
        cleanup_manager = get_cleanup_manager()
        result = cleanup_manager.cleanup_test_artifacts(dry_run=False)
        if result["files_deleted"] or result["dirs_deleted"]:
            print(
                f"✅ クリーンアップ完了: {len(result['files_deleted'])} ファイル, {len(result['dirs_deleted'])} ディレクトリを削除"
            )
        else:
            print("✅ クリーンアップ対象なし")
        if result["errors"]:
            print(f"⚠️ {len(result['errors'])} 件のエラーが発生しました")
    except Exception as e:
        print(f"⚠️ 自動クリーンアップでエラーが発生: {e}")
        print("   手動でクリーンアップしてください: python -m noveler.infrastructure.shared.test_cleanup_manager")
    finally:
        if not _fast:
            _safe_rmtree(TEST_DATA_ROOT)
            try:
                if RUNS_ROOT.exists() and not any(RUNS_ROOT.iterdir()):
                    RUNS_ROOT.rmdir()
            except Exception:
                pass
# -----------------------------------------------------------------
def pytest_unconfigure(config: pytest.Config) -> None:
    """After pytest releases resources, ensure the test-data root is empty."""
    if _SESSION_FAST_MODE:
        return
    if _SESSION_EXITSTATUS is None or _SESSION_EXITSTATUS != 0:
        return
    _assert_test_data_clean()


# -----------------------------------------------------------------
# テスト実行時フック
# -----------------------------------------------------------------
@pytest.fixture(autouse=True)
def setup_test_logging():
    """各テストでのログ設定"""
    import logging

    from noveler.infrastructure.logging.unified_logger import LogFormat, configure_logging

    configure_logging(preset="development", console_format=LogFormat.RICH)
    yield
    # テスト後はロギング設定を初期化
    # ハンドラーをクローズして、ファイルロックを解放
    for handler in logging.root.handlers[:]:
        try:
            handler.close()
            logging.root.removeHandler(handler)
        except Exception:
            pass

    configure_logging(preset="development")


def pytest_configure(config):
    """Phase 3: Enhanced test isolation and ordering for parallel execution support.

    Safety Profile:
    - Domain layer (tests/unit/domain): ✅ Safe for parallel execution (7 consecutive runs, 0 failures)
    - Application layer (tests/unit/application): ✅ Safe for parallel execution (verified)
    - Other layers: Sequential execution (default behavior)

    Phase 3 Enhancements:
    - Introduced pytest-ordering (@pytest.mark.run(order=N)) for test ordering control
    - Added specification file snapshot mechanism to prevent test interference
    - Enhanced logging handler cleanup for parallel execution isolation
    - Improved stdout capture for integration tests
    - Explicit PYTHONPATH setup for mcp_servers module discovery
    """
    # Phase 3: pytest-ordering is now available via pytest-ordering plugin
    # Tests can be ordered using @pytest.mark.run(order=N) decorator

    # Ensure src/ directory is in PYTHONPATH for mcp_servers module discovery
    import sys
    from pathlib import Path
    src_path = str(Path(__file__).parent.parent / "src")
    if src_path not in sys.path:
        sys.path.insert(0, src_path)

    # Pre-import MCP handlers to ensure mcp_servers module is fully resolved
    # before test collection (avoids late-binding issues in collection phase)
    try:
        import noveler.infrastructure.mcp.handlers  # noqa: F401
    except ImportError:
        pass  # Not all environments have mcp_servers installed

    # Register test markers
    markers = [
        "spec(id): 仕様書IDとの紐付け",
        "requirement(id): 要件IDとの紐付け",
        "spec(spec_id): テスト仕様ID",
        "slow: 実行時間の長いテスト",
        "integration: 統合テスト",
        "e2e: E2Eテスト",
        "unit: ユニットテスト",
        "architecture: アーキテクチャテスト",
        "contracts: コントラクトテスト",
        "plot_episode: プロットエピソード領域のテスト",
        "quality_domain: 品質ドメイン関連のテスト",
        "vo_smoke: 値オブジェクトのスモークテスト",
        "requires_git: gitコマンドが必要なテスト（利用不可環境でスキップ）",
        "performance: パフォーマンステスト",
        "async: 非同期テスト",
    ]
    for marker in markers:
        config.addinivalue_line("markers", marker)


@pytest.fixture(scope="session", autouse=True)
def ensure_specification_files_exist():
    """Ensure critical specification files exist for test_specification_existence.

    Before tests run, ensure all critical spec files exist.
    This is a safeguard for tests that check spec file existence.
    """
    from pathlib import Path

    specs_dir = Path("specs")
    if not specs_dir.exists():
        return

    # Define critical specs that must exist
    critical_specs = [
        "SPEC-EPISODE-027_episode_number.md",
        "SPEC-EPISODE-029_episode_title.md",
        "SPEC-ENTITY-009_word_count.md",
        "SPEC-QUALITY-031_quality_score.md",
        "SPEC-EPISODE-022_episode.md",
    ]

    # Before tests: verify critical specs exist (they should from the repo)
    # This is just documentation of expectations
    for spec_file in critical_specs:
        spec_path = specs_dir / spec_file
        if not spec_path.exists():
            # Log but don't fail - tests will handle missing specs
            pass

    return

    # After tests: no cleanup needed (specs are managed by git)


@pytest.fixture(autouse=True)
def cleanup_logging_handlers():
    """Clean up logging handlers after each test to prevent leakage.

    Ensures ロギング context stacks don't leak between tests.
    """
    yield

    # Test ended: clean up all logging handlers
    import copy
    import logging

    # Store current handlers before cleanup
    handlers_to_remove = copy.copy(logging.root.handlers)

    # Remove all handlers
    for handler in handlers_to_remove:
        try:
            handler.close()
            logging.root.removeHandler(handler)
        except Exception:
            pass


@pytest.fixture(autouse=True)
def reset_singleton_instances():
    """Reset singleton instances that may have been modified by tests.

    Prevents state leakage between tests for singleton managers.
    """
    yield

    # After test: reset known singleton instances
    try:
        from noveler.infrastructure.config.config_manager import ConfigManager
        if hasattr(ConfigManager, "_instance"):
            ConfigManager._instance = None
    except Exception:
        pass

    try:
        from noveler.application.services.ten_stage_executor import TenStageExecutor
        if hasattr(TenStageExecutor, "_instance"):
            TenStageExecutor._instance = None
    except Exception:
        pass


@pytest.fixture(autouse=True)
def isolate_stdout_stderr(capsys):
    """Isolate stdout/stderr capture to prevent leakage across tests.
    
    Ensures print() and subprocess outputs don't pollute other tests.
    Uses pytest's capsys fixture to capture and isolate output streams.
    """
    # capsys は自動的にテスト毎に隔離されるため、
    # このフィクスチャでは capsys を使用可能にして、
    # テスト終了後の self-cleanup を保証する
    yield capsys
    
    # テスト終了後: capsys を reset
    capsys.readouterr()


@pytest.fixture(autouse=True)
def isolate_logging_output(caplog):
    """Isolate logging output to prevent cross-test contamination.
    
    Uses pytest's caplog fixture for structured logging capture.
    Ensures log records don't leak between tests.
    """
    # caplog は自動的にテスト毎に隔離されるため、
    # このフィクスチャでは caplog を使用可能にして、
    # テスト終了後のクリーンアップを保証する
    yield caplog
    
    # テスト終了後: caplog を clear
    caplog.clear()



@pytest.fixture(autouse=True)
def cleanup_working_directory():
    """Restore current working directory after each test.

    Ensures tests that change cwd don't affect subsequent tests.
    """
    import os
    original_cwd = os.getcwd()
    yield
    # Restore original directory after test
    if original_cwd != os.getcwd():
        try:
            os.chdir(original_cwd)
        except OSError:
            # If unable to restore (dir deleted), attempt git root
            try:
                import subprocess
                root = subprocess.check_output(
                    ["git", "rev-parse", "--show-toplevel"],
                    stderr=subprocess.DEVNULL,
                    text=True
                ).strip()
                if os.path.exists(root):
                    os.chdir(root)
            except Exception:
                pass


@pytest.fixture(autouse=True)
def cleanup_yaml_cache():
    """Clear YAML prompt repository cache after each test.
    
    Prevents cache pollution between tests that use YamlPromptRepository.
    """
    yield
    
    # Clear any module-level YAML caches
    try:
        from noveler.infrastructure.repositories import ruamel_yaml_prompt_repository
        if hasattr(ruamel_yaml_prompt_repository, "_prompt_cache"):
            ruamel_yaml_prompt_repository._prompt_cache = {}
    except Exception:
        pass
    
    # Clear any singleton YAML repository instances
    try:
        from noveler.infrastructure.repositories import yaml_prompt_repository_singleton
        if hasattr(yaml_prompt_repository_singleton, "_instance"):
            yaml_prompt_repository_singleton._instance = None
    except Exception:
        pass

