"""Pure shared helpers for the Maeris CLI (no command registration)."""

import asyncio
import csv
import hashlib
import json
import sys
import threading
import subprocess
from io import StringIO
from pathlib import Path

import click


def _is_tty() -> bool:
    """Return True if stdout is an interactive terminal."""
    return sys.stdout.isatty()


def _print_record(data: dict) -> None:
    """Print a dict as human-readable key: value lines (TTY mode)."""
    for key, value in data.items():
        click.echo(f"  {key}: {value}")


def _print_json(data: object) -> None:
    """Print data as pretty-printed JSON (non-TTY / pipe mode)."""
    click.echo(_json_text(data))


def _json_text(data: object) -> str:
    """Return data as pretty-printed JSON text."""
    return json.dumps(data, indent=2)


def _print_csv(rows: list[dict], columns: list[str]) -> None:
    """Print a list of dicts as CSV."""
    click.echo(_csv_text(rows, columns))


def _csv_text(rows: list[dict], columns: list[str]) -> str:
    """Return a list of dicts as CSV text."""
    buffer = StringIO()
    writer = csv.DictWriter(buffer, fieldnames=columns, extrasaction="ignore")
    writer.writeheader()
    for row in rows:
        record = row if isinstance(row, dict) else {}
        writer.writerow({col: record.get(col, "") for col in columns})
    return buffer.getvalue().rstrip("\n")


def _resolve_structured_output_format(
    as_json: bool,
    as_csv: bool,
    output_path: str | None = None,
) -> str | None:
    """Resolve output format from flags and optional output filename."""
    if as_json and as_csv:
        raise click.UsageError("Choose only one output format: --json or --csv.")
    if as_json:
        return "json"
    if as_csv:
        return "csv"
    if not output_path:
        return None

    suffix = Path(output_path).suffix.lower()
    if suffix == ".json":
        return "json"
    if suffix == ".csv":
        return "csv"
    raise click.UsageError("When using --output without --json/--csv, use a .json or .csv filename.")


def _write_output_file(content: str, output_path: str) -> Path:
    """Write structured output to a file, creating parent directories as needed."""
    path = Path(output_path).expanduser()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content + ("" if content.endswith("\n") else "\n"), encoding="utf-8")
    return path


def _print_table(rows: list, columns: list[str]) -> None:
    """Print a list of dicts as a simple ASCII table."""
    if not rows:
        click.echo("  (no results)")
        return
    # Determine column widths
    widths = {col: len(col) for col in columns}
    for row in rows:
        r = row if isinstance(row, dict) else (row.__dict__ if hasattr(row, "__dict__") else {})
        for col in columns:
            val = str(r.get(col, ""))
            if len(val) > widths[col]:
                widths[col] = len(val)
    # Header
    header = "  " + "  ".join(col.ljust(widths[col]) for col in columns)
    sep = "  " + "  ".join("-" * widths[col] for col in columns)
    click.echo(header)
    click.echo(sep)
    for row in rows:
        r = row if isinstance(row, dict) else (row.__dict__ if hasattr(row, "__dict__") else {})
        line = "  " + "  ".join(str(r.get(col, "")).ljust(widths[col]) for col in columns)
        click.echo(line)


def _print_result(data: object) -> None:
    """Print a result that may be a dict or a list of dicts."""
    if isinstance(data, list):
        if data and isinstance(data[0], dict):
            _print_table(data, list(data[0].keys()))
        else:
            for item in data:
                click.echo(f"  {item}")
    elif isinstance(data, dict):
        _print_record(data)
    else:
        click.echo(str(data))


def _parse_json_input(value: str, option_name: str) -> object:
    """Parse a JSON string CLI option, raising click.BadParameter on failure."""
    try:
        return json.loads(value)
    except json.JSONDecodeError as exc:
        raise click.BadParameter(f"Invalid JSON for {option_name}: {exc}") from exc



def _repo_config_dir(repo_path: Path | None = None) -> Path:
    """Return the credentials directory for the current (or given) repo path.

    Resolution order:
      1. MAERIS_CONFIG_DIR env var (set by .mcp.json or assistant subprocess)
      2. ~/.maeris/repos/<hash>/ where <hash> is derived from the repo path
    """
    import os
    env_dir = os.getenv("MAERIS_CONFIG_DIR")
    if env_dir and not repo_path:
        return Path(env_dir)
    path = repo_path or Path.cwd()
    repo_hash = hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]
    return Path.home() / ".maeris" / "repos" / repo_hash


