import hashlib
import json
import os
import shlex
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from harbor.agents.installed.base import BaseInstalledAgent, ExecInput
from harbor.models.agent.context import AgentContext
from harbor.models.agent.name import AgentName


class ClineCli(BaseInstalledAgent):
    """
    Cline CLI agent for Harbor.
    Based on the TerminalBench Cline agent and Harbor's Cursor CLI pattern.
    Updated for new Cline CLI from bee/cli branch.

    Supports custom builds via agent kwargs:
      --agent-kwarg tarball-url=<url>           Pre-built CLI tarball URL (from pack-cli.yml workflow)
      --agent-kwarg github-user=<username>      GitHub user/org that owns the Cline fork
      --agent-kwarg commit-hash=<ref>           Branch, tag, or commit hash (default: main)
      --agent-kwarg cline-version=<version>     npm version to install (e.g., nightly, 3.57.1)
      --agent-kwarg thinking=<tokens>           Passes --thinking <tokens> to Cline CLI
      --agent-kwarg timeout=<seconds>           Passes -t <seconds> to Cline CLI
      --agent-kwarg timeout-sec=<seconds>       Alias of timeout
      --agent-kwarg cline-timeout-sec=<seconds> Alias of timeout
      --agent-kwarg reasoning-effort=<effort>   Passes --reasoning-effort where
                                                 effort is none|low|medium|high|xhigh
      --agent-kwarg double-check-completion=<bool> Passes
                                                 --double-check-completion when true
      --agent-kwarg double-check-completions=<bool> Backward-compatible alias
      --agent-kwarg max-consecutive-mistakes=<int> Passes
                                                 --max-consecutive-mistakes <int>

    Snake_case aliases are also accepted (tarball_url, github_user, commit_hash,
    cline_version, reasoning_effort, double_check_completion, max_consecutive_mistakes,
    timeout_sec, cline_timeout_sec).

    Priority: tarball_url > github_user+commit_hash > cline@nightly

    tarball_url is an optional path for pre-built CLI installs when you have
    access to the pack-cli.yml workflow or a published tarball URL.

    When github_user is provided, the install script clones from
    github.com/<github_user>/cline and checks out <commit_hash>.
    Otherwise, it installs cline@nightly from npm (default behavior).
    """

    def __init__(
        self,
        logs_dir: Path,
        tarball_url: str | None = None,
        github_user: str | None = None,
        commit_hash: str | None = None,
        cline_version: str | None = None,
        thinking: int | str | None = None,
        timeout: int | float | str | None = None,
        timeout_sec: int | float | str | None = None,
        cline_timeout_sec: int | float | str | None = None,
        agent_timeout_sec: int | float | str | None = None,
        reasoning_effort: str | None = None,
        double_check_completion: bool | str | None = None,
        max_consecutive_mistakes: int | str | None = None,
        *args,
        **kwargs,
    ):
        # Normalize common kebab-case aliases from --agent-kwarg key=value.
        if tarball_url is None:
            tarball_url = kwargs.pop("tarball-url", None)
        else:
            kwargs.pop("tarball-url", None)
        if github_user is None:
            github_user = kwargs.pop("github-user", None)
        else:
            kwargs.pop("github-user", None)

        if commit_hash is None:
            commit_hash = kwargs.pop("commit-hash", None)
        else:
            kwargs.pop("commit-hash", None)

        if cline_version is None:
            cline_version = kwargs.pop("cline-version", None)
        else:
            kwargs.pop("cline-version", None)

        if timeout is None:
            timeout = kwargs.pop("timeout-seconds", None)
        else:
            kwargs.pop("timeout-seconds", None)

        if timeout_sec is None:
            timeout_sec = kwargs.pop("timeout-sec", None)
        else:
            kwargs.pop("timeout-sec", None)

        if cline_timeout_sec is None:
            cline_timeout_sec = kwargs.pop("cline-timeout-sec", None)
            if cline_timeout_sec is None:
                cline_timeout_sec = kwargs.pop("cline-timeout", None)
        else:
            kwargs.pop("cline-timeout-sec", None)
            kwargs.pop("cline-timeout", None)

        if reasoning_effort is None:
            reasoning_effort = kwargs.pop("reasoning-effort", None)
        else:
            kwargs.pop("reasoning-effort", None)

        if double_check_completion is None:
            double_check_completion = kwargs.pop("double-check-completion", None)
            if double_check_completion is None:
                double_check_completion = kwargs.pop("double_check_completions", None)
            if double_check_completion is None:
                double_check_completion = kwargs.pop("double-check-completions", None)
        else:
            kwargs.pop("double-check-completion", None)
            kwargs.pop("double_check_completions", None)
            kwargs.pop("double-check-completions", None)

        if max_consecutive_mistakes is None:
            max_consecutive_mistakes = kwargs.pop("max-consecutive-mistakes", None)
        else:
            kwargs.pop("max-consecutive-mistakes", None)

        super().__init__(logs_dir, *args, **kwargs)
        # Default to cline/cline repo if commit_hash is provided without github_user
        if commit_hash and not github_user:
            github_user = "cline"
        self._tarball_url = tarball_url
        self._github_user = github_user
        self._commit_hash = commit_hash or "main"
        self._cline_version = cline_version

        self._harbor_agent_timeout_sec = self._parse_timeout_seconds(
            agent_timeout_sec, field_name="agent_timeout_sec"
        )
        timeout_sources = [
            ("cline_timeout_sec", cline_timeout_sec),
            ("timeout_sec", timeout_sec),
            ("timeout", timeout),
        ]
        explicit_timeout = next(
            (value for _, value in timeout_sources if value is not None), None
        )
        if explicit_timeout is not None:
            source_name = next(
                name for name, value in timeout_sources if value is not None
            )
            self._cline_timeout_sec = self._parse_timeout_seconds(
                explicit_timeout, field_name=source_name
            )
        else:
            self._cline_timeout_sec = self._harbor_agent_timeout_sec

        self._thinking_tokens: int | None = None
        if thinking is not None:
            try:
                parsed_thinking = int(thinking)
            except (TypeError, ValueError) as exc:
                raise ValueError(
                    f"Invalid thinking value: '{thinking}'. Must be an integer token count."
                ) from exc

            if parsed_thinking < 0:
                raise ValueError(f"Invalid thinking value: '{thinking}'. Must be >= 0.")
            self._thinking_tokens = parsed_thinking

        self._reasoning_effort: str | None = None
        if reasoning_effort is not None:
            normalized_effort = reasoning_effort.strip().lower()
            valid_reasoning_efforts = {"none", "low", "medium", "high", "xhigh"}
            if normalized_effort not in valid_reasoning_efforts:
                raise ValueError(
                    "Invalid reasoning_effort value: "
                    f"'{reasoning_effort}'. Valid values: "
                    f"{', '.join(sorted(valid_reasoning_efforts))}"
                )
            self._reasoning_effort = normalized_effort

        self._double_check_completion: bool | None = None
        if double_check_completion is not None:
            if isinstance(double_check_completion, bool):
                self._double_check_completion = double_check_completion
            elif isinstance(double_check_completion, str):
                normalized_bool = double_check_completion.strip().lower()
                if normalized_bool in {"1", "true", "yes", "on"}:
                    self._double_check_completion = True
                elif normalized_bool in {"0", "false", "no", "off"}:
                    self._double_check_completion = False
                else:
                    raise ValueError(
                        "Invalid double_check_completion value: "
                        f"'{double_check_completion}'. Valid values: "
                        "true|false|1|0|yes|no|on|off"
                    )
            else:
                raise ValueError(
                    "Invalid double_check_completion value: "
                    f"'{double_check_completion}'. Must be a boolean or boolean-like string."
                )

        self._max_consecutive_mistakes: int | None = None
        if max_consecutive_mistakes is not None:
            try:
                parsed_max_consecutive_mistakes = int(max_consecutive_mistakes)
            except (TypeError, ValueError) as exc:
                raise ValueError(
                    "Invalid max_consecutive_mistakes value: "
                    f"'{max_consecutive_mistakes}'. Must be an integer."
                ) from exc

            if parsed_max_consecutive_mistakes < 0:
                raise ValueError(
                    "Invalid max_consecutive_mistakes value: "
                    f"'{max_consecutive_mistakes}'. Must be >= 0."
                )
            self._max_consecutive_mistakes = parsed_max_consecutive_mistakes

    @staticmethod
    def _parse_timeout_seconds(
        raw_timeout: int | float | str | None, field_name: str
    ) -> int | None:
        if raw_timeout is None:
            return None

        if isinstance(raw_timeout, bool):
            raise ValueError(
                f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
            )

        timeout_value: int
        if isinstance(raw_timeout, int):
            timeout_value = raw_timeout
        elif isinstance(raw_timeout, float):
            if not raw_timeout.is_integer():
                raise ValueError(
                    f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
                )
            timeout_value = int(raw_timeout)
        elif isinstance(raw_timeout, str):
            normalized_timeout = raw_timeout.strip()
            if not normalized_timeout:
                raise ValueError(
                    f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
                )
            try:
                timeout_value = int(normalized_timeout)
            except ValueError as exc:
                try:
                    timeout_float = float(normalized_timeout)
                except ValueError:
                    raise ValueError(
                        f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
                    ) from exc
                if not timeout_float.is_integer():
                    raise ValueError(
                        f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
                    ) from exc
                timeout_value = int(timeout_float)
        else:
            raise ValueError(
                f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
            )

        if timeout_value <= 0:
            raise ValueError(
                f"Invalid {field_name} value: '{raw_timeout}'. Must be > 0 seconds."
            )

        return timeout_value

    @staticmethod
    def name() -> str:
        return AgentName.CLINE_CLI.value

    @property
    def _install_agent_template_path(self) -> Path:
        return Path(__file__).parent / "install-cline.sh.j2"

    @property
    def _template_variables(self) -> dict[str, str]:
        variables = super()._template_variables
        if self._tarball_url:
            variables["tarball_url"] = self._tarball_url
        if self._github_user:
            variables["github_user"] = self._github_user
            variables["commit_hash"] = self._commit_hash
        if self._cline_version:
            variables["cline_version"] = self._cline_version
        return variables

    def _setup_env(self) -> dict[str, str]:
        setup_env = super()._setup_env()
        for token_env_var in ("GITHUB_TOKEN", "GH_TOKEN"):
            token_value = os.environ.get(token_env_var)
            if token_value:
                setup_env[token_env_var] = token_value
        return setup_env

    def populate_context_post_run(self, context: AgentContext) -> None:
        """
        Parse Cline logs and populate context.
        For now just pass - could parse Cline output for metrics later.
        """
        pass

    @staticmethod
    def _infer_task_id_from_trial_id(trial_id: str | None) -> str | None:
        """
        Trial names are typically <task_id>__<suffix>. If available, infer task_id.
        """
        if not trial_id or "__" not in trial_id:
            return None

        task_id = trial_id.split("__", 1)[0]
        return task_id or None

    def _build_pre_run_metadata(
        self,
        instruction: str,
        provider: str,
        model: str,
        cline_provider: str,
    ) -> dict[str, str | int | bool | None]:
        trial_id = os.environ.get("HARBOR_TRIAL_ID") or os.environ.get("TRIAL_ID")
        if not trial_id and self.logs_dir.name == "agent":
            trial_id = self.logs_dir.parent.name

        task_id = os.environ.get("HARBOR_TASK_ID") or os.environ.get("TASK_ID")
        if not task_id:
            task_id = self._infer_task_id_from_trial_id(trial_id)

        prompt_template_path = (
            str(self._prompt_template_path) if self._prompt_template_path else None
        )

        return {
            "schema_version": "1",
            "timestamp_utc": datetime.now(timezone.utc).isoformat(),
            "agent_name": self.name(),
            "provider": provider,
            "cline_provider": cline_provider,
            "model_id": model,
            "model_name": self.model_name,
            "github_user": self._github_user,
            "commit_hash": self._commit_hash,
            "cline_version": self._cline_version or self.version(),
            "prompt_template_path": prompt_template_path,
            "prompt_artifact_dir": "/logs/agent",
            "prompt_artifact_system_prompt_glob": "task-*.system_prompt.md",
            "prompt_artifact_manifest_glob": "task-*.manifest.json",
            "thinking_tokens": self._thinking_tokens,
            "harbor_agent_timeout_sec": self._harbor_agent_timeout_sec,
            "cline_timeout_sec": self._cline_timeout_sec,
            "reasoning_effort": self._reasoning_effort,
            "double_check_completion": self._double_check_completion,
            "max_consecutive_mistakes": self._max_consecutive_mistakes,
            "trial_id": trial_id,
            "task_id": task_id,
            "instruction_sha256": hashlib.sha256(
                instruction.encode("utf-8")
            ).hexdigest(),
        }

    def _build_register_mcp_servers_command(self) -> str | None:
        """Return a shell command that writes MCP config to ~/.cline/cline_mcp_settings.json."""
        if not self.mcp_servers:
            return None
        servers: dict[str, dict[str, Any]] = {}
        for server in self.mcp_servers:
            if server.transport == "stdio":
                servers[server.name] = {
                    "command": server.command,
                    "args": server.args,
                    "disabled": False,
                }
            else:  # sse or streamable-http
                servers[server.name] = {"url": server.url, "disabled": False}
        config = json.dumps({"mcpServers": servers}, indent=2)
        escaped = shlex.quote(config)
        return f"mkdir -p ~/.cline && echo {escaped} > ~/.cline/cline_mcp_settings.json"

    def create_run_agent_commands(self, instruction: str) -> list[ExecInput]:
        """
        Create commands to run Cline CLI in headless mode.

        Model name format: provider:model-id
        - provider: openrouter, anthropic, openai, etc.
        - model-id: the specific model (can contain slashes, colons, etc.)

        Examples:
          openrouter:anthropic/claude-opus-4.5
          anthropic:claude-sonnet-4-5:1m
          openai:gpt-4o

        Requires environment variables:
        - API_KEY: Your API key for the provider
        - BASE_URL: (optional, required for openai provider only)
        """
        escaped_instruction = shlex.quote(instruction)

        # Parse provider from model_name (format: provider:model-id)
        # Use split(":", 1) to only split on FIRST colon (model IDs can have colons too)
        if not self.model_name or ":" not in self.model_name:
            raise ValueError(
                f"model_name must be in format 'provider:model-id', got: '{self.model_name}'\n"
                f"Examples:\n"
                f"  openrouter:anthropic/claude-opus-4.5\n"
                f"  anthropic:claude-sonnet-4-5:1m\n"
                f"  openai:gpt-4o"
            )

        provider, model = self.model_name.split(":", 1)

        # Validate required environment variable
        if "API_KEY" not in os.environ:
            raise ValueError("API_KEY environment variable is required")

        api_key = os.environ["API_KEY"]

        # Validate provider (exhaustive list from Cline's mapProviderStringToEnum)
        valid_providers = [
            "anthropic",
            "openai",
            "openai-compatible",  # alias for openai
            "openai-native",
            "openrouter",
            "xai",
            "bedrock",
            "gemini",
            "ollama",
            "cerebras",
            "cline",
            "oca",
            "hicap",
            "nousresearch",
            "vercel",
            "vercel-ai-gateway",  # cline CLI name for vercel
        ]
        if provider not in valid_providers:
            raise ValueError(
                f"Invalid provider: '{provider}'. Valid values: {', '.join(valid_providers)}"
            )

        # Map provider aliases to what cline CLI expects
        provider_mapping = {
            "vercel": "vercel-ai-gateway",  # cline CLI uses vercel-ai-gateway
        }
        cline_provider = provider_mapping.get(provider, provider)

        # Build environment variables for agent execution
        env = {
            "PROVIDER": provider,
            "API_KEY": api_key,
            "MODELID": model,  # Everything after the first colon
            "CLINE_WRITE_PROMPT_ARTIFACTS": "1",
            "CLINE_PROMPT_ARTIFACT_DIR": "/logs/agent",
        }

        # Add BASE_URL for openai provider with custom endpoints
        base_url = ""
        if provider == "openai":
            if "BASE_URL" not in os.environ:
                raise ValueError(
                    "BASE_URL environment variable is required for openai provider"
                )
            env["BASE_URL"] = os.environ["BASE_URL"]
            base_url = os.environ["BASE_URL"]

        # Command 0: Write minimal globalState.json to skip welcome wizard
        setup_command = (
            "mkdir -p ~/.cline/data && "
            "cat > ~/.cline/data/globalState.json <<EOF\n"
            '{"welcomeViewCompleted": true, "isNewUser": false}\n'
            "EOF"
        )

        mcp_command = self._build_register_mcp_servers_command()
        if mcp_command:
            setup_command += f" && {mcp_command}"

        setup_config_cmd = ExecInput(
            command=setup_command,
            env=env,
        )

        pre_run_metadata = self._build_pre_run_metadata(
            instruction=instruction,
            provider=provider,
            model=model,
            cline_provider=cline_provider,
        )
        pre_run_metadata_json = json.dumps(pre_run_metadata, indent=2, sort_keys=True)

        write_pre_run_metadata_cmd = ExecInput(
            command=(
                "mkdir -p /logs/agent && "
                "cat > /logs/agent/prompt-meta.pre.json <<'EOF'\n"
                f"{pre_run_metadata_json}\n"
                "EOF"
            ),
            env=env,
        )

        # Source nvm when present, but do not require it for system-level node/npm installs.
        nvm_setup_command = (
            'export NVM_DIR="$HOME/.nvm"; '
            'if [ -s "$NVM_DIR/nvm.sh" ]; then '
            '. "$NVM_DIR/nvm.sh"; '
            "nvm use 22 >/dev/null 2>&1 || true; "
            "fi"
        )

        # Command 1: Auth and run task in a single shell command so run only executes
        # after successful auth.
        if provider == "openai" and base_url:
            auth_command = 'cline auth --provider openai --apikey "$API_KEY" --modelid "$MODELID" --baseurl "$BASE_URL"'
        else:
            auth_command = f'cline auth --provider {cline_provider} --apikey "$API_KEY" --modelid "$MODELID"'

        run_flags = ["-y", "--verbose"]
        if self._cline_timeout_sec is not None:
            run_flags.extend(["-t", str(self._cline_timeout_sec)])
        if self._thinking_tokens is not None:
            run_flags.extend(["--thinking", str(self._thinking_tokens)])
        if self._reasoning_effort is not None:
            run_flags.extend(["--reasoning-effort", self._reasoning_effort])
        if self._double_check_completion:
            run_flags.append("--double-check-completion")
        if self._max_consecutive_mistakes is not None:
            run_flags.extend(
                ["--max-consecutive-mistakes", str(self._max_consecutive_mistakes)]
            )
        run_flags_str = " ".join(run_flags)

        # Command 1: Run task with -y flag (auto-approve) and --verbose
        run_cline_cmd = ExecInput(
            command=(
                f"{nvm_setup_command}; "
                f"set -o pipefail; "
                f"{auth_command} && "
                f"cline {run_flags_str} -- {escaped_instruction} 2>&1 | "
                f"stdbuf -oL tee /logs/agent/cline.txt; "
                f"EXIT_CODE=$?; "
                f'LATEST_PROMPT="$(ls -1t /logs/agent/task-*.system_prompt.md 2>/dev/null | head -n 1)"; '
                f'if [ -n "$LATEST_PROMPT" ]; then cp "$LATEST_PROMPT" /logs/agent/system_prompt.latest.md; fi; '
                f'LATEST_MANIFEST="$(ls -1t /logs/agent/task-*.manifest.json 2>/dev/null | head -n 1)"; '
                f'if [ -n "$LATEST_MANIFEST" ]; then cp "$LATEST_MANIFEST" /logs/agent/prompt_manifest.latest.json; fi; '
                f"ls -lah /logs/agent > /logs/agent/artifacts.index.txt 2>&1 || true; "
                f"exit $EXIT_CODE"
            ),
            env=env,
        )

        return [setup_config_cmd, write_pre_run_metadata_cmd, run_cline_cmd]
