from __future__ import annotations

import os
from functools import lru_cache
from pathlib import Path
from typing import Any, Literal
from urllib.parse import urlsplit, urlunsplit

from pydantic import AliasChoices, Field, ValidationInfo, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings.sources import PydanticBaseSettingsSource, YamlConfigSettingsSource


def _resolve_project_root(anchor: Path) -> Path:
    for parent in (anchor, *anchor.parents):
        if (parent / "pyproject.toml").exists():
            return parent
    return anchor.parents[3]


PROJECT_ROOT = _resolve_project_root(Path(__file__).resolve())
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
PACKAGE_PROMPT_ROOT = PACKAGE_ROOT / "assets/prompts"
APP_NAME = "outline-agent"
OUTLINE_AGENT_HOME_ENV = "OUTLINE_AGENT_HOME"
OUTLINE_AGENT_CONFIG_PATH_ENV = "OUTLINE_AGENT_CONFIG_PATH"

DEFAULT_CONFIG_TEMPLATE = """# Generated by `outline-agent start` when no config file is present.
# Environment variables and CLI flags can still override any of these values.

server:
  # HTTP bind host for the local webhook service.
  host: 127.0.0.1
  # HTTP bind port for the local webhook service.
  port: 8787

outline:
  # Your Outline API base URL. `/api` is added automatically if you omit it.
  api_base_url: ""
  # API key created from the Outline user account that should act as the agent.
  api_key: ""
  # Webhook signing secret from Outline admin webhook configuration.
  webhook_signing_secret: ""
  # Optional: HTTP timeout for Outline API requests, in seconds.
  # timeout_seconds: 30

trigger:
  # `mention` = only respond when mentioned or replied to; `all` = respond to all comments.
  mode: mention
  # Mention aliases that can trigger the agent in comment text.
  mention_aliases:
    - "@agent"
  # Optional: plain-text fallback mention detection when structured mentions are missing.
  # mention_alias_fallback_enabled: false
  # Optional: reply-to-agent comments trigger the agent even without a fresh mention.
  # on_reply_to_agent: true
  # Optional: restrict the agent to specific Outline collection IDs.
  # collection_allowlist: []

model:
  # Main model ref. `null` means “use model_profiles.default”.
  ref: null
  # Timeout for model API requests, in seconds.
  timeout_seconds: 180
  # Optional: override specific stages with different models.
  # action_router_ref: null
  # memory_ref: null
  # document_update_ref: null
  # tool_ref: null
  # document_memory_ref: null
  # Optional: cap model output size.
  # max_output_tokens: 800

model_profiles:
  # Default `alias/model-name` ref used when `model.ref` is null.
  default: demo/gpt-4.1-mini
  profiles:
    demo:
      # Provider adapter name used by the runtime.
      provider: openai-responses
      # Base URL for your model gateway / API service.
      base_url: https://your-gateway.example.com/openai/v1
      # API key for the model provider or gateway above.
      api_key: ""
      # Allowed model names under this alias. The first one is the default.
      models:
        - gpt-4.1-mini

prompts:
  # Built-in or custom prompt packs to append to the base system prompt.
  system_prompt_packs:
    - outline_style
  # Optional: override the base user system prompt file.
  # Relative paths resolve from your active config root, typically ~/.outline-agent/.
  # system_prompt_path: prompts/user/00_system.md
  # Optional: override where user prompt packs are loaded from.
  # Relative paths resolve from your active config root, typically ~/.outline-agent/.
  # prompt_pack_dir: prompts/user/packs
  # Optional: override where internal maintainer prompts are loaded from.
  # Relative paths resolve from your active config root, typically ~/.outline-agent/.
  # internal_prompt_dir: prompts/internal

features:
  # Enable collection-level memory action planning.
  memory_actions: true
  # Enable writing back durable collection memory after replies.
  memory_updates: true
  # Enable drafting and applying Outline document updates.
  document_updates: true
  # Enable local/file/shell tool use.
  tool_use: true
  # Enable writing back document-local memory after replies.
  document_memory: true
  # Enable reaction emoji updates during processing.
  reactions: true
  # Enable automatic Mermaid validation before document writes when a validator is available.
  mermaid_validation: true
  # Enable retrieval of related documents from the same collection as extra context.
  related_documents: true
  # Enable progress comments such as “Working on it...” while tools are running.
  progress_comments: true
  # Optional: enable lookup of other comment threads in the same document when needed.
  # same_document_comments: true

runtime:
  # If true, plan and simulate side effects without actually mutating Outline state.
  dry_run: false
  # Maximum number of plan/replan rounds for one request.
  tool_execution_max_rounds: 10
  # Maximum number of steps the planner may propose in one round.
  tool_execution_max_steps: 6
  # Number of steps actually executed before replanning.
  tool_execution_chunk_size: 2
  # `auto` = validate Mermaid when possible and skip if unavailable; `required` = fail writes if unavailable.
  mermaid_validation_mode: auto
  # Maximum Mermaid repair retries triggered by validation failures within one request.
  mermaid_validation_max_retries: 3
  # `block` = still block document writes after retry exhaustion; `allow_write` = allow later writes to bypass validation.
  mermaid_validation_exhausted_action: allow_write
  # Mermaid validation timeout per block, in seconds.
  mermaid_validation_timeout_seconds: 20
  # Optional: collection workspace root. Relative paths resolve from this config file's directory.
  # workspace_root: data/agents
  # Optional: raw webhook log directory.
  # webhook_log_dir: data/webhooks
  # Optional: dedupe store for processed webhook events.
  # dedupe_store_path: data/processed_events.json

logging:
  # Application log level.
  level: DEBUG
  # Optional: main service log file path.
  # file_path: logs/outline-agent.log

# Optional advanced tuning fields.
# These are still real config keys, but they map directly to flat AppSettings fields
# instead of the grouped YAML sections above.

# Shell command timeout for the `run_shell` tool, in seconds.
# tool_shell_timeout_seconds: 120

# Tool approval mode. `always_allow` keeps current behavior while preserving
# a future hook for interactive approval.
# tool_approval_mode: always_allow

# Number of recent actions shown in progress comments.
# progress_comment_recent_actions: 6

# Emoji used while the agent is actively processing.
# reaction_processing_emoji: "👀"

# Emoji used when the agent finishes successfully.
# reaction_done_emoji: "👍"

# Related-document retrieval tuning.
# related_document_limit: 3
# related_document_search_limit: 8
# related_document_excerpt_chars: 600
# related_document_query_max_chars: 200
# related_document_min_query_chars: 4
"""


