from __future__ import annotations

import asyncio
import logging
import textwrap
import typing
from pathlib import Path
from typing import Annotated, Any, Literal, cast

from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field

from tugboat.engine import analyze_yaml_stream

if typing.TYPE_CHECKING:
    from collections.abc import Iterator

    from pydantic.fields import FieldInfo

logger = logging.getLogger(__name__)
server = FastMCP("tugboat")


def _Docstring(description: str) -> FieldInfo:
    """Shortcut for creating a description-only Field annotation."""
    description = textwrap.dedent(description).strip()
    return Field(description=description)


class SuccessResult(BaseModel):
    """A report generated by the analyzer."""

    count: Annotated[
        int,
        _Docstring("The number of issues found in the manifest."),
    ]

    issues: Annotated[
        list[Issue],
        _Docstring("A list of issues found in the manifest."),
    ]


class ErrorResult(BaseModel):
    """An error result returned by the analyzer."""

    message: Annotated[
        str,
        _Docstring("A human-readable message describing the error."),
    ]


class Issue(BaseModel):
    """An issue reported by the analyzer."""

    line: Annotated[
        int,
        _Docstring(
            "Line number of the issue occurrence in the source file. The line number is cumulative across all documents in the YAML stream."
        ),
    ]

    column: Annotated[
        int,
        _Docstring("Column number of the issue occurrence in the source file."),
    ]

    sourceNearby: Annotated[
        str,
        _Docstring("Text near the issue occurrence in the source file."),
    ]

    type: Annotated[
        Literal["error", "failure", "warning"],
        _Docstring(
            """
            The type of the issue.
            * ``error`` indicates a critical issue that prevents the analyzer from running.
            * ``failure`` indicates an issue that the analyzer has detected.
            * ``warning`` indicates a potential issue that the analyzer has detected. This is not a critical issue, but it may require attention.
            """
        ),
    ]

    code: Annotated[
        str,
        _Docstring("A unique identifier representing the violated rule."),
    ]

    manifest: Annotated[
        str | None,
        _Docstring("The manifest name where the issue occurred."),
    ]

    loc: Annotated[
        tuple[str | int, ...],
        _Docstring(
            """
            A list of keys indicating the location of the issue in the manifest.
            For example, a issue found in the `spec.containers[0].name` field would have a location of `["spec", "containers", 0, "name"]`.
            """
        ),
    ]

    summary: Annotated[
        str,
        _Docstring("A short summary of the issue."),
    ]

    msg: Annotated[
        str,
        _Docstring("A human-readable message describing the issue."),
    ]

    input: Annotated[
        str | int | bool | float | Any | None,
        _Docstring("The input that caused the issue."),
    ]

    fix: Annotated[
        str | None,
        _Docstring(
            """
            A possible fix to the issue.
            This output is based on the analyzer's best guess and may not be correct.
            """
        ),
    ]


