"""
Editable and shadow mode marker file management.

This module manages marker files and metadata for editable and shadow modes,
which control how .claude/ files are synced to target projects.

Modes:
    - Editable Mode: Creates symlinks to source files for live-editing during development
    - Shadow Mode: Copies files but excludes them from git (local-only automation)
    - Normal Mode: Copies files and commits them to git

Key Functions:
    - is_editable_mode_enabled: Check if project is in editable mode
    - create_editable_marker: Enable editable mode with metadata tracking
    - get_editable_metadata: Read editable mode metadata (template path, timestamp)
    - is_shadow_mode_enabled: Check if project is in shadow mode
    - enable_shadow_mode: Enable shadow mode and update .git/info/exclude
    - is_ci_environment: Detect CI/CD environments (blocks editable mode)

Usage Example:
    >>> from pathlib import Path
    >>> from persuade.marker_ops import (
    ...     create_editable_marker, is_editable_mode_enabled, get_editable_metadata
    ... )
    >>>
    >>> # Enable editable mode for development
    >>> target_project = Path("/path/to/project")
    >>> template_repo = Path("/path/to/template")
    >>> create_editable_marker(target_project, template_repo)
    >>>
    >>> # Check if editable mode is active
    >>> if is_editable_mode_enabled(target_project):
    ...     metadata = get_editable_metadata(target_project)
    ...     print(f"Editable mode enabled at {metadata['enabled_at']}")
    ...     print(f"Template: {metadata['template_path']}")

Security:
    - Editable mode is automatically blocked in CI/CD environments
    - Shadow mode adds .git/info/exclude entries to prevent accidental commits
    - Marker files use JSON with runtime validation

Mode Transitions:
    - Normal → Editable: Next sync replaces copies with symlinks
    - Editable → Normal: Next sync replaces symlinks with copies
    - Shadow mode: Always uses copies (never symlinks)
    - Editable + Shadow: Mutually exclusive (validation error)
"""

import json
import os
import shlex
import subprocess
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal, TypedDict, cast

from persuade.constants import EDITABLE_MODE_MARKER, GIT_LS_FILES_TIMEOUT
from persuade.logging import get_logger

__all__ = [
    "EditableMetadata",
    "is_ci_environment",
    "is_editable_mode_enabled",
    "create_editable_marker",
    "get_editable_metadata",
    "remove_editable_marker",
    "validate_editable_environment",
    "is_shadow_mode_enabled",
    "enable_shadow_mode",
]

logger = get_logger()


class EditableMetadata(TypedDict):
    """Metadata stored in .claude/.editable marker file."""

    template_path: str  # Absolute path to template repository
    enabled_at: str  # ISO 8601 timestamp with timezone
    mode: Literal["editable"]  # Mode indicator (must be literal for TypedDict)


def is_ci_environment() -> bool:
    """
    Check if running in a CI/CD environment.

    Returns:
        True if running in CI
    """
    # Boolean flags (value = "true")
    bool_indicators = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI", "TRAVIS"]
    # Path-based indicators (just check if set)
    path_indicators = ["JENKINS_HOME", "BUILD_ID"]

    return any(os.environ.get(indicator) == "true" for indicator in bool_indicators) or any(
        indicator in os.environ for indicator in path_indicators
    )


def is_editable_mode_enabled(target_path: Path) -> bool:
    """
    Check if editable mode is enabled for a project.

    Args:
        target_path: Target project path

    Returns:
        True if editable mode is enabled (marker file exists)
    """
    editable_marker_path = target_path / ".claude" / ".editable"
    return editable_marker_path.exists()


def get_editable_metadata(target_path: Path) -> EditableMetadata | None:
    """
    Read editable mode metadata from marker file.

    Args:
        target_path: Target project path

    Returns:
        EditableMetadata dict or None if not in editable mode
    """
    editable_marker_path = target_path / ".claude" / ".editable"

    if not editable_marker_path.exists():
        return None

    try:
        with open(editable_marker_path) as f:
            data = json.load(f)

        # Validate required keys are present
        required_keys = {"template_path", "enabled_at", "mode"}
        if not isinstance(data, dict) or not required_keys.issubset(data.keys()):
            logger.warning(
                "invalid_editable_metadata",
                path=str(editable_marker_path),
                reason="missing_required_keys",
            )
            return None

        # Validate types and values
        if not isinstance(data.get("template_path"), str) or not data["template_path"]:
            logger.warning(
                "invalid_editable_metadata",
                path=str(editable_marker_path),
                reason="invalid_template_path_type",
            )
            return None

        if not isinstance(data.get("enabled_at"), str) or not data["enabled_at"]:
            logger.warning(
                "invalid_editable_metadata",
                path=str(editable_marker_path),
                reason="invalid_enabled_at_type",
            )
            return None

        if data.get("mode") != EDITABLE_MODE_MARKER:
            logger.warning(
                "invalid_editable_metadata",
                path=str(editable_marker_path),
                reason="invalid_mode_value",
                expected=EDITABLE_MODE_MARKER,
                actual=data.get("mode"),
            )
            return None

        return cast("EditableMetadata", data)
    except json.JSONDecodeError as e:
        logger.warning(
            "invalid_json_in_editable_marker",
            path=str(editable_marker_path),
            error=str(e),
        )
        return None
    except OSError as e:
        logger.warning(
            "cannot_read_editable_marker",
            path=str(editable_marker_path),
            error=str(e),
        )
        return None