def _run_async(
    coro: "asyncio.Coroutine",  # type: ignore[name-defined]
    spinner_message: str = "Working…",
) -> None:
    """Run an async coroutine from a synchronous Click command.

    Shows an animated Rich spinner with *spinner_message* while the coroutine
    executes.

    Catches AuthenticationError (token expired / 401) universally:
    launches ``maeris auth login`` so the user can re-authenticate, then asks
    them to re-run the original command.  Every CLI command gets automatic
    re-auth handling for free through this single entry point.
    """
    try:
        with _spinner(spinner_message):
            asyncio.run(coro)
    except Exception as exc:
        from maeris_mcp.maeris.client import AuthenticationError as _AuthErr
        if isinstance(exc, _AuthErr) or _is_token_expired(exc):
            import shutil
            click.echo()
            click.secho("Token expired.", fg="yellow")
            maeris_bin = shutil.which("maeris") or "maeris"
            click.echo("  Launching 'maeris auth login' — complete authentication in your browser…")
            rc = subprocess.run([maeris_bin, "auth", "login"]).returncode
            if rc == 0:
                click.secho("  OK Re-authenticated. Please re-run your command.", fg="green")
            else:
                click.secho("  FAIL Re-authentication failed. Run 'maeris auth login' manually.", fg="red")
            raise SystemExit(1)
        if isinstance(exc, ValueError):
            msg = str(exc)
            if "not found" in msg.lower() or "resolves outside the project directory" in msg.lower():
                raise click.ClickException(msg)
        raise


def _next_steps(*steps: str) -> None:
    """Print dim-colored next-step suggestions after a command completes."""
    click.echo()
    click.secho("  Next:", fg="bright_black")
    for step in steps:
        click.secho(f"    → {step}", fg="bright_black")
    click.echo()


def _spinner(message: str):
    """Return a rich spinner context manager for showing progress.

    Usage:
        with _spinner("Fetching data…"):
            result = asyncio.run(some_async_call())
    """
    from rich.console import Console
    console = Console()
    return console.status(f"[bold cyan]{message}", spinner="dots")


def _is_token_expired(exc: Exception) -> bool:
    """Return True if the exception looks like a 401 / token-expired error."""
    msg = str(exc)
    return (
        "401" in msg
        or "expired" in msg.lower()
        or "sign in again" in msg.lower()
        or "unauthorized" in msg.lower()
    )


async def _refresh_client() -> "MaerisClient":  # type: ignore[name-defined]
    """Re-authenticate interactively and return a new MaerisClient with fresh credentials.

    Runs ``maeris auth login`` as a subprocess so the user can complete the OAuth
    flow, then constructs a new client that picks up the saved token.
    """
    import shutil

    click.echo()
    click.secho("Token expired.", fg="yellow")
    maeris_bin = shutil.which("maeris") or "maeris"
    click.echo("  Launching 'maeris auth login' — complete authentication in your browser…")
    rc = subprocess.run([maeris_bin, "auth", "login"]).returncode
    if rc != 0:
        click.secho("  Re-authentication failed. Aborting.", fg="red")
        raise SystemExit(1)
    from maeris_mcp.maeris.client import MaerisClient
    return MaerisClient()


