"""Contract-only assembly module for PersonaSpec construction.

This module defines the `assemble_candidate` contract over canonical
in-memory component types from `larva.core.spec`. Implementation follows
the assembly rules documented in INTERFACES.md Section C.

Behavioral notes (contract-only):
- Prompts concatenate in declared order with "\\n\\n" separator
- Scalar conflicts (model, can_spawn, side_effect_policy) require explicit overrides
- Tool conflicts surface deterministically
- Output remains a PersonaSpec candidate inside core boundary
"""

from string import Formatter
from typing import Any, Mapping, cast

from deal import post, pre, raises

from larva.core.spec import (
    ModelComponent,
    PersonaSpec,
    ToolPosture,
)


class AssemblyError(Exception):
    """Base exception for assembly errors."""

    code: str
    message: str
    details: dict[str, Any]


@pre(
    lambda code, message, details=None: (
        isinstance(code, str)
        and len(code) > 0
        and isinstance(message, str)
        and len(message) > 0
        and (details is None or isinstance(details, dict))
    )
)
@post(
    lambda result: (
        isinstance(result, AssemblyError)
        and isinstance(result.code, str)
        and len(result.code) > 0
        and isinstance(result.message, str)
        and len(result.message) > 0
        and isinstance(result.details, dict)
    )
)
def _assembly_error(
    code: str,
    message: str,
    details: dict[str, Any] | None = None,
) -> AssemblyError:
    error = AssemblyError(f"{code}: {message}")
    error.code = code
    error.message = message
    error.details = {} if details is None else details
    return error


@pre(lambda mapping: isinstance(mapping, Mapping))
@post(
    lambda result: (
        isinstance(result, list)
        and all(isinstance(item, tuple) and len(item) == 2 for item in result)
    )
)
def _safe_items(mapping: Mapping[Any, Any]) -> list[tuple[Any, Any]]:
    """Return mapping items without propagating symbolic Mapping key errors.

    >>> _safe_items({"k": "v"})
    [('k', 'v')]
    >>> _safe_items({})
    []
    """
    if not isinstance(mapping, dict):
        return []
    try:
        return list(mapping.items())
    except (KeyError, TypeError, ValueError):
        return []


@pre(lambda prompt: isinstance(prompt, str))
@post(lambda result: isinstance(result, bool))
def _is_format_template(prompt: str) -> bool:
    """Return True when prompt is a valid str.format template."""
    try:
        for _ in Formatter().parse(prompt):
            continue
        return True
    except ValueError:
        return False


@pre(lambda data: isinstance(data, dict))
@post(lambda result: isinstance(result, bool))
def _has_scalar_conflicts(data: dict[str, object]) -> bool:
    """Return True when scalar assembly sources contain conflicting values."""
    constraints_obj = data.get("constraints", [])
    constraints = (
        [item for item in constraints_obj if isinstance(item, dict)]
        if isinstance(constraints_obj, list)
        else []
    )
    model = data.get("model")
    model_sources: list[dict[str, Any]] = []
    if isinstance(model, str):
        model_sources.append({"model": model})
    elif isinstance(model, dict):
        model_sources.append(cast("dict[str, Any]", model))

    sources = constraints + model_sources
    scalar_fields = ("model", "can_spawn", "side_effect_policy", "compaction_prompt")
    for field in scalar_fields:
        values = [source.get(field) for source in sources if field in source]
        values = [value for value in values if value is not None]
        if len(values) > 1:
            first = values[0]
            if any(value != first for value in values):
                return True
    return False


