"""MCP tool for OCTAVE eject (P2.3, Issue #171 GBNF).

Implements octave_eject tool with projection modes:
- canonical: Full document, lossy=false
- authoring: Lenient format, lossy=false
- executive: STATUS,RISKS,DECISIONS only, lossy=true
- developer: TESTS,CI,DEPS only, lossy=true

Output formats:
- octave: OCTAVE syntax
- json: JSON representation
- yaml: YAML representation
- markdown: Markdown format
- gbnf: llama.cpp GBNF grammar (Issue #171)
"""

import json
from typing import Any

import yaml

from octave_mcp.core.emitter import emit
from octave_mcp.core.gbnf_compiler import GBNFCompiler, compile_gbnf_from_meta
from octave_mcp.core.grammar.cst import Assignment, Block, Document, InlineMap, ListValue, LiteralZoneValue, Section
from octave_mcp.core.literal_zone_audit import build_literal_zone_repair_log
from octave_mcp.core.parser import parse
from octave_mcp.core.projector import project
from octave_mcp.core.schema_extractor import extract_schema_from_document
from octave_mcp.core.validator import _count_literal_zones
from octave_mcp.mcp.base_tool import BaseTool, SchemaBuilder


def _ast_to_dict(doc: Document) -> dict[str, Any]:
    """Convert AST Document to dictionary for JSON/YAML export.

    Args:
        doc: Document AST

    Returns:
        Dictionary representation of document
    """
    result: dict[str, Any] = {}

    # Add META if present, converting any AST types (e.g., ListValue) to native Python
    if doc.meta:
        result["META"] = {k: _convert_value(v) for k, v in doc.meta.items()}

    # Convert sections (Issue #341: handle Section, Assignment, and Block)
    for section in doc.sections:
        if isinstance(section, Section):
            result[_section_header_key(section)] = _convert_section(section)
        elif isinstance(section, Assignment):
            result[section.key] = _convert_value(section.value)
        elif isinstance(section, Block):
            result[section.key] = _convert_block(section)

    return result


def _convert_value(value: Any) -> Any:
    """Convert AST value to native Python type.

    Args:
        value: AST value node

    Returns:
        Native Python value
    """
    if isinstance(value, LiteralZoneValue):
        # Issue #235 T15: Export literal zones as structured dict (match point 5).
        # I1: content verbatim — no transformation. I3: no Python repr leak.
        return {
            "__literal_zone__": True,
            "content": value.content,
            "info_tag": value.info_tag,
            "fence_marker": value.fence_marker,
        }
    elif isinstance(value, ListValue):
        return [_convert_value(item) for item in value.items]
    elif isinstance(value, InlineMap):
        return {k: _convert_value(v) for k, v in value.pairs.items()}
    else:
        return value


def _convert_block(block: Block) -> dict[str, Any]:
    """Convert Block AST node to dictionary.

    Args:
        block: Block node

    Returns:
        Dictionary representation
    """
    result: dict[str, Any] = {}

    for child in block.children:
        if isinstance(child, Assignment):
            result[child.key] = _convert_value(child.value)
        elif isinstance(child, Block):
            result[child.key] = _convert_block(child)

    return result


def _convert_section(section: Section) -> dict[str, Any]:
    """Convert Section AST node to dictionary (Issue #341).

    Section children are a mix of Assignment and Block nodes,
    identical to Block children. Reuses the same conversion logic.

    Args:
        section: Section node

    Returns:
        Dictionary representation of section contents
    """
    result: dict[str, Any] = {}

    for child in section.children:
        if isinstance(child, Assignment):
            result[child.key] = _convert_value(child.value)
        elif isinstance(child, Block):
            result[child.key] = _convert_block(child)
        elif isinstance(child, Section):
            # Nested sections (rare but possible)
            section_key = f"\u00a7{child.section_id}::{child.key}"
            result[section_key] = _convert_section(child)

    return result


def _section_header_key(section: Section) -> str:
    """Build the dictionary key for a Section node.

    Uses the format '§N::NAME' to mirror OCTAVE source syntax
    and preserve section identification in JSON output.

    Args:
        section: Section node

    Returns:
        Key string like '§3::CAPABILITIES'
    """
    return f"\u00a7{section.section_id}::{section.key}"