def get_user_config_root() -> Path:
    configured = os.environ.get(OUTLINE_AGENT_HOME_ENV)
    if configured:
        return Path(configured).expanduser()
    return Path.home() / ".outline-agent"


def get_user_config_path() -> Path:
    configured = os.environ.get(OUTLINE_AGENT_CONFIG_PATH_ENV)
    if configured:
        return Path(configured).expanduser()
    return get_user_config_root() / "config.yaml"


def get_config_root() -> Path:
    return get_user_config_path().parent


def get_package_user_prompt_root() -> Path:
    return PACKAGE_PROMPT_ROOT / "user"


def get_package_prompt_path() -> Path:
    return get_package_user_prompt_root() / "00_system.md"


def get_package_prompt_pack_dir() -> Path:
    return get_package_user_prompt_root() / "packs"


def get_package_internal_prompt_dir() -> Path:
    return PACKAGE_PROMPT_ROOT / "internal"


def _first_existing_path(candidates: list[Path], *, fallback: Path) -> Path:
    for candidate in candidates:
        if candidate.exists():
            return candidate
    return fallback


def default_system_prompt_path() -> Path:
    return _first_existing_path(
        [
            get_config_root() / "prompts/user/00_system.md",
        ],
        fallback=get_package_prompt_path(),
    )


def default_prompt_pack_dir() -> Path:
    return _first_existing_path(
        [
            get_config_root() / "prompts/user/packs",
        ],
        fallback=get_package_prompt_pack_dir(),
    )


def default_internal_prompt_dir() -> Path:
    return _first_existing_path(
        [
            get_config_root() / "prompts/internal",
        ],
        fallback=get_package_internal_prompt_dir(),
    )


def default_workspace_root() -> Path:
    return get_config_root() / "data/agents"


