# File: src/noveler/infrastructure/adapters/file_outbox_repository.py
# Purpose: Persist integration events to the filesystem with strict fallback controls.
# Context: Used by infrastructure messaging components when no external broker is available.

"""Filesystem-backed outbox repository with strict mode validation and logging."""

from __future__ import annotations

import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from noveler.application.outbox import OutboxEntry, OutboxRepository
from noveler.domain.exceptions import RepositoryDataError, RepositoryFallbackError
from noveler.infrastructure.config.strict_mode_config import StrictModeConfig


class FileOutboxRepository(OutboxRepository):
    """Persist outbox entries as JSON files under a configured base directory.

    Args:
        base_dir: Directory where outbox files should reside.
        strict_config: Strict mode configuration that determines fallback behaviour.

    Raises:
        RepositoryFallbackError: Raised when repository strict mode forbids using
            the default directory.
        RepositoryDataError: Raised when strict mode forbids continuing with
            malformed persisted data.
    """

    _REPOSITORY_NAME = "FileOutboxRepository"
    _REQUIRED_FIELDS = ("id", "name", "payload", "created_at", "status")

    def __init__(self, base_dir: Path | None = None, strict_config: StrictModeConfig | None = None) -> None:
        self._logger = logging.getLogger(__name__)
        self._strict_config = strict_config or StrictModeConfig.from_env()
        self.base_dir = self._resolve_base_dir(base_dir)
        self.base_dir.mkdir(parents=True, exist_ok=True)
        # DLQ用ディレクトリも作成
        self.dlq_dir = self.base_dir / "dlq"
        self.dlq_dir.mkdir(parents=True, exist_ok=True)

    def _resolve_base_dir(self, base_dir: Path | None) -> Path:
        """Resolve the base directory, applying strict-mode fallback logic."""
        if base_dir is not None:
            return base_dir

        if self._strict_config.is_repository_strict():
            raise RepositoryFallbackError(
                repository=self._REPOSITORY_NAME,
                attempted_fallback="default_directory",
                message="Base directory must be provided when repository strict mode is ERROR",
            )

        if self._strict_config.should_warn_on_repository_fallback():
            self._logger.warning(
                "[repository] Falling back to default outbox directory because no base_dir was provided"
            )

        return Path.cwd() / "temp" / "bus_outbox"

    def _path(self, entry_id: str) -> Path:
        return self.base_dir / f"{entry_id}.json"

    def _dlq_path(self, entry_id: str) -> Path:
        return self.dlq_dir / f"{entry_id}.json"

    def add(self, entry: OutboxEntry) -> None:
        """Persist a new outbox entry, ensuring the storage key is unique."""
        base_key = entry.id
        storage_key = base_key
        p = self._path(storage_key)
        counter = 1
        while p.exists():
            storage_key = f"{base_key}__{counter}"
            p = self._path(storage_key)
            counter += 1

        payload: dict[str, Any] = {
            "id": entry.id,
            "name": entry.name,
            "payload": entry.payload,
            "created_at": entry.created_at.astimezone(timezone.utc).isoformat(),
            "attempts": entry.attempts,
            "dispatched_at": entry.dispatched_at.isoformat() if entry.dispatched_at else None,
            "last_error": entry.last_error,
            "failed_at": entry.failed_at.isoformat() if entry.failed_at else None,
            "status": "pending",
            "storage_key": storage_key,
        }
        p.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")

    def load_pending(self, limit: int = 100) -> list[OutboxEntry]:
        """Load pending outbox entries, applying strict validation rules."""
        entries: list[OutboxEntry] = []
        for fp in sorted(self.base_dir.glob("*.json")):
            try:
                data = json.loads(fp.read_text(encoding="utf-8"))
                if not self._validate_required_fields(data, fp):
                    continue
                if data.get("status") != "pending":
                    continue
                attempts = self._coerce_attempts(data, fp)
                storage_key = self._resolve_storage_key(data, fp)
                entry = OutboxEntry(
                    id=data["id"],
                    name=data["name"],
                    payload=data.get("payload", {}),
                    created_at=datetime.fromisoformat(data["created_at"]),
                    attempts=attempts,
                    dispatched_at=datetime.fromisoformat(data["dispatched_at"]) if data.get("dispatched_at") else None,
                    last_error=data.get("last_error"),
                    failed_at=datetime.fromisoformat(data["failed_at"]) if data.get("failed_at") else None,
                    storage_key=storage_key,
                )
                entries.append(entry)
                if len(entries) >= limit:
                    break
            except RepositoryDataError:
                raise
            except Exception as exc:  # pragma: no cover - defensive rescue
                if self._strict_config.should_warn_on_repository_fallback():
                    self._logger.warning(
                        "[repository] Failed to load pending entry from '%s': %s", fp.name, exc
                    )
                continue
        return entries

    def _validate_required_fields(self, data: dict[str, Any], fp: Path) -> bool:
        """Validate required persisted fields, applying strict-mode handling."""
        missing = [field for field in self._REQUIRED_FIELDS if field not in data]
        if not missing:
            return True

        message = f"Missing required fields {missing} in file '{fp.name}'"
        if self._strict_config.is_repository_strict():
            raise RepositoryDataError(self._REPOSITORY_NAME, message)

        if self._strict_config.should_warn_on_repository_fallback():
            self._logger.warning("[repository] %s", message)

        return False

    def _coerce_attempts(self, data: dict[str, Any], fp: Path) -> int:
        """Ensure attempts has a safe integer value, warning when falling back."""
        attempts_raw = data.get("attempts")
        if attempts_raw is None:
            if self._strict_config.should_warn_on_repository_fallback():
                self._logger.warning(
                    "[repository] Missing 'attempts' in file '%s'; defaulting to 0", fp.name
                )
            return 0
        try:
            return int(attempts_raw)
        except (TypeError, ValueError):
            if self._strict_config.is_repository_strict():
                raise RepositoryDataError(
                    self._REPOSITORY_NAME, f"Invalid attempts value '{attempts_raw}' in file '{fp.name}'"
                ) from None
            if self._strict_config.should_warn_on_repository_fallback():
                self._logger.warning(
                    "[repository] Invalid 'attempts' value '%s' in file '%s'; defaulting to 0",
                    attempts_raw,
                    fp.name,
                )
            return 0

    def _resolve_storage_key(self, data: dict[str, Any], fp: Path) -> str:
        """Derive a storage key, warning when falling back to the filename stem."""
        storage_key = data.get("storage_key")
        if storage_key:
            return storage_key

        fallback_key = fp.stem
        if self._strict_config.should_warn_on_repository_fallback():
            self._logger.warning(
                "[repository] Missing 'storage_key' in file '%s'; using '%s' as fallback",
                fp.name,
                fallback_key,
            )
        return fallback_key

    def mark_dispatched(self, entry_id: str) -> None:
        """Mark an entry as dispatched when its backing file exists."""
        p = self._path(entry_id)
        if not p.exists():
            return
        try:
            data = json.loads(p.read_text(encoding="utf-8"))
            data["status"] = "dispatched"
            data["dispatched_at"] = datetime.now(timezone.utc).isoformat()
            p.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
        except Exception:
            return

    def increment_attempts(self, entry_id: str, error_message: str) -> None:
        """Record a failed dispatch attempt and persist the associated error."""
        p = self._path(entry_id)
        if not p.exists():
            return
        try:
            data = json.loads(p.read_text(encoding="utf-8"))
            data["attempts"] = int(data.get("attempts", 0)) + 1
            data["last_error"] = error_message
            data["failed_at"] = datetime.now(timezone.utc).isoformat()
            p.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
        except Exception:
            return

    def move_to_dlq(self, entry_id: str) -> None:
        """Move a failed entry into the dead-letter queue directory."""
        p = self._path(entry_id)
        dlq_p = self._dlq_path(entry_id)
        if not p.exists():
            return
        try:
            data = json.loads(p.read_text(encoding="utf-8"))
            data["status"] = "dlq"
            data["failed_at"] = datetime.now(timezone.utc).isoformat()
            # DLQディレクトリに移動
            dlq_p.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
            # 元ファイルを削除
            p.unlink()
        except Exception:
            return

    def load_dlq_entries(self, limit: int = 100) -> list[OutboxEntry]:
        """Return dead-letter queue entries up to the requested limit."""
        entries: list[OutboxEntry] = []
        for fp in sorted(self.dlq_dir.glob("*.json")):
            try:
                data = json.loads(fp.read_text(encoding="utf-8"))
                entry = OutboxEntry(
                    id=data["id"],
                    name=data["name"],
                    payload=data.get("payload", {}),
                    created_at=datetime.fromisoformat(data["created_at"]),
                    attempts=int(data.get("attempts", 0)),
                    dispatched_at=datetime.fromisoformat(data["dispatched_at"]) if data.get("dispatched_at") else None,
                    last_error=data.get("last_error"),
                    failed_at=datetime.fromisoformat(data["failed_at"]) if data.get("failed_at") else None,
                    storage_key=data.get("storage_key", fp.stem),
                )
                entries.append(entry)
                if len(entries) >= limit:
                    break
            except Exception:
                continue
        return entries