def _run_claude_capture(
    claude_bin: str,
    prompt: str,
    model: str,
    cwd: str | None = None,
    label: str = "Claude",
    extra_args: "list[str] | None" = None,
    stdin_text: str | None = None,
) -> tuple[str, int]:
    """Run Claude CLI, stream stderr live to the terminal, and return captured stdout.

    Unlike ``subprocess.run(capture_output=True)`` (which is completely silent),
    this prints Claude's stderr output line-by-line as it arrives so the user can
    see progress — while returning stdout for structured parsing (JSON, etc.).

    Args:
        extra_args:  Additional CLI flags appended after the standard args
                     (e.g. ``["--output-format", "json", "--no-session-persistence"]``).
        stdin_text:  Text piped to Claude's stdin (for ``--input-format text``).

    Returns:
        (stdout_text, return_code)
    """
    cmd = [claude_bin, "-p", prompt, "--model", model] + (extra_args or [])

    from rich.console import Console
    console = Console(stderr=True)

    proc = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        stdin=subprocess.PIPE if stdin_text is not None else None,
        text=True,
        encoding="utf-8",
        bufsize=1,
        cwd=cwd,
    )

    stderr_lines: list[str] = []

    def _drain_stderr() -> None:
        assert proc.stderr is not None
        for line in proc.stderr:
            stderr_lines.append(line)

    t = threading.Thread(target=_drain_stderr, daemon=True)
    t.start()

    # Write stdin (if any) then read all stdout.
    assert proc.stdout is not None
    if stdin_text is not None:
        assert proc.stdin is not None
        proc.stdin.write(stdin_text)
        proc.stdin.close()

    with console.status(f"[bold cyan]{label} — thinking…", spinner="dots"):
        stdout_data = proc.stdout.read()
        return_code = proc.wait()
        t.join(timeout=5)

    return stdout_data, return_code


def _resolve_ai_mode() -> "tuple[str | None, str]":
    """Return (claude_bin, ai_mode).

    claude_bin is None when Maeris direct API should be used.
    Raises SystemExit(1) if external_llm mode but claude not installed.
    """
    import shutil
    from maeris_mcp.auth.ai_config import AIConfig
    ai_mode = AIConfig().get_ai_mode() or ""
    if ai_mode == "maeris":
        return None, ai_mode
    # Prefer the real Windows executable (`claude.exe`) to avoid wrapper scripts
    # (`claude.ps1` / `claude.cmd`) which can hit command-line length limits.
    claude_bin = shutil.which("claude.exe") or shutil.which("claude")
    if ai_mode == "external_llm" and not claude_bin:
        click.secho(
            "FAIL External LLM mode requires Claude Code CLI but it is not installed. "
            "Install it from https://claude.ai/download or switch to Maeris AI "
            "with 'maeris app init'.",
            fg="red", err=True,
        )
        raise SystemExit(1)
    return claude_bin, ai_mode


def _run_direct_api_capture(
    prompt: str,
    model: str,
    system_prompt: str = "",
    label: str = "Maeris AI",
    stdin_text: str | None = None,
) -> "tuple[str, int]":
    """Single-turn call to proxy_anthropic_message for JSON extraction.

    Drop-in replacement for _run_claude_capture when claude_bin is None.
    stdin_text is prepended into the user message.
    Returns (stdout_text, return_code) — same signature as _run_claude_capture.
    """
    from uuid import uuid4

    full_prompt = prompt
    if stdin_text:
        full_prompt = stdin_text + "\n\n" + prompt

    body: dict = {
        "model": model,
        "max_tokens": 8192,
        "messages": [
            {"role": "user", "content": full_prompt},
        ],
    }
    if system_prompt:
        body["system"] = system_prompt

    async def _call() -> str:
        from maeris_mcp.maeris.client import MaerisClient
        async with MaerisClient() as client:
            response = await client.proxy_anthropic_message(
                body=body,
                idempotency_key=str(uuid4()),
            )
        content = response.get("content", [])
        if isinstance(content, list):
            return "".join(
                block.get("text", "")
                for block in content
                if isinstance(block, dict) and block.get("type") == "text"
            )
        if isinstance(content, str):
            return content
        return ""

    try:
        with _spinner(f"{label} — thinking…"):
            result = asyncio.run(_call())
        return result, 0
    except Exception as exc:
        click.secho(f"  FAIL Maeris AI error: {exc}", fg="red", err=True)
        return "", 1


def _get_testcase_id(tc: dict) -> str:
    return tc.get("testcase_id") or tc.get("id") or tc.get("_id") or ""