def default_webhook_log_dir() -> Path:
    return get_config_root() / "data/webhooks"


def default_dedupe_store_path() -> Path:
    return get_config_root() / "data/processed_events.json"


def default_log_file_path() -> Path:
    return get_config_root() / "logs/outline-agent.log"


_RUNTIME_PATH_FIELDS = {
    "workspace_root",
    "webhook_log_dir",
    "dedupe_store_path",
    "log_file_path",
}

_RESOURCE_PATH_FIELDS = {
    "system_prompt_path",
    "prompt_pack_dir",
    "internal_prompt_dir",
}

_GROUPED_CONFIG_FIELDS = {
    "server": {
        "host": "host",
        "port": "port",
    },
    "outline": {
        "api_base_url": "outline_api_base_url",
        "api_key": "outline_api_key",
        "webhook_signing_secret": "outline_webhook_signing_secret",
        "timeout_seconds": "outline_timeout_seconds",
    },
    "trigger": {
        "collection_allowlist": "collection_allowlist",
        "mode": "trigger_mode",
        "mention_aliases": "mention_aliases",
        "mention_alias_fallback_enabled": "mention_alias_fallback_enabled",
        "on_reply_to_agent": "trigger_on_reply_to_agent",
    },
    "model": {
        "ref": "model_ref",
        "action_router_ref": "action_router_model_ref",
        "memory_ref": "memory_model_ref",
        "document_update_ref": "document_update_model_ref",
        "tool_ref": "tool_model_ref",
        "document_memory_ref": "document_memory_model_ref",
        "timeout_seconds": "model_timeout_seconds",
        "max_output_tokens": "max_output_tokens",
    },
    "prompts": {
        "system_prompt_path": "system_prompt_path",
        "system_prompt": "system_prompt",
        "prompt_pack_dir": "prompt_pack_dir",
        "internal_prompt_dir": "internal_prompt_dir",
        "system_prompt_packs": "system_prompt_packs",
    },
    "features": {
        "memory_actions": "memory_action_enabled",
        "memory_updates": "memory_update_enabled",
        "document_updates": "document_update_enabled",
        "tool_use": "tool_use_enabled",
        "document_memory": "document_memory_update_enabled",
        "reactions": "reaction_enabled",
        "mermaid_validation": "mermaid_validation_enabled",
        "related_documents": "related_documents_enabled",
        "same_document_comments": "same_document_comment_lookup_enabled",
        "progress_comments": "progress_comment_enabled",
    },
    "runtime": {
        "workspace_root": "workspace_root",
        "webhook_log_dir": "webhook_log_dir",
        "dedupe_store_path": "dedupe_store_path",
        "dry_run": "dry_run",
        "tool_execution_max_rounds": "tool_execution_max_rounds",
        "tool_execution_max_steps": "tool_execution_max_steps",
        "tool_execution_chunk_size": "tool_execution_chunk_size",
        "mermaid_validation_mode": "mermaid_validation_mode",
        "mermaid_validation_max_retries": "mermaid_validation_max_retries",
        "mermaid_validation_exhausted_action": "mermaid_validation_exhausted_action",
        "mermaid_validation_timeout_seconds": "mermaid_validation_timeout_seconds",
    },
    "logging": {
        "level": "log_level",
        "file_path": "log_file_path",
    },
}