def _parse_section_id(identifier: str) -> str:
    """Parse a flexible section identifier to a bare section_id string (Issue #341).

    Accepts multiple formats:
    - '§3' -> '3'
    - '3' -> '3'
    - '§3::CAPABILITIES' -> '3'
    - '§3.5' -> '3.5'
    - '3.5' -> '3.5'

    Args:
        identifier: Section identifier string in any supported format

    Returns:
        Bare section_id string (e.g., '3', '3.5', '2b')
    """
    # Strip whitespace
    identifier = identifier.strip()

    # Remove leading § if present
    if identifier.startswith("\u00a7"):
        identifier = identifier[1:]

    # Remove ::NAME suffix if present (e.g., '3::CAPABILITIES' -> '3')
    if "::" in identifier:
        identifier = identifier.split("::")[0]

    return identifier


def _filter_document_by_sections(doc: Document, section_ids: list[str]) -> tuple[Document, list[str]]:
    """Filter document to include only specified sections (Issue #341).

    When section filtering is applied:
    - Only Section nodes matching the requested IDs are kept
    - Non-Section nodes (Assignment, Block) are excluded
    - META is preserved separately (always included in output)
    - Non-existent sections are silently omitted (I3: reflect only present)

    Args:
        doc: Document AST (after projection)
        section_ids: List of raw section identifier strings (already parsed)

    Returns:
        Tuple of (filtered Document, list of omitted section header strings)
    """
    from dataclasses import replace

    requested_ids = set(section_ids)
    kept_sections: list = []
    omitted: list[str] = []

    for node in doc.sections:
        if isinstance(node, Section):
            if node.section_id in requested_ids:
                kept_sections.append(node)
            else:
                omitted.append(_section_header_key(node))
        else:
            # Non-Section nodes (Assignment, Block) are excluded
            # when section filtering is active
            if hasattr(node, "key"):
                omitted.append(node.key)

    filtered_doc = replace(doc, sections=kept_sections)
    return filtered_doc, omitted


def _format_markdown_value(value: Any) -> str:
    """Format an AST value for markdown output.

    I3 (Mirror Constraint): Reflect only present data, create nothing.
    Python internals (like ListValue.__repr__) must not leak into output.

    Args:
        value: AST value node

    Returns:
        Human-readable string representation for markdown
    """
    if isinstance(value, LiteralZoneValue):
        # Issue #235 T15: Emit literal zone as markdown fenced code block (match point 6).
        # I1: content verbatim. I3: no Python repr leak.
        tag = value.info_tag or ""
        content = value.content
        if content and not content.endswith("\n"):
            content = content + "\n"
        return f"{value.fence_marker}{tag}\n{content}{value.fence_marker}"
    elif isinstance(value, ListValue):
        # Format list items, recursively formatting nested values
        items = [_format_markdown_value(item) for item in value.items]
        return ", ".join(str(item) for item in items)
    elif isinstance(value, InlineMap):
        # Format inline map as key: value pairs
        pairs = [f"{k}: {_format_markdown_value(v)}" for k, v in value.pairs.items()]
        return ", ".join(pairs)
    else:
        # Regular values are stringified directly
        return str(value)


def _ast_to_markdown(doc: Document) -> str:
    """Convert AST Document to Markdown format.

    Args:
        doc: Document AST

    Returns:
        Markdown representation
    """
    lines: list[str] = []

    # Add title
    lines.append(f"# {doc.name}")
    lines.append("")

    # Add META section
    if doc.meta:
        lines.append("## META")
        lines.append("")
        for key, value in doc.meta.items():
            # I3: Format values to avoid exposing Python internals
            lines.append(f"- **{key}**: {_format_markdown_value(value)}")
        lines.append("")

    # Add sections
    for section in doc.sections:
        if isinstance(section, Assignment):
            # I3: Format values to avoid exposing Python internals
            lines.append(f"**{section.key}**: {_format_markdown_value(section.value)}")
            lines.append("")
        elif isinstance(section, Block):
            lines.append(f"## {section.key}")
            lines.append("")
            _block_to_markdown(section, lines, level=3)

    return "\n".join(lines)


def _block_to_markdown(block: Block, lines: list[str], level: int = 3) -> None:
    """Convert Block to Markdown recursively.

    Args:
        block: Block node
        lines: Output lines list (mutated)
        level: Heading level
    """
    for child in block.children:
        if isinstance(child, Assignment):
            # I3: Format values to avoid exposing Python internals
            lines.append(f"- **{child.key}**: {_format_markdown_value(child.value)}")
        elif isinstance(child, Block):
            lines.append(f"{'#' * level} {child.key}")
            lines.append("")
            _block_to_markdown(child, lines, level + 1)


