"""Single-pass parser for ``.prompt`` documents."""

from __future__ import annotations

from typing import Any

from pydantic import ValidationError

from dotpromptz.errors import (
    FrontmatterMissingError,
    FrontmatterUnterminatedError,
    FrontmatterValidationError,
    PromptVersionError,
)
from dotpromptz.typing import (
    AdapterConfig,
    GenerateConfig,
    ParsedPrompt,
    PromptInputConfig,
    PromptOutputConfig,
    RuntimeConfig,
)
from dotpromptz.version import CURRENT_MAJOR
from dotpromptz.yamlio import YamlError, load_yaml

_INPUT_FIELDS = frozenset({'chain', 'defaults', 'data', 'files'})
_RESERVED_METADATA_KEYWORDS = frozenset(
    {
        'adapter',
        'config',
        'description',
        'input',
        'output',
        'runtime',
        'version',
    }
)


def _scan_frontmatter_and_body(source: str) -> tuple[str, str]:
    """Scan source text and split into frontmatter/body sections.

    Args:
        source: Full ``.prompt`` source text.

    Returns:
        A tuple ``(frontmatter_text, body_text)``.

    Raises:
        FrontmatterMissingError: If opening ``---`` marker is missing.
        FrontmatterUnterminatedError: If closing ``---`` marker is missing.
    """
    lines = source.splitlines(keepends=True)
    idx = 0
    while idx < len(lines) and not lines[idx].strip():
        idx += 1

    if idx >= len(lines) or lines[idx].strip() != '---':
        raise FrontmatterMissingError(
            'Frontmatter is required. The first non-empty line must be "---".',
            code='frontmatter_missing',
            lineno=idx + 1 if lines else 1,
            raw_line=lines[idx].rstrip('\r\n') if idx < len(lines) else None,
        )

    opening_lineno = idx + 1
    idx += 1
    frontmatter_lines: list[str] = []
    while idx < len(lines):
        current = lines[idx]
        if current.strip() == '---':
            body = ''.join(lines[idx + 1 :])
            return ''.join(frontmatter_lines), body
        frontmatter_lines.append(current)
        idx += 1

    raise FrontmatterUnterminatedError(
        'Frontmatter starts with "---" but is missing the closing "---".',
        code='frontmatter_unterminated',
        lineno=opening_lineno,
    )


def extract_frontmatter_and_body(source: str) -> tuple[str, str]:
    """Extract frontmatter/body from source using strict marker scanning."""
    return _scan_frontmatter_and_body(source)


def _parse_config(raw_config: Any) -> dict[str, Any] | None:
    """Parse strict ``config`` object."""
    if raw_config is None:
        return None
    if not isinstance(raw_config, dict):
        raise FrontmatterValidationError(
            'Frontmatter field "config" must be a mapping (object/dict).',
            code='frontmatter_config_type',
        )
    try:
        GenerateConfig(**raw_config)
    except ValidationError as exc:
        raise FrontmatterValidationError(
            f'Invalid config field(s): {exc}',
            code='frontmatter_config_invalid',
        ) from exc
    return raw_config


def _parse_input_config(raw_input: Any) -> PromptInputConfig | None:
    """Parse strict structured ``input`` object."""
    if raw_input is None:
        return None
    if not isinstance(raw_input, dict):
        raise FrontmatterValidationError(
            ('Frontmatter field "input" must be an object with keys ["chain", "defaults", "data", "files"].'),
            code='frontmatter_input_type',
        )

    unknown = sorted(key for key in raw_input if key not in _INPUT_FIELDS)
    if unknown:
        raise FrontmatterValidationError(
            (
                f'Frontmatter field "input" contains unrecognized field(s): {unknown}. '
                f'Valid fields are: {sorted(_INPUT_FIELDS)}'
            ),
            code='frontmatter_input_unknown_field',
        )

    try:
        return PromptInputConfig.model_validate(raw_input)
    except ValidationError as exc:
        raise FrontmatterValidationError(
            f'Invalid input field(s): {exc}',
            code='frontmatter_input_invalid',
        ) from exc