class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(
        extra="ignore",
        enable_decoding=False,
    )

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> tuple[PydanticBaseSettingsSource, ...]:
        user_yaml = YamlConfigSettingsSource(
            settings_cls,
            yaml_file=get_user_config_path(),
            yaml_file_encoding="utf-8",
        )
        return (
            init_settings,
            env_settings,
            user_yaml,
            file_secret_settings,
        )

    app_name: str = APP_NAME
    host: str = "127.0.0.1"
    port: int = 8787

    outline_api_base_url: str = ""
    outline_api_key: str | None = None
    outline_webhook_signing_secret: str | None = None
    runtime_outline_user_id: str | None = None
    runtime_outline_user_name: str | None = None
    outline_timeout_seconds: float = 30.0

    collection_allowlist: list[str] = Field(
        default_factory=list,
        validation_alias=AliasChoices("ALLOWED_COLLECTION_IDS", "collection_allowlist"),
    )
    trigger_mode: Literal["mention", "all"] = "mention"
    mention_aliases: list[str] = Field(
        default_factory=lambda: ["@agent"],
        validation_alias=AliasChoices("MENTION_ALIASES", "mention_aliases"),
    )
    mention_alias_fallback_enabled: bool = False
    trigger_on_reply_to_agent: bool = True

    model_ref: str | None = None
    action_router_model_ref: str | None = None
    memory_action_enabled: bool = True
    memory_update_enabled: bool = True
    memory_model_ref: str | None = None
    document_update_enabled: bool = True
    document_update_model_ref: str | None = None
    tool_use_enabled: bool = True
    tool_model_ref: str | None = None
    tool_execution_max_steps: int = 6
    tool_execution_chunk_size: int = 2
    tool_execution_max_rounds: int = 10
    tool_file_max_chars: int = 12000
    tool_list_dir_max_entries: int = 40
    tool_shell_timeout_seconds: float = 120.0
    tool_shell_max_output_chars: int = 2000
    tool_approval_mode: Literal["always_allow"] = "always_allow"
    tool_recent_runs: int = 4
    tool_run_summary_max_chars: int = 240
    progress_comment_enabled: bool = True
    progress_comment_recent_actions: int = 6
    max_document_update_chars: int = 12000
    document_update_max_operations: int = 3
    document_update_max_context_sections: int = 4
    document_update_section_max_chars: int = 1800
    document_update_outline_max_sections: int = 40
    model_timeout_seconds: float = 180.0
    max_output_tokens: int = 800
    max_prompt_chars: int = 8000
    max_document_chars: int = 5000
    max_memory_chars: int = 3000
    max_full_thread_chars: int = 12000
    max_document_memory_chars: int = 2000
    max_context_comments: int = 8
    thread_tail_comments: int = 10
    thread_summary_max_chars: int = 2000
    comment_list_limit: int = 25
    thread_recent_comments: int = 8
    thread_comment_max_chars: int = 280
    cross_thread_handoff_candidate_limit: int = 3
    thread_recent_turns: int = 6
    thread_turn_max_chars: int = 280
    memory_update_max_entries: int = 3
    memory_update_entry_max_chars: int = 180
    document_memory_update_enabled: bool = True
    document_memory_model_ref: str | None = None
    document_memory_summary_max_chars: int = 320
    document_memory_item_max_chars: int = 180
    document_memory_max_open_questions: int = 4
    document_memory_max_working_notes: int = 4
    reaction_enabled: bool = True
    mermaid_validation_enabled: bool = True
    mermaid_validation_mode: Literal["off", "auto", "required"] = "auto"
    mermaid_validation_max_retries: int = 3
    mermaid_validation_exhausted_action: Literal["block", "allow_write"] = "allow_write"
    mermaid_validation_timeout_seconds: float = 20.0
    reaction_processing_emoji: str = "👀"
    reaction_done_emoji: str = "👍"
    system_prompt_path: Path = Field(default_factory=default_system_prompt_path)
    system_prompt: str = ""
    prompt_pack_dir: Path = Field(default_factory=default_prompt_pack_dir)
    internal_prompt_dir: Path = Field(default_factory=default_internal_prompt_dir)
    system_prompt_packs: list[str] = Field(
        default_factory=lambda: ["outline_style"],
        validation_alias=AliasChoices("SYSTEM_PROMPT_PACKS", "system_prompt_packs"),
    )

    related_documents_enabled: bool = True
    related_document_limit: int = 3
    related_document_search_limit: int = 8
    related_document_excerpt_chars: int = 600
    related_document_query_max_chars: int = 200
    related_document_min_query_chars: int = 4
    same_document_comment_lookup_enabled: bool = True
    same_document_comment_lookup_fetch_limit: int = 100
    same_document_comment_lookup_thread_limit: int = 3
    same_document_comment_lookup_comment_limit: int = 4
    same_document_comment_lookup_excerpt_chars: int = 240

    workspace_root: Path = Field(default_factory=default_workspace_root)
    webhook_log_dir: Path = Field(default_factory=default_webhook_log_dir)
    dedupe_store_path: Path = Field(default_factory=default_dedupe_store_path)
    log_level: str = "DEBUG"
    log_file_path: Path = Field(default_factory=default_log_file_path)
    dry_run: bool = False

    @model_validator(mode="before")
    @classmethod
    def _flatten_grouped_config(cls, data: Any) -> Any:
        if not isinstance(data, dict):
            return data
        flattened = dict(data)
        for section_name, field_map in _GROUPED_CONFIG_FIELDS.items():
            section = flattened.get(section_name)
            if not isinstance(section, dict):
                continue
            for nested_key, flat_key in field_map.items():
                if flat_key not in flattened and nested_key in section:
                    flattened[flat_key] = section[nested_key]
        return flattened

    @field_validator(
        "system_prompt_path",
        "prompt_pack_dir",
        "internal_prompt_dir",
        "workspace_root",
        "webhook_log_dir",
        "dedupe_store_path",
        "log_file_path",
        mode="before",
    )
    @classmethod
    def _resolve_project_relative_paths(cls, value: str | Path, info: ValidationInfo) -> Path:
        path = Path(value)
        if path.is_absolute():
            return path
        config_root = get_config_root()
        if info.field_name in _RESOURCE_PATH_FIELDS:
            return config_root / path
        if info.field_name in _RUNTIME_PATH_FIELDS:
            return config_root / path
        return PROJECT_ROOT / path

    @field_validator("outline_api_base_url", mode="before")
    @classmethod
    def _normalize_outline_api_base_url(cls, value: str | None) -> str:
        normalized = _normalize_text(value).rstrip("/")
        if not normalized:
            return ""
        parts = urlsplit(normalized)
        path = parts.path.rstrip("/")
        if path in {"", "/"}:
            path = "/api"
        return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment)).rstrip("/")

    @field_validator("collection_allowlist", "mention_aliases", "system_prompt_packs", mode="before")
    @classmethod
    def _normalize_string_or_list_fields(cls, value: object) -> list[str]:
        if value is None:
            return []
        if isinstance(value, str):
            return _split_csv(value)
        if isinstance(value, (list, tuple)):
            return [str(item).strip() for item in value if str(item).strip()]
        raise TypeError(f"Expected string or list value, got {type(value).__name__}")

    @field_validator("system_prompt", mode="before")
    @classmethod
    def _normalize_system_prompt(cls, value: str | None) -> str:
        return _normalize_text(value)

    @field_validator("mention_aliases")
    @classmethod
    def _default_mention_aliases(cls, value: list[str]) -> list[str]:
        return value or ["@agent"]

    @model_validator(mode="after")
    def _load_default_system_prompt(self) -> AppSettings:
        if self.system_prompt:
            return self
        self.system_prompt = _load_required_text_file(self.system_prompt_path)
        return self

    @property
    def allow_all_collections(self) -> bool:
        return not self.collection_allowlist


@lru_cache(maxsize=1)
def get_settings() -> AppSettings:
    settings = AppSettings()
    settings.prompt_pack_dir.mkdir(parents=True, exist_ok=True)
    settings.workspace_root.mkdir(parents=True, exist_ok=True)
    settings.webhook_log_dir.mkdir(parents=True, exist_ok=True)
    settings.dedupe_store_path.parent.mkdir(parents=True, exist_ok=True)
    settings.log_file_path.parent.mkdir(parents=True, exist_ok=True)
    return settings


def clear_settings_cache() -> None:
    get_settings.cache_clear()


def create_default_config(path: Path) -> Path:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(DEFAULT_CONFIG_TEMPLATE, encoding="utf-8")
    return path


def _split_csv(value: str) -> list[str]:
    return [item.strip() for item in value.split(",") if item.strip()]


def _normalize_text(value: str | None) -> str:
    if value is None:
        return ""
    return value.strip()


def _load_required_text_file(path: Path) -> str:
    try:
        text = path.read_text(encoding="utf-8").strip()
    except FileNotFoundError as exc:
        raise ValueError(f"Required text file does not exist: {path}") from exc
    if not text:
        raise ValueError(f"Required text file is empty: {path}")
    return text