def _prompt_numbered(items: list[str], prompt: str, multi: bool = False) -> list[int]:
    """Display a numbered list and return selected 0-based indices.

    If multi=True, accepts comma/range/all input.
    If multi=False, loops until the user enters a valid single number.
    """
    click.echo()
    for i, item in enumerate(items, 1):
        click.echo(f"  {i:>3})  {item}")
    click.echo()

    if multi:
        try:
            raw = click.prompt(f'{prompt} (e.g. "1,3" or "2-5" or "all")').strip()
        except click.Abort:
            click.echo("\nAborted.")
            sys.exit(0)
        if raw.lower() == "all":
            return list(range(len(items)))
        indices: set[int] = set()
        for part in raw.split(","):
            part = part.strip()
            if "-" in part:
                a, _, b = part.partition("-")
                if a.isdigit() and b.isdigit():
                    indices.update(range(int(a) - 1, int(b)))
            elif part.isdigit():
                indices.add(int(part) - 1)
        return [i for i in sorted(indices) if 0 <= i < len(items)]
    else:
        while True:
            try:
                raw = click.prompt(prompt).strip()
            except click.Abort:
                click.echo("\nAborted.")
                sys.exit(0)
            if raw.isdigit() and 1 <= int(raw) <= len(items):
                return [int(raw) - 1]
            click.secho(f"  Enter a number between 1 and {len(items)}.", fg="yellow")


def _id_or_name_match(item: dict, query: str) -> bool:
    """Return True if query matches the item's ID or name (case-insensitive)."""
    q = query.strip().lower()
    for key in ("_id", "id", "api_id", "api_collection_id", "collection_id", "api_environment_id", "environment_id", "env_id", "flow_id", "api_flow_id"):
        if q == str(item.get(key, "")).lower():
            return True
    for key in ("name", "api_name", "collection_name", "environment_name", "flow_name"):
        if q == str(item.get(key, "")).lower():
            return True
    return False


def _extract_api_id(item: dict) -> str:
    return item.get("_id") or item.get("api_id") or item.get("id") or ""


def _not_found_error(entity: str, value: str, list_command: str) -> ValueError:
    """Return a consistent not-found error with a next-step suggestion."""
    return ValueError(
        f'{entity} not found: "{value}".\nTip: Run \'{list_command}\' to see available {entity.lower()}s.'
    )


async def _resolve_api(client: object, name_or_id: str) -> str:
    """Return API ID for a given name or ID.

    If multiple APIs share the same name, prompts the user to pick one.
    """
    items = await client.get_all_apis()  # type: ignore[attr-defined]

    # Exact ID or name matches
    exact = [i for i in items if _id_or_name_match(i, name_or_id)]
    if len(exact) == 1:
        return _extract_api_id(exact[0])

    # Substring matches (only if no exact hits)
    if not exact:
        q = name_or_id.strip().lower()
        exact = [i for i in items if q in str(i.get("name", "")).lower()]

    if not exact:
        raise _not_found_error("API", name_or_id, "maeris api list")

    if len(exact) == 1:
        return _extract_api_id(exact[0])

    # Multiple matches — ask the user to pick
    click.echo(f"\n  Multiple APIs match '{name_or_id}':")
    for idx, item in enumerate(exact, 1):
        aid = _extract_api_id(item)
        method = (item.get("type") or item.get("method", "")).upper()
        url = item.get("url", "")
        col = item.get("collection_name") or item.get("collection_id") or ""
        col_str = f"  [{col}]" if col else ""
        click.echo(f"  {idx}) {item.get('name', aid)}  {method} {url}{col_str}")

    while True:
        try:
            raw = click.prompt("\n  Select").strip()
        except click.Abort:
            click.echo("\nAborted.")
            sys.exit(0)
        if raw.isdigit() and 1 <= int(raw) <= len(exact):
            return _extract_api_id(exact[int(raw) - 1])
        click.secho(f"  Enter a number between 1 and {len(exact)}.", fg="yellow")


async def _resolve_collection(client: object, name_or_id: str) -> str:
    """Return collection ID for a given name or ID, raising ValueError if not found."""
    items = await client.get_collections()  # type: ignore[attr-defined]
    for item in items:
        if _id_or_name_match(item, name_or_id):
            return (
                item.get("_id") or item.get("api_collection_id")
                or item.get("collection_id") or item.get("id") or ""
            )
    q = name_or_id.strip().lower()
    for item in items:
        if q in str(item.get("name", "")).lower():
            return (
                item.get("_id") or item.get("api_collection_id")
                or item.get("collection_id") or item.get("id") or ""
            )
    raise _not_found_error("Collection", name_or_id, "maeris collection list")


