"""
Context map generation command.

Orchestrates the generation of Ontos_Context_Map.md using
extracted core and io modules.

Phase 2 Decomposition - Created from Phase2-Implementation-Spec.md Section 4.10
"""

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional, Any, Tuple

from ontos.core.validation import ValidationOrchestrator
from ontos.core.tokens import estimate_tokens, format_token_count
from ontos.core.types import DocumentData, DocumentStatus, ValidationResult
from ontos.ui.json_output import emit_command_error, emit_command_success


class CompactMode(Enum):
    """Compact output mode for context map."""
    OFF = "off"
    BASIC = "basic"
    RICH = "rich"


@dataclass
class GenerateMapOptions:
    """Configuration options for context map generation."""
    output_path: Optional[Path] = None
    strict: bool = False
    include_staleness: bool = True
    include_timeline: bool = True
    include_lint: bool = False
    max_dependency_depth: int = 5
    json_output: bool = False  # Phase 4: JSON output support
    quiet: bool = False  # Phase 4: Quiet mode
    obsidian: bool = False
    compact: CompactMode = CompactMode.OFF


# Helper to handle string/enum duality for type/status fields
def _val(item: Any) -> str:
    return item.value if hasattr(item, "value") else str(item)


def _load_known_concepts(root: Path) -> set:
    """Load known concept vocabulary from Common_Concepts.md.

    Returns an empty set if the file doesn't exist or can't be parsed,
    which disables vocabulary checking (structural checks still run).
    """
    # Check both possible locations
    for candidate in [
        root / ".ontos-internal" / "reference" / "Common_Concepts.md",
        root / "docs" / "reference" / "Common_Concepts.md",
    ]:
        if candidate.exists():
            try:
                content = candidate.read_text(encoding='utf-8')
                concepts = set()
                # Extract concept names from the markdown table rows
                # Format: | `concept_name` | ... |
                for line in content.split('\n'):
                    line = line.strip()
                    if line.startswith('|') and '`' in line:
                        cells = line.split('|')
                        if len(cells) >= 2:
                            cell = cells[1].strip()
                            # Extract backtick-wrapped concept name
                            if cell.startswith('`') and cell.endswith('`'):
                                concepts.add(cell[1:-1])
                return concepts
            except Exception:
                pass
    return set()


def generate_context_map(
    docs: Dict[str, DocumentData],
    config: Dict[str, Any],
    options: GenerateMapOptions = None,
    known_concepts: set = None
) -> Tuple[str, ValidationResult]:
    """Generate the context map markdown content.

    Args:
        docs: Dictionary of parsed documents
        config: Configuration dictionary
        options: Generation options

    Returns:
        Tuple of (content string, validation result)
    """
    options = options or GenerateMapOptions()

    # Vocabulary check (#42 / CC-16 / VUL-06)
    # If known_concepts is not provided, vocabulary check is skipped (old behavior)
    
    # Run validation
    validator = ValidationOrchestrator(docs, {
        "max_dependency_depth": options.max_dependency_depth,
        "allowed_orphan_types": config.get("allowed_orphan_types", ["atom", "log"]),
        "severity_map": {
            "broken_link": "warning",
            "concepts": "warning"
        },
        "known_concepts": known_concepts
    })
    result = validator.validate_all()

    # Compact output (if enabled)
    if options.compact != CompactMode.OFF:
        return _generate_compact_output(docs, options.compact), result

    # Ensure project_root exists in config (fallback to CWD for standalone usage)
    if "project_root" not in config:
        config = dict(config)
        config["project_root"] = str(Path.cwd())

    # Generate context map with 3 tiers
    root_path = _get_root_path(config)

    sections = [
        _generate_header(config),
        _generate_tier1_summary(docs, config, options),
        "## Tier 2: Document Index",
        "",
        _generate_document_table(
            docs,
            options.obsidian,
            root_path=root_path,
        ),
        "",
        "## Tier 3: Full Graph Details",
        "",
        _generate_validation_section(result),
        _generate_dependency_tree(docs),
        _generate_timeline(docs),
        _generate_staleness_section(docs),
        _generate_lint_section(docs, result) if options.include_lint else None,
    ]

    # Filter out None and assemble
    content = "\n\n".join([s for s in sections if s is not None])

    # Token Cap for Tier 1 (~2000 tokens)
    # If the combined map is huge, we still want to ensure Tier 1 is at the top.
    # The prompt suggests a cap, but we'll apply it to the whole content if it's for orientation.
    # Actually, the prompt says "Tier 1: Essential Context ... Hard cap at ~2000 tokens with truncation."
    # We'll allow the full map to be generated, but ensure Tier 1 is clearly marked.

    return content, result