class EjectTool(BaseTool):
    """MCP tool for octave_eject - projection and formatting."""

    def get_name(self) -> str:
        """Get tool name."""
        return "octave_eject"

    def get_description(self) -> str:
        """Get tool description."""
        return (
            "Eject OCTAVE content with projection modes. "
            "Supports canonical, authoring, executive, and developer views. "
            "Can generate templates when content is null. "
            "Output formats: octave, json, yaml, markdown, gbnf."
        )

    def get_input_schema(self) -> dict[str, Any]:
        """Get input schema."""
        schema = SchemaBuilder()

        schema.add_parameter(
            "content", "string", required=False, description="OCTAVE content to eject (null for template generation)"
        )

        schema.add_parameter(
            "schema", "string", required=True, description="Schema name for validation or template generation"
        )

        schema.add_parameter(
            "mode",
            "string",
            required=False,
            description="Projection mode: canonical (full), authoring (lenient), executive (STATUS,RISKS,DECISIONS), developer (TESTS,CI,DEPS)",
            enum=["canonical", "authoring", "executive", "developer"],
        )

        schema.add_parameter(
            "format",
            "string",
            required=False,
            description="Output format (gbnf exports llama.cpp GBNF grammar)",
            enum=["octave", "json", "yaml", "markdown", "gbnf"],
        )

        # Build base schema then add sections array parameter manually
        # (SchemaBuilder doesn't support array types natively)
        built = schema.build()
        built["properties"]["sections"] = {
            "type": "array",
            "items": {"type": "string"},
            "description": (
                "List of section identifiers to extract (Issue #341). "
                "When provided, only matching sections + META are included in output. "
                "Accepts flexible formats: '\u00a73', '3', '\u00a73::CAPABILITIES' all match section 3. "
                "Non-existent sections are silently omitted."
            ),
        }

        return built

    async def execute(self, **kwargs: Any) -> dict[str, Any]:
        """Execute eject projection.

        Args:
            content: OCTAVE content to eject (None for template)
            schema: Schema name for validation/template
            mode: Projection mode (canonical, authoring, executive, developer)
            format: Output format (octave, json, yaml, markdown, gbnf)
            sections: List of section identifiers to extract (Issue #341)

        Returns:
            Dictionary with:
            - output: Formatted content
            - lossy: Boolean (true if mode discards fields)
            - fields_omitted: List of dropped fields if lossy
        """
        # Validate and extract parameters
        params = self.validate_parameters(kwargs)
        content = params.get("content", None)
        schema_name = params["schema"]
        mode = params.get("mode", "canonical")
        output_format = params.get("format", "octave")
        sections_filter: list[str] | None = params.get("sections", None)

        # If content is None, generate template
        if content is None:
            # For now, generate minimal template
            template = f"""===TEMPLATE===
META:
  TYPE::{schema_name}
  VERSION::"1.0"

// Template generated for schema: {schema_name}
===END==="""
            # I5 (Schema Sovereignty): validation_status must be UNVALIDATED to make bypass visible
            # "Schema bypass shall be visible, never silent" - North Star I5
            return {
                "output": template,
                "lossy": False,
                "fields_omitted": [],
                "validation_status": "UNVALIDATED",  # I5: Explicit bypass - no schema validator yet
            }

        # Parse content to AST
        try:
            doc = parse(content)
        except Exception as e:
            # If parsing fails, return error
            # I5 (Schema Sovereignty): validation_status must be UNVALIDATED to make bypass visible
            return {
                "output": f"// Parse error: {str(e)}\n{content}",
                "lossy": False,
                "fields_omitted": [],
                "validation_status": "UNVALIDATED",  # I5: Explicit bypass - no schema validator yet
            }

        # Project to desired mode
        result = project(doc, mode=mode)

        # Issue #341: Apply section filtering if sections parameter is provided.
        # Section filtering happens AFTER projection (mode-based filtering first).
        # When active, only Section nodes matching the requested IDs are kept,
        # and non-Section nodes (Assignment, Block) are excluded.
        if sections_filter:
            # Parse flexible identifiers to bare section_ids
            parsed_ids = [_parse_section_id(s) for s in sections_filter]
            filtered_doc, sections_omitted = _filter_document_by_sections(result.filtered_doc, parsed_ids)
            result = result.__class__(
                output=result.output,
                lossy=True,  # Section filtering is inherently lossy
                fields_omitted=result.fields_omitted + sections_omitted,
                filtered_doc=filtered_doc,
            )

        # Issue #235 T15: Compute literal zone info once; add to each format's return dict.
        # Uses the filtered_doc (after projection) so zone count reflects the projected view.
        zones = _count_literal_zones(result.filtered_doc)
        zone_extras: dict[str, Any] = {}
        if zones:
            zone_extras["contains_literal_zones"] = True
            zone_extras["literal_zone_count"] = len(zones)
            zone_extras["literal_zones_validated"] = False  # I5: always False (D4: content opaque)
            zone_extras["zone_report"] = {
                "dsl": {"status": "valid", "errors": []},
                "container": {
                    "status": "preserved" if result.filtered_doc.raw_frontmatter else "absent",
                    "validation_status": "UNVALIDATED",  # I5: default before schema validation runs
                },
                "literal": {
                    "status": "preserved",
                    "count": len(zones),
                    "content_validated": False,
                    "zones": zones,
                },
            }
            zone_extras["literal_zone_repair_log"] = build_literal_zone_repair_log(
                zones, result.filtered_doc, "octave_eject"
            ).to_dict()

        # Convert to requested output format
        # IL-PLACEHOLDER-FIX-002-REWORK: Use filtered AST from projection for all formats
        # I5 (Schema Sovereignty): All outputs must include validation_status
        # "Schema bypass shall be visible, never silent" - North Star I5
        if output_format == "json":
            # Convert filtered AST to dictionary, then serialize as JSON
            data = _ast_to_dict(result.filtered_doc)
            output = json.dumps(data, indent=2, ensure_ascii=False)
            return {
                "output": output,
                "lossy": result.lossy,
                "fields_omitted": result.fields_omitted,
                "validation_status": "UNVALIDATED",  # I5: Explicit bypass
                **zone_extras,
            }

        elif output_format == "yaml":
            # Convert filtered AST to dictionary, then serialize as YAML
            data = _ast_to_dict(result.filtered_doc)
            output = yaml.dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False)
            return {
                "output": output,
                "lossy": result.lossy,
                "fields_omitted": result.fields_omitted,
                "validation_status": "UNVALIDATED",  # I5: Explicit bypass
                **zone_extras,
            }

        elif output_format == "markdown":
            # Convert filtered AST to Markdown
            output = _ast_to_markdown(result.filtered_doc)
            return {
                "output": output,
                "lossy": result.lossy,
                "fields_omitted": result.fields_omitted,
                "validation_status": "UNVALIDATED",  # I5: Explicit bypass
                **zone_extras,
            }

        elif output_format == "gbnf":
            # Issue #171/#191: Export as llama.cpp GBNF grammar
            # Check for META.CONTRACT first (v6 self-describing documents)
            # Fall back to POLICY/FIELDS blocks if no CONTRACT
            doc = result.filtered_doc
            if doc.meta and "CONTRACT" in doc.meta:
                # Issue #191: Use META.CONTRACT for schema-specific GBNF
                gbnf_grammar = compile_gbnf_from_meta(doc.meta)
            else:
                # Fall back to extracting from POLICY/FIELDS blocks
                schema = extract_schema_from_document(doc)
                compiler = GBNFCompiler()
                gbnf_grammar = compiler.compile_schema(schema, include_envelope=True)
            return {
                "output": gbnf_grammar,
                "lossy": False,  # GBNF export is lossless from schema perspective
                "fields_omitted": [],
                "validation_status": "UNVALIDATED",  # I5: Explicit bypass
                "format": "gbnf",  # Indicate output format
                **zone_extras,
            }

        else:  # output_format == "octave"
            # GH#347: When section filtering is active, result.output still contains
            # the pre-filtering emit. Re-emit from filtered_doc to produce correct output.
            octave_output = emit(result.filtered_doc) if sections_filter else result.output
            return {
                "output": octave_output,
                "lossy": result.lossy,
                "fields_omitted": result.fields_omitted,
                "validation_status": "UNVALIDATED",  # I5: Explicit bypass
                **zone_extras,
            }