@server.tool(
    annotations=ToolAnnotations(
        title="Analyze Manifest",
        readOnlyHint=True,
        destructiveHint=False,
        openWorldHint=True,
    )
)
async def analyze_stream(
    manifest_path: Annotated[
        str,
        _Docstring(
            """
            Path to the manifest file.
            The file MUST be a valid, plain Kubernetes manifest file in YAML format.
            Any manifest template (e.g. Helm) should be pre-processed before being passed to this tool.
            """
        ),
    ],
    is_helm_template: Annotated[
        bool,
        _Docstring(
            """
            Whether the manifest file is a Helm template.
            If `true`, the tool will try to render the Helm template before analyzing it.
            If `false`, the tool will analyze the manifest file as-is.
            """
        ),
    ],
) -> SuccessResult | ErrorResult:
    """
    A linter to analyze a Argo Workflows manifest file for potential issues.

    ## Example

    Given the input manifest path `/path/to/manifest.yaml`, which contains:

    ```yaml
    apiVersion: argoproj.io/v1alpha1
    kind: Workflow
    metadata:
      generateName: demo-
    spec:
      templates:
        - name: whalesay
          inputs:
            parameters:
              - name: message
                value: Hello Argo!
          container:
            image: docker/whalesay:latest
            command: [cowsay]
            args:
              - "{{ inputs.parameter.message }}"
    ```

    This tool will analyze the manifest and return a JSON object with the following structure:

    ```json
    {"count": 2, "issues": [{"line": 6, "column": 3, "sourceNearby": "spec:\\n  templates:\\n    - name: whalesay\\n      inputs:", "type": "failure", "code": "M101", "manifest": "demo-", "loc": ["spec", "entrypoint"], "summary": "Missing required field \'entrypoint\'", "msg": "Field \'entrypoint\' is required in the \'spec\' section but missing.", "input": null, "fix": null}, {"line": 16, "column": 11, "sourceNearby": "        args:\\n          - \\"{{ inputs.parameter.message }}\\"", "type": "failure", "code": "VAR002", "manifest": "demo-", "loc": ["spec", "templates", 0, "container", "args", 0], "summary": "Invalid reference", "msg": "The parameter reference \'inputs.parameter.message\' used in template \'whalesay\' is invalid.", "input": "{{ inputs.parameter.message }}", "fix": "{{ inputs.parameters.message }}"}]}
    ```
    """
    # resolve the manifest path
    manifest = Path(manifest_path).resolve()
    logger.debug("Linting manifest %s", manifest)

    if not manifest.exists():
        return ErrorResult.model_validate(
            {
                "message": f"Manifest not found. Input path: {manifest}, resolved path: {manifest}",
            }
        )

    if not manifest.is_file():
        return ErrorResult.model_validate(
            {
                "message": f"Manifest path is not a file. Input path: {manifest}, resolved path: {manifest}",
            }
        )

    # read the manifest content
    if is_helm_template:
        # render Helm template
        try:
            manifest_content = await render_helm_template(manifest)
        except Exception as e:
            return ErrorResult.model_validate({"message": str(e)})

    else:
        # plain manifest file
        with manifest.open() as fd:
            manifest_content = fd.read()

    manifest_content_lines = manifest_content.splitlines()

    # analyze
    issues = []
    for diagnosis in analyze_yaml_stream(manifest_content, manifest):
        issue = cast("dict", diagnosis)
        line = diagnosis["line"]

        # get lines near the issue
        issue["sourceNearby"] = "\n".join(get_lines_near(manifest_content_lines, line))

        issues.append(issue)

    return SuccessResult.model_validate(
        {
            "count": len(issues),
            "issues": issues,
        }
    )


async def render_helm_template(template: Path) -> str:
    """
    Give a path to the template file.
    Returns the rendered output of a helm template command.
    """
    # locate the Helm chart root (directory containing Chart.yaml).
    chart_dir: Path | None = None
    for candidate in template.parents:
        if (candidate / "Chart.yaml").is_file():
            chart_dir = candidate
            break

    if chart_dir is None:
        raise FileNotFoundError(
            f"Could not find Chart.yaml for template path {template}"
        )

    # render the template using `helm template`
    command = [
        "helm",
        "template",
        ".",
        "--show-only",
        str(template.relative_to(chart_dir)),
    ]

    logger.debug("Executing command: $ %s", " ".join(command))

    try:
        process = await asyncio.create_subprocess_exec(
            *command,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=chart_dir,
        )
    except FileNotFoundError:
        raise RuntimeError(
            "Helm executable not found; ensure Helm is installed and in PATH."
        ) from None

    stdout, stderr = await process.communicate()

    if stderr:
        error_msg = stderr.decode("utf-8", errors="replace").strip()
        logger.warning("Helm template command stderr: %s", error_msg)

        if process.returncode != 0:
            raise RuntimeError(f"Helm template command failed: {error_msg}")

    return stdout.decode("utf-8", errors="replace")


def get_lines_near(content: list[str], focus_line: int) -> Iterator[str]:
    focus_line -= 1  # 1-based to 0-based
    line_starting = max(0, focus_line - 1)
    line_ending = min(len(content), focus_line + 2)
    yield from content[line_starting : line_ending + 1]