async def _resolve_environment(client: object, name_or_id: str) -> str:
    """Return environment ID for a given name or ID, raising ValueError if not found."""
    items = await client.get_environments()  # type: ignore[attr-defined]
    for item in items:
        if _id_or_name_match(item, name_or_id):
            return (
                item.get("_id") or item.get("env_id") or item.get("api_environment_id")
                or item.get("environment_id") or item.get("id") or ""
            )
    q = name_or_id.strip().lower()
    for item in items:
        if q in str(item.get("name", "")).lower():
            return (
                item.get("_id") or item.get("env_id") or item.get("api_environment_id")
                or item.get("environment_id") or item.get("id") or ""
            )
    raise _not_found_error("Environment", name_or_id, "maeris env list")


async def _resolve_flow(client: object, name_or_id: str) -> str:
    """Return flow ID for a given name or ID, raising ValueError if not found."""
    items = await client.get_api_flows()  # type: ignore[attr-defined]
    for item in items:
        if _id_or_name_match(item, name_or_id):
            return item.get("_id") or item.get("flow_id") or item.get("id") or ""
    q = name_or_id.strip().lower()
    for item in items:
        name_val = item.get("flow_name") or item.get("name") or ""
        if q in str(name_val).lower():
            return item.get("_id") or item.get("flow_id") or item.get("id") or ""
    raise _not_found_error("Flow", name_or_id, "maeris flow list")


async def _resolve_or_create_collection(client: object, name_or_id: str) -> str:
    """Return the collection ID for name_or_id, creating it if it doesn't exist."""
    try:
        return await _resolve_collection(client, name_or_id)
    except Exception:
        pass
    # Not found — create it
    new_id = await client.create_collection(name_or_id)
    return new_id


# ---------------------------------------------------------------------------
# Maeris AI mode helpers
# ---------------------------------------------------------------------------


_AUTOMATION_SYSTEM_PROMPT = (
    "You are running in a fully automated non-interactive CLI session. "
    "Follow the user prompt exactly. "
    "Only call the MCP tools explicitly requested in the prompt. "
    "Do not run shell commands. Do not modify files unless the prompt explicitly instructs it. "
    "If a tool response contains a pendingDecision, stop after printing the requested summary."
)


_INTERACTIVE_SYSTEM_PROMPT = (
    "You are running in an interactive CLI session where the user can review and provide feedback. "
    "Follow the initial prompt to scan for APIs using the create_apis_for MCP tool. "
    "Only call MCP tools explicitly requested in the prompt. "
    "Do not run shell commands. Do not modify files. "
    "When the tool returns a pendingDecision with status 'review', present a clear summary: "
    "list the APIs found, flows proposed, and environments detected. "
    "Ask the user to confirm or request changes before calling approve=true. "
    "Never approve without explicit user confirmation."
)


def _claude_run(
    claude_bin: str,
    prompt: str,
    model: str,
    cwd: str,
    allowed_tools: list[str] | None = None,
    spinner_message: str = "Working…",
) -> int:
    """Run Claude CLI with the given prompt and return the exit code."""
    import subprocess
    import threading
    from rich.console import Console

    console = Console(stderr=True)

    cmd = [claude_bin, "-p", prompt, "--model", model,
           "--append-system-prompt", _AUTOMATION_SYSTEM_PROMPT]
    if allowed_tools:
        for tool in allowed_tools:
            cmd.extend(["--allowedTools", tool])

    process = subprocess.Popen(
        cmd, cwd=cwd,
        stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
    )

    stdout_lines: list[str] = []
    stderr_lines: list[str] = []

    def _drain(stream, buf):
        for line in stream:
            buf.append(line)

    t_out = threading.Thread(target=_drain, args=(process.stdout, stdout_lines), daemon=True)
    t_err = threading.Thread(target=_drain, args=(process.stderr, stderr_lines), daemon=True)
    t_out.start()
    t_err.start()

    with console.status(f"[bold cyan]{spinner_message}", spinner="dots"):
        process.wait()
        t_out.join(timeout=5)
        t_err.join(timeout=5)

    output = "".join(stdout_lines).strip()
    if output:
        from rich.markdown import Markdown
        from rich.panel import Panel

        out_console = Console()
        out_console.print()
        out_console.print(Panel(
            Markdown(output),
            border_style="cyan",
            padding=(1, 2),
        ))

    return process.returncode