def _parse_output_config(raw_output: Any) -> PromptOutputConfig | None:
    """Parse strict ``output`` object."""
    if raw_output is None:
        return None
    if not isinstance(raw_output, dict):
        raise FrontmatterValidationError(
            'Frontmatter field "output" must be a mapping (object/dict).',
            code='frontmatter_output_type',
        )
    try:
        return PromptOutputConfig.model_validate(raw_output)
    except ValidationError as exc:
        raise FrontmatterValidationError(
            f'Invalid output field(s): {exc}',
            code='frontmatter_output_invalid',
        ) from exc


def _parse_adapter(raw_adapter: Any) -> str | AdapterConfig | None:
    """Parse strict ``adapter`` object/string."""
    if raw_adapter is None or isinstance(raw_adapter, str):
        return raw_adapter
    if not isinstance(raw_adapter, dict):
        raise FrontmatterValidationError(
            'Frontmatter field "adapter" must be a string or mapping (object/dict).',
            code='frontmatter_adapter_type',
        )
    return AdapterConfig.model_validate(raw_adapter)


def _parse_runtime(raw_runtime: Any) -> RuntimeConfig | None:
    """Parse strict ``runtime`` object."""
    if raw_runtime is None:
        return None
    if not isinstance(raw_runtime, dict):
        raise FrontmatterValidationError(
            'Frontmatter field "runtime" must be a mapping (object/dict).',
            code='frontmatter_runtime_type',
        )
    return RuntimeConfig.model_validate(raw_runtime)


def _validate_frontmatter_metadata(data: dict[str, Any]) -> None:
    """Validate top-level frontmatter metadata contract."""
    if any(not isinstance(k, str) for k in data):
        raise FrontmatterValidationError(
            'Frontmatter keys must be strings.',
            code='frontmatter_key_type',
        )

    unrecognized = sorted(key for key in data if key not in _RESERVED_METADATA_KEYWORDS or '.' in key)
    if unrecognized:
        raise FrontmatterValidationError(
            (
                f'Unrecognized frontmatter field(s): {unrecognized}. '
                f'Valid fields are: {sorted(_RESERVED_METADATA_KEYWORDS)}'
            ),
            code='frontmatter_unknown_field',
        )

    if 'version' not in data:
        raise FrontmatterValidationError(
            'Missing required frontmatter field: version',
            code='frontmatter_missing_version',
        )

    raw_version = data['version']
    if not isinstance(raw_version, int):
        raise FrontmatterValidationError(
            f'Frontmatter field "version" must be an integer, got {type(raw_version).__name__}.',
            code='frontmatter_version_type',
        )
    if raw_version != CURRENT_MAJOR:
        raise PromptVersionError(raw_version, CURRENT_MAJOR)


def parse_document(source: str) -> ParsedPrompt:
    """Parse a ``.prompt`` document into structured metadata and template body."""
    frontmatter, body = _scan_frontmatter_and_body(source)
    try:
        parsed_metadata = load_yaml(frontmatter)
    except YamlError as exc:
        raise FrontmatterValidationError(
            f'Invalid frontmatter YAML: {exc}',
            code='frontmatter_yaml_invalid',
        ) from exc

    if parsed_metadata is None:
        parsed_metadata = {}
    if not isinstance(parsed_metadata, dict):
        raise FrontmatterValidationError(
            f'Frontmatter must be a YAML mapping (object/dict), got {type(parsed_metadata).__name__}.',
            code='frontmatter_type_invalid',
        )

    _validate_frontmatter_metadata(parsed_metadata)

    return ParsedPrompt(
        description=parsed_metadata.get('description'),
        version=parsed_metadata['version'],
        adapter=_parse_adapter(parsed_metadata.get('adapter')),
        input=_parse_input_config(parsed_metadata.get('input')),
        output=_parse_output_config(parsed_metadata.get('output')),
        runtime=_parse_runtime(parsed_metadata.get('runtime')),
        config=_parse_config(parsed_metadata.get('config')),
        metadata={},
        template=body.strip(),
    )
