import json
import logging
import re
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple

import click
from pydantic import ConfigDict, Field

from ..client import (
    EvaluationMetric,
    EvaluationMetricResult,
    EvaluationType,
    HLJSONEncoder,
)
from ..client.evaluation import (
    EvaluationMetricCodeEnum,
    create_evaluation_metric,
    create_evaluation_metric_result,
    find_or_create_evaluation_metric,
    get_existing_evaluation_metrics,
)
from ..client.evaluation import iter_evaluations as query_list_evaluations
from ..client.gql_base import get_all_mutations
from ..core import GQLBaseModel


class EvaluationMutationPayload(GQLBaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid", populate_by_name=True)

    errors: List[str]
    evaluation: Optional[EvaluationType] = Field(default=None, validation_alias="evaluation")


class DeleteEvaluationPayload(GQLBaseModel):
    errors: List[str]
    evaluation: Optional[EvaluationType] = None


class UpdateMetricPayload(GQLBaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid", populate_by_name=True)

    errors: List[str]
    metric: Optional[EvaluationMetric] = Field(default=None, validation_alias="evaluation_metric")


class ExperimentResultMutationPayload(GQLBaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid", populate_by_name=True)

    errors: List[str]
    result: Optional[dict] = Field(default=None, validation_alias="experiment_result")


class ExperimentResultsQueryPayload(GQLBaseModel):
    experiment_results: List[dict] = Field(default_factory=list)


def _print_json(payload) -> None:
    click.echo(json.dumps(payload, cls=HLJSONEncoder))


def _emit_mutation_result(payload: GQLBaseModel, failure_message: str) -> None:
    result = payload.gql_dict()
    if result.get("errors"):
        raise click.ClickException(failure_message)
    _print_json(result)


def _resolve_metric_id_from_lookup(client, evaluation_id: int, metric_name: str) -> int:
    existing_metrics = {
        metric.name: metric for metric in get_existing_evaluation_metrics(client, evaluation_id)
    }
    metric = existing_metrics.get(metric_name)
    if metric is None:
        raise click.ClickException(f"Could not find metric '{metric_name}' in evaluation {evaluation_id}")
    return metric.id


def _warn_deprecated(command: str, replacement: str) -> None:
    click.echo(f"Deprecated: '{command}' will be removed. Use '{replacement}'.", err=True)


def _heading_to_field_name(heading: str) -> Optional[str]:
    normalized = re.sub(r"[\s_-]+", " ", heading.strip().lower())
    heading_map = {
        "title": "title",
        "description": "description",
        "objective": "objective",
        "evaluation process": "evaluation_process",
    }
    return heading_map.get(normalized)


_MARKDOWN_FIELDS = ("title", "description", "objective", "evaluation_process")


def _merge_with_markdown(explicit: dict, markdown_file: Optional[str]) -> dict:
    """Merge explicit CLI values with markdown fallback. Explicit values take precedence."""
    markdown_values = _parse_evaluation_markdown(markdown_file)
    return {
        field: explicit.get(field) if explicit.get(field) is not None else markdown_values.get(field)
        for field in _MARKDOWN_FIELDS
    }


def _parse_evaluation_markdown(markdown_file: Optional[str]) -> dict:
    if markdown_file is None:
        return {}

    content = Path(markdown_file).read_text()
    values = {}
    current_field = None
    buffer: List[str] = []

    def flush():
        if current_field is None:
            return
        section_text = "\n".join(buffer).strip()
        if section_text:
            values[current_field] = section_text

    for line in content.splitlines():
        heading_match = re.match(r"^(#{1,6})\s+(.*\S)\s*$", line)
        if heading_match:
            level = len(heading_match.group(1))
            heading_text = heading_match.group(2).strip()
            if level == 1:
                flush()
                current_field = _heading_to_field_name(heading_text)
                buffer = []
                continue
        if current_field is not None:
            buffer.append(line)

    flush()
    return values


@click.group("evaluation")
@click.pass_context
def evaluation_group(ctx):
    """Manage evaluations, metrics and results.

    \b
    Common workflows:
      hl evaluation create --title "Eval A" --assigned-to-id 12
      hl evaluation metric create --evaluation-id 42 --code mAP --name "mAP@50"
      hl evaluation result create --metric-id 101 --value 0.87 --training-run-id 555
    """
    pass


@evaluation_group.command("list")
@click.pass_context
def list_evaluations(ctx):
    """List evaluations.

    \b
    Example:
      hl evaluation list
    """
    client = ctx.obj["client"]
    result = list(query_list_evaluations(client))
    _print_json([item.gql_dict() for item in result])


@evaluation_group.command("read")
@click.option("-i", "--id", "evaluation_id", type=int, required=True)
@click.pass_context
def read_evaluation(ctx, evaluation_id):
    """Read an evaluation by ID.

    \b
    Example:
      hl evaluation read --id 42
    """
    client = ctx.obj["client"]
    result = client.evaluation(return_type=EvaluationType, id=evaluation_id)
    if result is None:
        raise click.ClickException(f"Failed to find evaluation with id {evaluation_id}")
    _print_json(result.gql_dict())


@evaluation_group.command("create")
@click.option("-t", "--title", type=str, required=False)
@click.option("-a", "--assigned-to-id", type=int, required=True)
@click.option("-d", "--description", type=str, required=False)
@click.option("-o", "--objective", type=str, required=False)
@click.option("-e", "--evaluation-process", type=str, required=False)
@click.option("--markdown-file", type=click.Path(exists=True, dir_okay=False, readable=True), required=False)
@click.pass_context
def create_evaluation(ctx, title, assigned_to_id, description, objective, evaluation_process, markdown_file):
    """Create an evaluation.

    \b
    Example:
      hl evaluation create --title "Baseline Eval" --assigned-to-id 12 --description "Initial benchmark" --objective "Compare baseline quality" --evaluation-process "Run metrics and review errors"
      hl evaluation create --assigned-to-id 12 --markdown-file evaluation.md
    """
    client = ctx.obj["client"]
    fields = _merge_with_markdown(
        {
            "title": title,
            "description": description,
            "objective": objective,
            "evaluation_process": evaluation_process,
        },
        markdown_file,
    )

    if fields["title"] is None:
        raise click.ClickException(
            "Title is required. Provide --title or a '# Title' section in --markdown-file."
        )
    if fields["description"] is None:
        raise click.ClickException(
            "Description is required. Provide --description or a '# Description' section in --markdown-file."
        )
    if fields["objective"] is None:
        raise click.ClickException(
            "Objective is required. Provide --objective or a '# Objective' section in --markdown-file."
        )
    if fields["evaluation_process"] is None:
        raise click.ClickException(
            "Evaluation process is required. Provide --evaluation-process or a '# Evaluation Process' section in --markdown-file."
        )

    mutation_result = client.createEvaluation(
        return_type=EvaluationMutationPayload,
        title=fields["title"],
        assignedToId=assigned_to_id,
        description=fields["description"],
        objective=fields["objective"],
        evaluationProcess=fields["evaluation_process"],
    )
    _emit_mutation_result(mutation_result, "Evaluation creation failed")


@evaluation_group.command("update")
@click.option("-i", "--id", "evaluation_id", type=int, required=True)
@click.option("-t", "--title", type=str, required=False)
@click.option("-a", "--assigned-to-id", type=int, required=False)
@click.option("-d", "--description", type=str, required=False)
@click.option("-o", "--objective", type=str, required=False)
@click.option("-e", "--evaluation-process", type=str, required=False)
@click.option("--markdown-file", type=click.Path(exists=True, dir_okay=False, readable=True), required=False)
@click.pass_context
def update_evaluation(
    ctx,
    evaluation_id,
    title,
    assigned_to_id,
    description,
    objective,
    evaluation_process,
    markdown_file,
):
    """Update an evaluation.

    \b
    Example:
      hl evaluation update --id 42 --title "Eval v2" --objective "Compare against baseline"
      hl evaluation update --id 42 --markdown-file evaluation.md
    """
    client = ctx.obj["client"]
    fields = _merge_with_markdown(
        {
            "title": title,
            "description": description,
            "objective": objective,
            "evaluation_process": evaluation_process,
        },
        markdown_file,
    )

    kwargs = {
        "title": fields["title"],
        "assignedToId": assigned_to_id,
        "description": fields["description"],
        "objective": fields["objective"],
        "evaluationProcess": fields["evaluation_process"],
    }
    kwargs = {k: v for k, v in kwargs.items() if v is not None}
    if not kwargs:
        raise click.ClickException("No fields provided to update")

    mutation_result = client.updateEvaluation(
        return_type=EvaluationMutationPayload,
        id=evaluation_id,
        **kwargs,
    )
    _emit_mutation_result(mutation_result, "Evaluation update failed")


@evaluation_group.command("delete")
@click.option("-i", "--id", "evaluation_id", type=int, required=True)
@click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt")
@click.pass_context
def delete_evaluation(ctx, evaluation_id, yes):
    """Delete an evaluation if supported by the server schema.

    \b
    Example:
      hl evaluation delete --id 42
      hl evaluation delete --id 42 -y
    """
    if not yes:
        click.confirm(f"Are you sure you want to delete evaluation {evaluation_id}?", abort=True)

    client = ctx.obj["client"]
    if "deleteEvaluation" not in get_all_mutations(client.schema):
        raise click.ClickException(
            "deleteEvaluation is not available on this server. Upgrade backend or use web UI/manual workflow."
        )

    mutation_result = client.deleteEvaluation(return_type=DeleteEvaluationPayload, id=evaluation_id)
    payload = mutation_result.gql_dict()
    if payload.get("errors") or payload.get("evaluation") is None:
        raise click.ClickException("Evaluation delete failed")
    _print_json(payload)


@evaluation_group.group("metric")
@click.pass_context
def metric_group(ctx):
    """Manage evaluation metrics.

    \b
    Examples:
      hl evaluation metric list --evaluation-id 42
      hl evaluation metric create --evaluation-id 42 --code mAP --name "mAP@50"
    """
    pass


@metric_group.command("list")
@click.option("--evaluation-id", type=int, required=True)
@click.option("--name", type=str, required=False)
@click.pass_context
def list_metrics(ctx, evaluation_id, name):
    """List metrics for an evaluation.

    \b
    Examples:
      hl evaluation metric list --evaluation-id 42
      hl evaluation metric list --evaluation-id 42 --name "mAP@50"
    """
    metrics = get_existing_evaluation_metrics(ctx.obj["client"], evaluation_id)
    if name is not None:
        metrics = [metric for metric in metrics if metric.name == name]
    _print_json([metric.gql_dict() for metric in metrics])


@metric_group.command("read")
@click.option("--evaluation-id", type=int, required=True)
@click.option("-i", "--id", "metric_id", type=int, required=False)
@click.option("--name", type=str, required=False)
@click.pass_context
def read_metric(ctx, evaluation_id, metric_id, name):
    """Read a metric by ID or name within an evaluation.

    \b
    Examples:
      hl evaluation metric read --evaluation-id 42 --id 101
      hl evaluation metric read --evaluation-id 42 --name "mAP@50"
    """
    if metric_id is None and name is None:
        raise click.ClickException("Provide either --id or --name")
    metrics = get_existing_evaluation_metrics(ctx.obj["client"], evaluation_id)
    metric = None
    if metric_id is not None:
        metric = next((item for item in metrics if item.id == metric_id), None)
    elif name is not None:
        metric = next((item for item in metrics if item.name == name), None)
    if metric is None:
        raise click.ClickException("Metric not found")
    _print_json(metric.gql_dict())


@metric_group.command("create")
@click.option("--evaluation-id", type=int, required=True)
@click.option(
    "--code",
    type=click.Choice(tuple(EvaluationMetricCodeEnum.__members__.values())),
    required=True,
)
@click.option("--name", type=str, required=True)
@click.option("--description", "-d", type=str, default=None)
@click.option("--iou", type=click.FloatRange(min=0.0, max=1.0), default=None)
@click.option("--weighted/--unweighted", default=False)
@click.option("--object-class-uuid", "-o", type=str, default=None)
@click.pass_context
def create_metric(ctx, evaluation_id, code, name, description, iou, weighted, object_class_uuid):
    """Create an evaluation metric.

    \b
    Example:
      hl evaluation metric create --evaluation-id 42 --code Dice --name "Dice score" --weighted
    """
    metric = create_evaluation_metric(
        ctx.obj["client"],
        evaluation_id=evaluation_id,
        code=code,
        name=name,
        description=description,
        iou=iou,
        weighted=weighted,
        object_class_uuid=object_class_uuid,
    )
    _print_json(metric.gql_dict())


@metric_group.command("update")
@click.option("-i", "--id", "metric_id", type=int, required=True)
@click.option("--evaluation-id", type=int, required=False)
@click.option(
    "--code",
    type=click.Choice(tuple(EvaluationMetricCodeEnum.__members__.values())),
    required=False,
)
@click.option("--name", type=str, required=False)
@click.option("--description", "-d", type=str, default=None)
@click.option("--iou", type=click.FloatRange(min=0.0, max=1.0), default=None)
@click.option("--weighted/--unweighted", default=None)
@click.option("--object-class-uuid", "-o", type=str, default=None)
@click.pass_context
def update_metric(ctx, metric_id, evaluation_id, code, name, description, iou, weighted, object_class_uuid):
    """Update an evaluation metric.

    \b
    Example:
      hl evaluation metric update --id 101 --code mAP --name "mAP@50" --description "Updated label"
    """
    kwargs = {
        "evaluationId": evaluation_id,
        "code": code,
        "name": name,
        "description": description,
        "iou": iou,
        "weighted": weighted,
        "objectClassUuid": object_class_uuid,
    }
    kwargs = {k: v for k, v in kwargs.items() if v is not None}
    if not kwargs:
        raise click.ClickException("No fields provided to update")

    mutation_result = ctx.obj["client"].updateEvaluationMetric(
        return_type=UpdateMetricPayload,
        id=metric_id,
        **kwargs,
    )
    _emit_mutation_result(mutation_result, "Metric update failed")


@metric_group.command("delete", hidden=True)
def delete_metric():
    """Delete an evaluation metric (not yet supported by the GraphQL API)."""
    raise click.ClickException("Metric delete is not currently supported by the GraphQL API")


@evaluation_group.group("result")
@click.pass_context
def result_group(ctx):
    """Manage evaluation results.

    \b
    Examples:
      hl evaluation result create --metric-id 101 --value 0.91
      hl evaluation result update --id 55 --evaluation-id 42 --metric-id 101 --experiment-id 7 --occurred-at 2026-01-01T00:00:00
    """
    pass


@result_group.command("list")
@click.option("--experiment-id", type=int, required=True)
@click.pass_context
def list_results(ctx, experiment_id):
    """List results for an experiment.

    \b
    Example:
      hl evaluation result list --experiment-id 7
    """
    experiment = ctx.obj["client"].experiment(return_type=ExperimentResultsQueryPayload, id=experiment_id)
    if experiment is None:
        raise click.ClickException(f"Failed to find experiment with id {experiment_id}")
    _print_json(experiment.gql_dict().get("experimentResults", []))


@result_group.command("read")
@click.option("--experiment-id", type=int, required=True)
@click.option("-i", "--id", "result_id", type=int, required=True)
@click.pass_context
def read_result(ctx, experiment_id, result_id):
    """Read a single result by ID within an experiment.

    \b
    Example:
      hl evaluation result read --experiment-id 7 --id 55
    """
    experiment = ctx.obj["client"].experiment(return_type=ExperimentResultsQueryPayload, id=experiment_id)
    if experiment is None:
        raise click.ClickException(f"Failed to find experiment with id {experiment_id}")
    results = experiment.gql_dict().get("experimentResults", [])
    result = next((item for item in results if int(item.get("id")) == result_id), None)
    if result is None:
        raise click.ClickException(f"Result {result_id} not found for experiment {experiment_id}")
    _print_json(result)


@result_group.command("create")
@click.option("--metric-id", type=int, required=True)
@click.option("--value", type=float, required=True)
@click.option("--occurred-at", type=click.DateTime(), default=None)
@click.option("--occured-at", type=click.DateTime(), default=None, hidden=True)
@click.option("--object-class-uuid", "-o", type=str, default=None)
@click.option("--training-run-id", "-t", type=int, default=None)
@click.pass_context
def create_result(
    ctx,
    metric_id,
    value,
    occurred_at,
    occured_at,
    object_class_uuid,
    training_run_id,
):
    """Create an evaluation result.

    \b
    Examples:
      hl evaluation result create --metric-id 101 --value 0.91
      hl evaluation result create --metric-id 101 --value 0.91 --occurred-at 2026-01-01T00:00:00
    """
    timestamp = occurred_at or occured_at
    metric_result = create_evaluation_metric_result(
        ctx.obj["client"],
        metric_id,
        value,
        occured_at=timestamp,
        object_class_uuid=object_class_uuid,
        training_run_id=training_run_id,
    )
    _print_json(metric_result.gql_dict())


@result_group.command("update")
@click.option("-i", "--id", "result_id", type=int, required=True)
@click.option("--evaluation-id", type=int, required=False)
@click.option("--metric-id", type=int, required=False)
@click.option("--experiment-id", type=int, required=False)
@click.option("--occurred-at", type=click.DateTime(), required=False)
@click.option("--value", type=float, required=False)
@click.option("--training-run-id", type=int, required=False)
@click.pass_context
def update_result(
    ctx, result_id, evaluation_id, metric_id, experiment_id, occurred_at, value, training_run_id
):
    """Update an evaluation result.

    \b
    Example:
      hl evaluation result update --id 55 --value 0.93
      hl evaluation result update --id 55 --evaluation-id 42 --metric-id 101 --experiment-id 7 --occurred-at 2026-01-01T00:00:00 --value 0.93
    """
    kwargs = {
        "evaluationId": evaluation_id,
        "evaluationMetricId": metric_id,
        "experimentId": experiment_id,
        "occuredAt": occurred_at,
        "result": value,
        "trainingRunId": training_run_id,
    }
    kwargs = {k: v for k, v in kwargs.items() if v is not None}
    if not kwargs:
        raise click.ClickException("No fields provided to update")

    mutation_result = ctx.obj["client"].updateExperimentResult(
        return_type=ExperimentResultMutationPayload,
        id=result_id,
        **kwargs,
    )
    _emit_mutation_result(mutation_result, "Result update failed")


@result_group.command("delete")
@click.option("-i", "--id", "result_id", type=int, required=True)
@click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt")
@click.pass_context
def delete_result(ctx, result_id, yes):
    """Delete an evaluation result.

    \b
    Example:
      hl evaluation result delete --id 55
      hl evaluation result delete --id 55 -y
    """
    if not yes:
        click.confirm(f"Are you sure you want to delete result {result_id}?", abort=True)

    mutation_result = ctx.obj["client"].deleteExperimentResult(
        return_type=ExperimentResultMutationPayload, id=result_id
    )
    _emit_mutation_result(mutation_result, "Result delete failed")


@evaluation_group.command("create-metric")
@click.argument("evaluation_id", type=int)
@click.argument(
    "code",
    type=click.Choice(tuple(EvaluationMetricCodeEnum.__members__.values())),
)
@click.argument("metric_name", type=str)
@click.option("--description", "-d", type=str, default=None)
@click.option("--iou", type=click.FloatRange(min=0.0, max=1.0), default=None)
@click.option("--weighted", type=bool, default=False)
@click.option("--object-class-uuid", "-o", type=str, default=None)
@click.pass_context
def create_metric_legacy(
    ctx, evaluation_id, code, metric_name, description, iou, weighted, object_class_uuid
):
    """Deprecated alias for `evaluation metric create`."""
    _warn_deprecated("hl evaluation create-metric", "hl evaluation metric create")
    metric = create_evaluation_metric(
        ctx.obj["client"],
        evaluation_id=evaluation_id,
        code=code,
        name=metric_name,
        description=description,
        iou=iou,
        weighted=weighted,
        object_class_uuid=object_class_uuid,
    )
    _print_json(metric.gql_dict())


def create_metric(ctx, evaluation_id, code, metric_name, description, iou, weighted, object_class_uuid):
    """Create an evaluation metric record

    \b
    Args:
        evaluation_id: https://...highlighter.ai/evaluations/<EVALUATION_ID>
        metric_name: Human readable name
        code: Select from common metrics or use 'Other'

    \b
    Example:
        hl evaluation create-metric -d 'useful description' 123 Other foo
    """

    evaluation_metric: EvaluationMetric
    evaluation_metric = find_or_create_evaluation_metric(
        ctx.obj["client"],
        EvaluationMetric(
            evaluation_id=evaluation_id,
            code=code,
            description=description,
            iou=iou,
            name=metric_name,
            object_class_uuid=object_class_uuid,
            weighted=weighted,
        ),
    )[0]
    click.echo(json.dumps(evaluation_metric.model_dump(), cls=HLJSONEncoder))


@evaluation_group.command("create-result")
@click.argument("evaluation_metric_id", type=int)
@click.argument("result", type=float)
@click.option("--occured-at", type=click.DateTime(), default=None)
@click.option("--object-class-uuid", "-o", type=str, default=None)
@click.option("--training-run-id", "-t", type=int, default=None)
@click.option(
    "--lookup",
    "-l",
    type=click.Tuple((int, str)),
    default=None,
    help="Lookup metric ID by (EVALUATION_ID, METRIC_NAME).",
)
@click.pass_context
def create_result_legacy(
    ctx,
    evaluation_metric_id: int,
    result: float,
    occured_at: Optional[datetime],
    object_class_uuid: Optional[str],
    training_run_id: Optional[int],
    lookup: Optional[Tuple[int, str]],
):
    """Deprecated alias for `evaluation result create`."""
    _warn_deprecated("hl evaluation create-result", "hl evaluation result create")

    metric_id = evaluation_metric_id
    if metric_id == 0:
        if lookup is None:
            raise click.ClickException("To use metric lookup, provide --lookup EVALUATION_ID METRIC_NAME")
        evaluation_id, metric_name = lookup
        metric_id = _resolve_metric_id_from_lookup(ctx.obj["client"], evaluation_id, metric_name)

    metric_result = create_evaluation_metric_result(
        ctx.obj["client"],
        metric_id,
        result,
        occured_at=occured_at,
        object_class_uuid=object_class_uuid,
        training_run_id=training_run_id,
    )
    _print_json(metric_result.gql_dict())