def create_editable_marker(target_path: Path, source_path: Path) -> None:
    """
    Create .claude/.editable marker file with metadata.

    Enables editable mode for the target project. When transitioning from normal mode
    to editable mode, the next sync will replace existing copied files with symlinks.
    This allows live-editing of template files during development.

    Mode Transitions:
        - Normal → Editable: Copied files become symlinks on next sync
        - Editable → Normal: Symlinks become copies on next sync (marker removed)
        - Editable → Shadow: Blocked (incompatible modes)

    Args:
        target_path: Target project path
        source_path: Source template repository path
    """
    editable_marker_path = target_path / ".claude" / ".editable"
    editable_marker_path.parent.mkdir(parents=True, exist_ok=True)

    metadata: EditableMetadata = {
        "template_path": str(source_path.resolve()),
        "enabled_at": datetime.now(UTC).isoformat(),
        "mode": EDITABLE_MODE_MARKER,
    }

    with open(editable_marker_path, "w") as f:
        json.dump(metadata, f, indent=2)
        f.write("\n")

    logger.info("editable_marker_created", path=str(editable_marker_path))


def remove_editable_marker(target_path: Path) -> None:
    """
    Remove .claude/.editable marker file.

    Args:
        target_path: Target project path
    """
    editable_marker_path = target_path / ".claude" / ".editable"

    if editable_marker_path.exists():
        editable_marker_path.unlink()
        logger.info("editable_marker_removed", path=str(editable_marker_path))


def validate_editable_environment() -> None:
    """
    Validate that editable mode can be enabled.

    Raises:
        ValueError: If editable mode cannot be enabled (e.g., in CI)

    Example:
        >>> # In CI environment (GITHUB_ACTIONS=true)
        >>> validate_editable_environment()
        Traceback (most recent call last):
        ValueError: Editable mode is blocked in CI/CD environments...
    """
    if is_ci_environment():
        raise ValueError(
            "Editable mode is blocked in CI/CD environments. "
            "Editable mode is for development only. "
            "Use regular sync mode in CI/CD pipelines."
        )


def is_shadow_mode_enabled(target_path: Path) -> bool:
    """
    Check if shadow mode is enabled for a project.

    Args:
        target_path: Target project path

    Returns:
        True if shadow mode is enabled (marker file exists)
    """
    shadow_marker_path = target_path / ".claude" / ".shadow"
    return shadow_marker_path.exists()