def _claude_interactive(
    claude_bin: str,
    prompt: str,
    model: str,
    cwd: str,
    allowed_tools: list[str] | None = None,
    system_prompt: str = "",
) -> int:
    """Launch Claude Code interactively with an initial prompt.

    Unlike _claude_run (which uses -p for non-interactive print mode),
    this passes the prompt as a positional arg so Claude Code starts an
    interactive session seeded with the prompt. The user can then converse
    with Claude for review/approval workflows.
    """
    import subprocess
    from ._claude_helpers import ensure_mcp_json, cleanup_mcp_json

    mcp_was_new, mcp_original = ensure_mcp_json(cwd)

    cmd = [claude_bin, prompt, "--model", model]
    if system_prompt:
        cmd.extend(["--append-system-prompt", system_prompt])
    if allowed_tools:
        for tool in allowed_tools:
            cmd.extend(["--allowedTools", tool])

    try:
        result = subprocess.run(cmd, cwd=cwd)
        return result.returncode
    except KeyboardInterrupt:
        return 130
    finally:
        cleanup_mcp_json(cwd, mcp_was_new, mcp_original)


def _run_via_direct_api(prompt: str, target_path: str, spinner_message: str = "Thinking…") -> int:
    """Run a prompt via the Maeris direct API (no Claude CLI needed).

    Creates a MaerisClient, calls execute_via_direct_api with a CLI-appropriate
    system prompt and progress callback. Returns exit code (0 success / 1 failure).
    """
    from uuid import uuid4
    from rich.console import Console
    from rich.markdown import Markdown
    from rich.panel import Panel

    from maeris_mcp.maeris.client import MaerisClient

    console = Console(stderr=True)
    cmd_id = str(uuid4())
    cancel_event = threading.Event()

    lines: list[str] = []

    def on_progress(msg: str) -> None:
        click.echo(f"    {msg}")
        lines.append(msg)

    async def _run() -> tuple[bool, str]:
        from ._direct_api import execute_via_direct_api

        async with MaerisClient() as client:
            return await execute_via_direct_api(
                client,
                cmd_id,
                prompt,
                target_path,
                _AUTOMATION_SYSTEM_PROMPT,
                on_progress,
                cancel_event,
            )

    try:
        with console.status(f"[bold cyan]{spinner_message}", spinner="dots"):
            success, result_text = asyncio.run(_run())
    except Exception as exc:
        from maeris_mcp.maeris.client import AuthenticationError as _AuthErr
        if isinstance(exc, _AuthErr) or _is_token_expired(exc):
            import shutil as _shutil
            click.echo()
            click.secho("Token expired.", fg="yellow")
            maeris_bin = _shutil.which("maeris") or "maeris"
            click.echo("  Launching 'maeris auth login' — complete authentication in your browser…")
            rc = subprocess.run([maeris_bin, "auth", "login"]).returncode
            if rc == 0:
                click.secho("  OK Re-authenticated. Please re-run your command.", fg="green")
            else:
                click.secho("  FAIL Re-authentication failed. Run 'maeris auth login' manually.", fg="red")
            return 1
        click.secho(f"  FAIL {exc}", fg="red", err=True)
        return 1

    if result_text and result_text.strip():
        out_console = Console()
        out_console.print()
        out_console.print(Panel(
            Markdown(result_text),
            border_style="cyan",
            padding=(1, 2),
        ))

    return 0 if success else 1


def _require_maeris_ai() -> None:
    """Abort with a helpful error if the user is not authenticated with Maeris.

    Raises:
        SystemExit(1) with an informative message if not authenticated.
    """
    from maeris_mcp.auth.token_store import TokenStore

    if not TokenStore(config_dir=_repo_config_dir()).is_authenticated():
        click.secho("Not authenticated with Maeris.", fg="red")
        click.echo()
        click.echo("Run:")
        click.secho("  maeris auth login", bold=True)
        sys.exit(1)