@pre(
    lambda sources, field, allow_multiple=False: (
        isinstance(sources, list)
        and isinstance(field, str)
        and len(field) > 0
        and isinstance(allow_multiple, bool)
    )
)
@raises(AssemblyError)
def _collect_scalar(
    sources: list[dict[str, Any]],
    field: str,
    allow_multiple: bool = False,
) -> Any:
    """Collect a scalar field from multiple sources."""
    values = [source.get(field) for source in sources if field in source]
    values = [value for value in values if value is not None]

    if not values:
        return None

    if not allow_multiple and len(values) > 1:
        first = values[0]
        if any(value != first for value in values):
            raise _assembly_error(
                code="COMPONENT_CONFLICT",
                message=f"Multiple sources provide different values for '{field}': {values}",
                details={"field": field, "values": values},
            )

    return values[0]


@pre(
    lambda toolsets: isinstance(toolsets, list) and all(isinstance(item, dict) for item in toolsets)
)
@raises(AssemblyError)
def _merge_tools(toolsets: list[dict[str, object]]) -> dict[str, str]:
    """Merge tools from multiple toolset components."""
    merged: dict[str, str] = {}

    for toolset in toolsets:
        if "tools" not in toolset:
            continue
        tools_obj = toolset["tools"]
        if not isinstance(tools_obj, dict):
            continue
        tools = cast("Mapping[object, object]", tools_obj)
        for tool_name, posture in _safe_items(tools):
            if not isinstance(tool_name, str) or not isinstance(posture, str):
                continue
            if tool_name in merged and merged[tool_name] != posture:
                raise _assembly_error(
                    code="COMPONENT_CONFLICT",
                    message=(
                        f"Contradictory posture for tool '{tool_name}': "
                        f"'{merged[tool_name]}' vs '{posture}'"
                    ),
                    details={"tool": tool_name, "postures": [merged[tool_name], posture]},
                )
            merged[tool_name] = posture

    return merged


@pre(lambda base, patch: isinstance(base, dict) and isinstance(patch, dict))
def _deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
    """Deep-merge patch dict into base dict (patch values override base)."""
    result = dict(base)
    for key, value in _safe_items(patch):
        if not isinstance(key, str):
            continue
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = _deep_merge(result[key], value)
        else:
            result[key] = value
    return result


@pre(
    lambda prompt, variables: (
        isinstance(prompt, str)
        and _is_format_template(prompt)
        and isinstance(variables, dict)
        and all(
            isinstance(key, str) and isinstance(value, str) for key, value in _safe_items(variables)
        )
    )
)
@raises(AssemblyError)
def _inject_variables(prompt: str, variables: dict[str, str]) -> str:
    """Inject variables into prompt text using str.format_map."""
    try:
        return prompt.format_map(variables)
    except KeyError as error:
        missing = list(error.args)
        raise _assembly_error(
            code="VARIABLE_UNRESOLVED",
            message=f"Missing required variable(s): {missing}",
            details={"missing_variables": missing},
        ) from error


@pre(lambda data: isinstance(data, dict))
@post(lambda result: isinstance(result, list) and all(isinstance(item, str) for item in result))
def _collect_prompt_texts(data: dict[str, object]) -> list[str]:
    prompts_obj = data.get("prompts", [])
    prompts = prompts_obj if isinstance(prompts_obj, list) else []
    variables_obj = data.get("variables", {})
    variables = (
        {
            key: value
            for key, value in _safe_items(cast("Mapping[Any, Any]", variables_obj))
            if isinstance(key, str) and isinstance(value, str)
        }
        if isinstance(variables_obj, dict)
        else {}
    )

    prompt_texts: list[str] = []
    for prompt_component in prompts:
        if not isinstance(prompt_component, dict):
            continue
        text = prompt_component.get("text", "")
        if not isinstance(text, str):
            text = ""
        if variables and "{" in text:
            text = _inject_variables(text, variables)
        prompt_texts.append(text)
    return prompt_texts