def _generate_header(config: Dict[str, Any]) -> str:
    """Generate context map header with provenance and frontmatter."""
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    project_name = config.get("project_name", "Project")
    
    version = config.get("version", "unknown")
    
    # v3.0.1: Add YAML frontmatter for self-reference
    # v3.2.1: Add version marker
    frontmatter = f"""---
id: ontos_context_map
type: reference
status: generated
ontos_map_version: 2
generated_by: ontos map
generated_at: {timestamp}
---"""
    
    return f"""{frontmatter}

# {project_name} Tiered Context Map

> Auto-generated by Ontos {version}
> Last updated: {timestamp}

This document provides a tiered index of the knowledge graph for AI orientation.
- **Tier 1: Essential Context**: (~2k tokens) Project summary, recent work, and architecture.
- **Tier 2: Document Index**: Full list of all tracked documents.
- **Tier 3: Full Graph Details**: Dependency trees, timelines, and health metrics."""


def _generate_tier1_summary(
    docs: Dict[str, DocumentData],
    config: Dict[str, Any],
    options: GenerateMapOptions = None
) -> str:
    """Generate Tier 1: Essential Context (~2k tokens).

    Includes: Project summary, recent activity, critical paths.
    Hard cap at ~2000 tokens with section-aware truncation.
    """
    sections = []
    
    # Header
    sections.append("## Tier 1: Essential Context\n")

    # Project Summary
    summary_lines = ["### Project Summary"]
    project_name = config.get("project_name", "Unknown Project")
    summary_lines.append(f"- **Name:** {project_name}")
    summary_lines.append(f"- **Doc Count:** {len(docs)}")
    summary_lines.append(f"- **Last Updated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
    summary_lines.append("")
    sections.append("\n".join(summary_lines))

    # Recent Activity (from logs, limit to 3)
    log_lines = ["### Recent Activity"]
    log_docs = [d for d in docs.values() if _val(d.type) == "log"]
    
    # Sort by date frontmatter (falling back to ID)
    def log_sort_key(doc):
        date_str = doc.frontmatter.get("date")
        if date_str:
            return str(date_str)
        return doc.id

    log_docs_sorted = sorted(log_docs, key=log_sort_key, reverse=True)[:3]

    if log_docs_sorted:
        log_lines.append("| Log | Status | Summary |")
        log_lines.append("|-----|--------|---------|")
        for doc in log_docs_sorted:
            status = doc.status.value
            summary = doc.frontmatter.get("summary", "No summary")
            # B3: Escape pipes and remove newlines in summary
            summary_escaped = _escape_markdown_table_cell(summary).replace("\n", " ")
            log_lines.append(f"| {doc.id} | {status} | {summary_escaped} |")

        remaining = len(log_docs) - 3
        if remaining > 0:
            log_lines.append(f"| ... and {remaining} more logs | | |")
    else:
        log_lines.append("No recent logs found.")
    log_lines.append("")
    sections.append("\n".join(log_lines))

    # In Progress Documents
    ip_lines = []
    in_progress = [d for d in docs.values() if d.status == DocumentStatus.IN_PROGRESS]
    if in_progress:
        ip_lines.append("### In Progress")
        ip_lines.append("| Document | ID |")
        ip_lines.append("|----------|-----|")
        for doc in in_progress[:5]:
            ip_lines.append(f"| {doc.filepath.name} | {doc.id} |")
        if len(in_progress) > 5:
            ip_lines.append(f"| ... and {len(in_progress) - 5} more | |")
        ip_lines.append("")
        sections.append("\n".join(ip_lines))

    root_path = _get_root_path(config)

    # Key Documents (derived from dependency graph in-degree)
    in_degree: Dict[str, int] = {}
    for doc in docs.values():
        for dep_id in doc.depends_on:
            if dep_id in docs:
                in_degree[dep_id] = in_degree.get(dep_id, 0) + 1

    if in_degree:
        kd_lines = ["### Key Documents"]
        # Show top documents by in-degree (most depended-on), max 3.
        top_docs = sorted(in_degree.items(), key=lambda x: (-x[1], x[0]))[:3]

        for doc_id, count in top_docs:
            doc = docs.get(doc_id)
            rel_path = _format_rel_path(doc.filepath, root_path) if doc else "(path unknown)"
            kd_lines.append(f"- `{doc_id}` ({count} dependents) — {rel_path}")

        kd_lines.append("")
        sections.append("\n".join(kd_lines))

    # Critical Paths (config-driven)
    cp_lines = ["### Critical Paths"]
    docs_dir = config.get("docs_dir") or "docs"
    logs_dir = config.get("logs_dir") or f"{docs_dir}/logs"
    cp_lines.append(f"- **Docs Root:** {_format_critical_path(docs_dir, root_path)}")
    cp_lines.append(f"- **Logs:** {_format_critical_path(logs_dir, root_path)}")
    if config.get("is_contributor_mode", False):
        cp_lines.append(f"- **Strategy:** {_format_critical_path('.ontos-internal/strategy', root_path)}")
        cp_lines.append(f"- **Reference:** {_format_critical_path('.ontos-internal/reference', root_path)}")
    cp_lines.append("")
    sections.append("\n".join(cp_lines))

    # Build content with token awareness
    content_parts = []
    current_tokens = 0
    token_limit = 2000
    truncated = False

    for section in sections:
        section_tokens = estimate_tokens(section)
        if current_tokens + section_tokens > token_limit:
            truncated = True
            break
        content_parts.append(section)
        current_tokens += section_tokens

    if truncated:
        content_parts.append("\n... (Tier 1 truncated to stay within 2k token budget) ...\n")
    
    content_parts.append("---")
    content = "\n".join(content_parts)
        
    return content


def _escape_markdown_table_cell(value: str) -> str:
    """Escape special characters for markdown table cells.

    Escapes pipe characters and backslashes that would break table formatting.
    """
    if not value:
        return value
    # Escape backslashes first, then pipes
    return value.replace("\\", "\\\\").replace("|", "\\|")


def _get_root_path(config: Dict[str, Any]) -> Optional[Path]:
    """Resolve project root from config, or return None if unavailable."""
    project_root = config.get("project_root")
    if not project_root:
        return None
    try:
        return Path(project_root).resolve()
    except (OSError, RuntimeError):
        return None


def _sanitize_inline_code(value: str) -> str:
    """Escape backticks to avoid Markdown injection."""
    return str(value).replace("`", "'")


def _format_rel_path(path: Path, root_path: Optional[Path] = None) -> str:
    """Format a path relative to project root or CWD without leaking absolute paths."""
    try:
        resolved = path.resolve()
    except (OSError, RuntimeError):
        resolved = path

    if root_path:
        try:
            return resolved.relative_to(root_path).as_posix()
        except ValueError:
            pass

    try:
        return resolved.relative_to(Path.cwd()).as_posix()
    except ValueError:
        pass

    if not path.is_absolute():
        return path.as_posix()

    return path.name


def _format_critical_path(path_str: Optional[str], root_path: Optional[Path]) -> str:
    """Format a config path for Critical Paths, with validation and safety."""
    if not path_str or not str(path_str).strip():
        return "`(unset)`"

    raw = str(path_str).strip()
    try:
        path = Path(raw)
    except ValueError:
        return "`(invalid path)`"

    if path.is_absolute():
        return "`(invalid path)`"

    # Tri-state: None = unknown (no root), False = missing, True = present.
    exists = None
    if root_path:
        try:
            resolved = (root_path / path).resolve()
            if not resolved.is_relative_to(root_path):
                return "`(invalid path)`"
            display = resolved.relative_to(root_path).as_posix()
            exists = resolved.exists()
        except (OSError, RuntimeError, ValueError):
            return "`(invalid path)`"
    else:
        display = path.as_posix()

    display = _sanitize_inline_code(display.rstrip("/") + "/")
    suffix = " (missing)" if exists is False else ""
    return f"`{display}`{suffix}"


def _generate_document_table(
    docs: Dict[str, DocumentData],
    obsidian_mode: bool = False,
    root_path: Optional[Path] = None,
) -> str:
    """Generate document listing table."""
    if not docs:
        return "## Documents\n\nNo documents found."

    lines = [
        "## Documents",
        "",
        "| Path | ID | Type | Status |",
        "|------|-----|------|--------|",
    ]

    # Sort docs by path
    sorted_docs = sorted(docs.values(), key=lambda d: str(d.filepath))

    for doc in sorted_docs:
        doc_type = _val(doc.type)
        doc_status = _val(doc.status)
        # Escape special characters to prevent table breakage
        filepath = _escape_markdown_table_cell(_format_rel_path(doc.filepath, root_path))
        doc_id_link = _format_doc_link(doc.id, doc.filepath, obsidian_mode)
        lines.append(f"| {filepath} | {doc_id_link} | {doc_type} | {doc_status} |")

    return "\n".join(lines)


def _generate_validation_section(result: ValidationResult, errors_only: bool = False) -> str:
    """Generate validation messages section."""
    lines = ["## Validation"]
    
    if result.errors:
        lines.append("")
        lines.append("### Errors")
        for error in result.errors:
            lines.append(f"- ❌ **{error.doc_id}**: {error.message}")
            if error.fix_suggestion:
                lines.append(f"    {error.fix_suggestion}")
    
    if not errors_only and result.warnings:
        lines.append("")
        lines.append("### Warnings")
        for warning in result.warnings:
            lines.append(f"- ⚠️ **{warning.doc_id}**: {warning.message}")
            if warning.fix_suggestion:
                lines.append(f"    {warning.fix_suggestion}")
    
    if errors_only and not result.errors:
        return ""
        
    return "\n".join(lines)






def _generate_dependency_tree(docs: Dict[str, DocumentData]) -> str:
    """Generate dependency tree visualization."""
    lines = ["## Dependency Tree"]
    
    if not docs:
        lines.append("\nNo dependencies to display.")
        return "\n".join(lines)
    
    # Find root nodes (no dependents)
    all_deps = set()
    for doc in docs.values():
        all_deps.update(doc.depends_on)
    
    roots = [doc_id for doc_id in docs if doc_id not in all_deps]
    
    if not roots:
        lines.append("\nAll documents have dependencies (possible cycle).")
        return "\n".join(lines)
    
    lines.append("")
    for root in sorted(roots)[:10]:  # Limit for readability
        lines.append(f"- **{root}**")
        if root in docs:
            for dep in docs[root].depends_on[:5]:
                lines.append(f"  - {dep}")
    
    return "\n".join(lines)


def _generate_timeline(docs: Dict[str, DocumentData]) -> str:
    """Generate activity timeline."""
    lines = ["## Recent Activity"]
    
    # Filter to logs only
    logs = [doc for doc in docs.values() 
            if doc.type.value == "log"]
    
    if not logs:
        lines.append("\nNo session logs found.")
        return "\n".join(lines)
    
    # Sort by date (extract from ID if possible)
    sorted_logs = sorted(logs, key=lambda d: d.id, reverse=True)[:10]
    
    lines.append("")
    for log in sorted_logs:
        lines.append(f"- `{log.id}`")
    
    return "\n".join(lines)


def _generate_staleness_section(docs: Dict[str, DocumentData]) -> Optional[str]:
    """Generate staleness detection section."""
    # Find documents with describes field that might be stale
    stale_candidates = []
    
    for doc in docs.values():
        # Check if doc has describes field in frontmatter
        describes = doc.frontmatter.get("describes", [])
        if describes:
            stale_candidates.append(doc)
    
    if not stale_candidates:
        return None
    
    lines = ["## Staleness Check"]
    lines.append("")
    lines.append(f"Found {len(stale_candidates)} documents with `describes` field:")
    lines.append("")
    
    for doc in stale_candidates[:10]:  # Limit display
        describes = doc.frontmatter.get("describes", [])
        verified = doc.frontmatter.get("describes_verified", "never")
        lines.append(f"- **{doc.id}**: describes {describes}, verified: {verified}")
    
    return "\n".join(lines)


def _generate_lint_section(docs: Dict[str, DocumentData], result: ValidationResult) -> Optional[str]:
    """Generate lint warnings section."""
    lint_issues = []

    for doc in docs.values():
        doc_type = _val(doc.type)

        # Check for empty impacts on logs
        if doc_type == "log":
            impacts = doc.frontmatter.get("impacts", [])
            if not impacts:
                lint_issues.append(f"**{doc.id}**: Empty impacts on log")

        # Check for too many concepts
        concepts = doc.frontmatter.get("concepts", [])
        if len(concepts) > 6:
            lint_issues.append(f"**{doc.id}**: {len(concepts)} concepts (recommended: 2-4)")

    if not lint_issues and not result.warnings:
        return None

    lines = ["## Lint Warnings"]
    lines.append("")

    for issue in lint_issues[:20]:  # Limit display
        lines.append(f"- ⚠️ {issue}")

    return "\n".join(lines)


def _generate_compact_output(docs: Dict[str, Any], mode: CompactMode) -> str:
    """Generate compact context map format.

    Standard compact (BASIC): id:type:status
    Rich compact (RICH): id:type:status:"summary"

    Args:
        docs: Document dictionary (DocumentData objects).
        mode: CompactMode (BASIC or RICH).

    Returns:
        Compact format string, one doc per line.
    """
    if mode == CompactMode.OFF:
        return ""

    lines = []
    for doc_id, doc in sorted(docs.items()):
        doc_type = _val(doc.type)
        doc_status = _val(doc.status)

        if mode == CompactMode.RICH:
            summary = str(doc.frontmatter.get('summary', ''))
            if summary:
                # Escape backslashes, quotes, and newlines
                summary_safe = (summary
                    .replace('\\', '\\\\')
                    .replace('"', '\\"')
                    .replace('\n', '\\n'))
                lines.append(f'{doc_id}:{doc_type}:{doc_status}:"{summary_safe}"')
            else:
                lines.append(f'{doc_id}:{doc_type}:{doc_status}')
        else:
            lines.append(f'{doc_id}:{doc_type}:{doc_status}')

    return '\n'.join(lines)


def _format_doc_link(doc_id: str, doc_path: Path, obsidian_mode: bool) -> str:
    """Format document link based on output mode.

    In Obsidian mode, uses [[filename|display]] format since Obsidian
    resolves links by filename, not frontmatter ID.

    Args:
        doc_id: Document ID from frontmatter.
        doc_path: Path to the document file.
        obsidian_mode: Whether to use Obsidian wikilink format.

    Returns:
        Formatted link string.
    """
    if obsidian_mode:
        filename = doc_path.stem  # filename without extension
        if filename == doc_id:
            return f"[[{doc_id}]]"  # No alias needed
        return f"[[{filename}|{doc_id}]]"  # Alias for display
    return f"`{doc_id}`"


@dataclass
class FilterExpression:
    """Single filter expression."""
    field: str
    values: list


def parse_filter(expr: str) -> list:
    """Parse filter expression string.

    Syntax: FIELD:VALUE | FIELD:VALUE,VALUE | EXPR EXPR
    - Multiple values for same field: OR (match any)
    - Multiple fields: AND (match all)

    Args:
        expr: Filter string like "type:strategy status:active"

    Returns:
        List of FilterExpression objects.
    """
    if not expr or not expr.strip():
        return []

    filters = []
    for part in expr.split():
        if ':' not in part:
            continue  # Skip invalid parts
        field, _, value = part.partition(':')
        field = field.strip().lower()
        if not field or not value:
            continue  # Skip empty field or value
        values = [v.strip() for v in value.split(',') if v.strip()]
        if values:
            filters.append(FilterExpression(field=field, values=values))
    return filters


def matches_filter(doc: Any, filters: list) -> bool:
    """Check if document matches all filter expressions.

    Args:
        doc: Document to check (DocumentData).
        filters: List of FilterExpression objects (AND).

    Returns:
        True if document matches all filters.
    """
    import fnmatch

    for f in filters:
        if f.field == 'type':
            doc_type_str = _val(doc.type)
            if doc_type_str.lower() not in [v.lower() for v in f.values]:
                return False
        elif f.field == 'status':
            doc_status_str = _val(doc.status)
            if doc_status_str.lower() not in [v.lower() for v in f.values]:
                return False
        elif f.field == 'concept':
            concepts = doc.frontmatter.get('concepts', [])
            if not any(c.lower() in [v.lower() for v in f.values] for c in concepts):
                return False
        elif f.field == 'id':
            if not any(fnmatch.fnmatch(doc.id.lower(), v.lower()) for v in f.values):
                return False
        # Unknown fields: ignore (per CA guidance)

    return True


@dataclass
class MapOptions:
    """CLI-level options for map command."""
    output: Optional[Path] = None
    strict: bool = False
    json_output: bool = False
    quiet: bool = False
    obsidian: bool = False
    compact: CompactMode = CompactMode.OFF
    filter_expr: Optional[str] = None
    no_cache: bool = False
    sync_agents: bool = False
    scope: Optional[str] = None


def map_command(options: MapOptions) -> int:
    """Execute map command from CLI.

    Orchestrates document scanning, loading, map generation, and file writing.

    Args:
        options: CLI-level map options

    Returns:
        Exit code (0 for success, 1 for errors, 2 for warnings in strict mode)
    """
    from ontos.io.files import find_project_root, load_documents, DocumentLoadResult
    from ontos.io.config import load_project_config
    from ontos.io.scan_scope import collect_scoped_documents, resolve_scan_scope
    from ontos.io.yaml import parse_frontmatter_content
    from ontos.core.cache import DocumentCache

    # Find project root
    try:
        project_root = find_project_root()
    except FileNotFoundError as e:
        if options.json_output:
            emit_command_error(
                command="map",
                exit_code=1,
                code="E_COMMAND_FAILED",
                message=str(e),
            )
        elif not options.quiet:
            print(f"Error: {e}")
        return 1

    # Load config
    try:
        config = load_project_config(repo_root=project_root)
    except Exception as e:
        if options.json_output:
            emit_command_error(
                command="map",
                exit_code=1,
                code="E_COMMAND_FAILED",
                message=f"Config error: {e}",
            )
        elif not options.quiet:
            print(f"Config error: {e}")
        return 1

    # Determine paths
    output_path = options.output or (project_root / config.paths.context_map)

    # Scan using shared scope utility.
    effective_scope = resolve_scan_scope(options.scope, config.scanning.default_scope)
    skip = list(config.scanning.skip_patterns)
    # Skip the context map itself using full resolved path
    skip.append(str(output_path.resolve()))
    doc_paths = collect_scoped_documents(
        project_root,
        config,
        effective_scope,
        base_skip_patterns=skip,
    )

    # Load documents using canonical loader (#40, #10)
    cache = DocumentCache() if not options.no_cache else None
    load_result: DocumentLoadResult = load_documents(
        doc_paths, 
        parse_frontmatter_content, 
        cache=cache
    )
    
    # Process load issues
    for issue in load_result.issues:
        label = "Error" if issue.code in {"duplicate_id", "parse_error", "io_error"} else "Warning"
        if not options.quiet:
            print(f"{label}: {issue.message}")

    if load_result.has_fatal_errors or load_result.duplicate_ids:
        return 1

    # filter
    filters = parse_filter(options.filter_expr)
    docs = {
        doc_id: doc 
        for doc_id, doc in load_result.documents.items() 
        if matches_filter(doc, filters)
    }

    # Build config dict for generation
    is_contributor_mode = (project_root / ".ontos-internal").is_dir()
    gen_config = {
        "project_name": project_root.name,
        "version": config.ontos.version,
        "allowed_orphan_types": config.validation.allowed_orphan_types,
        "project_root": str(project_root),
        "docs_dir": str(config.paths.docs_dir),
        "logs_dir": str(config.paths.logs_dir),
        "is_contributor_mode": is_contributor_mode,
    }

    # Generate context map
    gen_options = GenerateMapOptions(
        output_path=output_path,
        strict=options.strict,
        max_dependency_depth=config.validation.max_dependency_depth,
        obsidian=options.obsidian,
        compact=options.compact,
    )

    # Load authoritative vocabulary (#42 / CC-16 / VUL-06)
    known_concepts = _load_known_concepts(project_root)

    content, result = generate_context_map(docs, gen_config, gen_options, known_concepts=known_concepts)

    # Write output
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(content, encoding="utf-8")
    
    # Sync AGENTS.md if flag is set and file exists
    if options.sync_agents:
        from ontos.io.files import find_project_root
        try:
            repo_root = find_project_root()
            agents_path = repo_root / "AGENTS.md"
            if agents_path.exists():
                if not options.quiet:
                    print("Syncing AGENTS.md...")
                from ontos.core.instruction_artifacts import generate_agents_files

                code, msg = generate_agents_files(
                    repo_root=repo_root,
                    output_path=agents_path,
                    force=True,
                    format="agents",
                    all_formats=False,
                    scope=options.scope,
                )
                if code != 0 and not options.quiet:
                    print(f"⚠ AGENTS.md sync warning: {msg}")
                if not options.quiet:
                    print("✓ AGENTS.md synced")
            else:
                if not options.quiet:
                    print("⚠ AGENTS.md not found. Run 'ontos agents' to create.")
        except Exception as e:
            if not options.quiet:
                print(f"⚠ Failed to sync AGENTS.md: {e}")

    # Determine exit code
    exit_code = 0
    if result.errors:
        exit_code = 1
    elif options.strict and result.warnings:
        exit_code = 2

    # Output result
    if options.json_output:
        payload = {
            "path": str(output_path),
            "documents": len(docs),
            "errors": len(result.errors),
            "warnings": len(result.warnings),
        }
        if exit_code == 0:
            emit_command_success(
                command="map",
                exit_code=0,
                message="Context map generated",
                data=payload,
            )
        else:
            emit_command_error(
                command="map",
                exit_code=exit_code,
                code="E_COMMAND_FAILED",
                message="Context map generated with issues",
                data=payload,
            )
    elif not options.quiet:
        print(f"Context map generated: {output_path}")
        print(f"  Documents: {len(docs)}")
        if result.errors:
            print(f"  Errors: {len(result.errors)}")
        if result.warnings:
            print(f"  Warnings: {len(result.warnings)}")

    return exit_code