def enable_shadow_mode(
    target_path: Path,
    synced_destinations: list[str] | None = None,
) -> tuple[list[str], list[str]]:
    """Enable shadow mode by writing to .git/info/exclude and creating marker file.

    Shadow mode syncs files to the target project but keeps them out of git
    by adding entries to the local .git/info/exclude file (never committed).

    Args:
        target_path: Target project path
        synced_destinations: List of all synced destination paths (relative to target)

    Returns:
        Tuple of (exclude_additions, untrack_commands)
        - exclude_additions: List of entries written to .git/info/exclude
        - untrack_commands: Git commands to untrack already-tracked files

    Raises:
        OSError: If marker file cannot be written
        PermissionError: If insufficient permissions
    """
    if synced_destinations is None:
        synced_destinations = []

    shadow_marker_path = target_path / ".claude" / ".shadow"
    git_dir = target_path / ".git"

    logger.info("enabling_shadow_mode", target_path=str(target_path))

    # Build exclude entries: .claude/ always, plus external destinations
    exclude_entries = [".claude/"]
    for dest in synced_destinations:
        if not dest.startswith(".claude/"):
            exclude_entries.append(dest)

    # Write to .git/info/exclude if .git directory exists
    additions: list[str] = []
    if git_dir.is_dir():
        exclude_path = git_dir / "info" / "exclude"
        exclude_path.parent.mkdir(parents=True, exist_ok=True)

        # Read existing content
        try:
            existing = exclude_path.read_text(encoding="utf-8") if exclude_path.exists() else ""
        except (OSError, UnicodeDecodeError) as e:
            logger.warning(
                "shadow_exclude_read_failed",
                path=str(exclude_path),
                error=str(e),
            )
            existing = ""

        # Remove existing persuade block if present
        start_marker = "# persuade shadow mode"
        end_marker = "# end persuade"
        if start_marker in existing:
            before = existing[: existing.index(start_marker)]
            after_idx = existing.find(end_marker)
            if after_idx != -1:
                after = existing[after_idx + len(end_marker) :]
                # Strip leading newline from after
                if after.startswith("\n"):
                    after = after[1:]
                existing = before.rstrip("\n")
                if after.strip():
                    existing = existing + "\n" + after
            else:
                # Malformed block — remove from start_marker to end of file
                logger.warning(
                    "shadow_exclude_malformed_block",
                    path=str(exclude_path),
                    note="end marker not found; truncating from start marker to EOF",
                )
                existing = existing[: existing.index(start_marker)].rstrip("\n")

        # Build new block
        block_lines = [f"{start_marker} \u2014 do not edit this block"]
        for entry in exclude_entries:
            block_lines.append(entry)
        block_lines.append(end_marker)
        block = "\n".join(block_lines) + "\n"

        # Write file: existing content + separator + new block
        with open(exclude_path, "w", encoding="utf-8") as f:
            if existing and not existing.endswith("\n"):
                existing += "\n"
            if existing:
                f.write(existing)
                f.write("\n")
            f.write(block)

        additions = exclude_entries
        logger.info("shadow_exclude_entries_written", count=len(additions), entries=additions)
    else:
        logger.warning(
            "no_git_dir_skipping_exclude",
            target_path=str(target_path),
            note="shadow mode marker created but git exclusion was skipped",
        )

    # Create .shadow marker file
    shadow_marker_path.parent.mkdir(parents=True, exist_ok=True)
    with open(shadow_marker_path, "w") as f:
        f.write("# Shadow mode enabled - .claude/ files are excluded from git\n")
        f.write("# This marker indicates the project uses Claude automation locally\n")
        f.write("# but keeps it out of the git repository.\n")

    logger.info("shadow_marker_created", path=str(shadow_marker_path))

    # Check if any synced files are already tracked or staged
    untrack_commands: list[str] = []
    if git_dir.is_dir():
        try:
            # Check for staged .claude/ files
            staged_result = subprocess.run(
                ["git", "diff", "--cached", "--name-only", "--", ".claude/"],
                cwd=target_path,
                capture_output=True,
                text=True,
                check=False,
                timeout=GIT_LS_FILES_TIMEOUT,
            )

            if staged_result.returncode == 0 and staged_result.stdout.strip():
                logger.warning(
                    "shadow_mode_staged_files_found",
                    files=staged_result.stdout.strip().splitlines()[:5],
                )
                untrack_commands.append("git restore --staged .claude/")

            # Check for tracked .claude/ files
            result = subprocess.run(
                ["git", "ls-files", ".claude/"],
                cwd=target_path,
                capture_output=True,
                text=True,
                check=False,
                timeout=GIT_LS_FILES_TIMEOUT,
            )

            if result.returncode == 0 and result.stdout.strip():
                logger.warning(
                    "shadow_mode_tracked_files_found",
                    files=result.stdout.strip().splitlines()[:5],
                )
                untrack_commands.extend(
                    [
                        "git rm -r --cached .claude/",
                        'git commit -m "chore: untrack .claude/ files (shadow mode enabled)"',
                    ]
                )

            # Also check external synced files
            external_dests = [d for d in synced_destinations if not d.startswith(".claude/")]
            for dest in external_dests:
                ext_result = subprocess.run(
                    ["git", "ls-files", "--error-unmatch", dest],
                    cwd=target_path,
                    capture_output=True,
                    text=True,
                    check=False,
                    timeout=GIT_LS_FILES_TIMEOUT,
                )
                if ext_result.returncode == 0:
                    logger.warning("shadow_mode_external_tracked", file=dest)
                    untrack_commands.append(f"git rm --cached {shlex.quote(dest)}")

        except (FileNotFoundError, subprocess.TimeoutExpired) as e:
            logger.warning(
                "shadow_mode_git_check_skipped",
                error=str(e),
                note="staged or tracked files may not be detected",
            )

    return additions, untrack_commands