@pre(lambda data: isinstance(data, dict))
@post(lambda result: isinstance(result, list) and all(isinstance(item, dict) for item in result))
def _collect_constraint_sources(data: dict[str, object]) -> list[dict[str, Any]]:
    constraints_obj = data.get("constraints", [])
    constraints = constraints_obj if isinstance(constraints_obj, list) else []
    constraint_sources: list[dict[str, Any]] = [
        cast("dict[str, Any]", item) for item in constraints if isinstance(item, dict)
    ]

    model = data.get("model")
    if isinstance(model, str):
        constraint_sources.append({"model": model})
    elif isinstance(model, dict):
        constraint_sources.append(cast("dict[str, Any]", model))
    return constraint_sources


@pre(lambda result, overrides: isinstance(result, dict) and isinstance(overrides, dict))
@post(lambda result: isinstance(result, dict))
def _apply_overrides(result: Mapping[str, object], overrides: dict[str, Any]) -> dict[str, object]:
    updated = dict(result)
    if "model_params" in updated and isinstance(overrides.get("model_params"), dict):
        updated["model_params"] = _deep_merge(
            cast("dict[str, Any]", updated["model_params"]),
            cast("dict[str, Any]", overrides["model_params"]),
        )
        return updated

    for key, value in _safe_items(overrides):
        if not isinstance(key, str):
            continue
        if key != "model_params":
            updated[key] = value  # type: ignore[literal-required]
    return updated


@pre(lambda data: isinstance(data, dict) and "id" in data and not _has_scalar_conflicts(data))
@post(lambda result: isinstance(result, dict) and "id" in result)
@raises(AssemblyError)
def assemble_candidate(data: dict[str, object]) -> PersonaSpec:
    """Assemble a PersonaSpec candidate from component inputs.

    Contract (from INTERFACES.md Section C Assembly Rules):
    - Prompts concatenate in declared order with "\\n\\n" separator
    - Scalars (model, can_spawn, side_effect_policy):
      Multiple sources for same field -> error (COMPONENT_CONFLICT)
    - Tools: Merged only if no contradictory posture values for same tool family
    - model_params: Deep-merged from model component, overrides can patch keys

    Args:
        data: Mapping containing in-memory component values and overrides.

    Returns:
        PersonaSpec candidate (not yet normalized/validated)

    Raises:
        AssemblyError: On component conflicts or unresolved variables.

    Examples:
        >>> result = assemble_candidate(
        ...     {
        ...         "id": "p",
        ...         "prompts": [{"text": "You are {role}"}],
        ...         "variables": {"role": "assistant"},
        ...     }
        ... )
        >>> result["id"]
        'p'
        >>> result["prompt"]
        'You are assistant'
    """
    persona_id = cast("str", data.get("id"))
    result: PersonaSpec = {"id": persona_id}

    prompt_texts = _collect_prompt_texts(data)

    if prompt_texts:
        result["prompt"] = "\n\n".join(prompt_texts)

    model = data.get("model")
    constraint_sources = _collect_constraint_sources(data)
    scalar_fields = ["model", "can_spawn", "side_effect_policy", "compaction_prompt"]
    for field in scalar_fields:
        value = _collect_scalar(constraint_sources, field)
        if value is not None:
            result[field] = value  # type: ignore[literal-required]

    toolsets_obj = data.get("toolsets", [])
    toolsets = (
        [cast("dict[str, object]", item) for item in toolsets_obj if isinstance(item, dict)]
        if isinstance(toolsets_obj, list)
        else []
    )
    if toolsets:
        result["tools"] = cast("dict[str, ToolPosture]", _merge_tools(toolsets))

    model_component: ModelComponent | None = None
    if isinstance(model, dict):
        model_component = cast("ModelComponent", model)
    elif model is None:
        model_component = {}

    if model_component and "model_params" in model_component:
        result["model_params"] = dict(model_component["model_params"])

    overrides_obj = data.get("overrides", {})
    overrides = overrides_obj if isinstance(overrides_obj, dict) else {}
    if overrides:
        result = cast("PersonaSpec", _apply_overrides(result, cast("dict[str, Any]", overrides)))

    return result
