"""Step definitions for diff review artifact BDD tests."""

from __future__ import annotations

import json
from typing import Any

from behave import given, then, when
from behave.runner import Context

from cleveragents.domain.models.core.change import (
    ChangeEntry,
    ChangeOperation,
    SpecChangeSet,
)
from cleveragents.domain.models.core.diff_review import (
    DiffBuilder,
    DiffSerializer,
    ReviewArtifact,
)

__all__: list[str] = []


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_entry(
    operation: str,
    path: str,
    resource_id: str,
    plan_id: str,
    *,
    before_hash: str | None = None,
    after_hash: str | None = None,
    after_mode: int | None = None,
) -> ChangeEntry:
    """Build a ChangeEntry with sensible defaults."""
    kwargs: dict[str, Any] = {
        "plan_id": plan_id,
        "resource_id": resource_id,
        "tool_name": "builtin/test-tool",
        "operation": ChangeOperation(operation),
        "path": path,
    }
    if before_hash is not None:
        kwargs["before_hash"] = before_hash
    if after_hash is not None:
        kwargs["after_hash"] = after_hash
    if after_mode is not None:
        kwargs["after_mode"] = after_mode
    return ChangeEntry(**kwargs)


# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------


@given('a plan ID "{plan_id}"')
def step_given_plan_id(context: Context, plan_id: str) -> None:
    context.plan_id = plan_id
    context.before_contents = {}  # dict[str, str]
    context.after_contents = {}  # dict[str, str]
    context.resource_names = {}  # dict[str, str]


@given("an empty changeset for the plan")
def step_given_empty_changeset(context: Context) -> None:
    context.changeset = SpecChangeSet(plan_id=context.plan_id)


# ---------------------------------------------------------------------------
# Change entry creation
# ---------------------------------------------------------------------------


@given('a change entry with operation "{op}" for path "{path}" in resource "{res}"')
def step_add_change_entry(context: Context, op: str, path: str, res: str) -> None:
    entry = _make_entry(op, path, res, context.plan_id)
    context.changeset.add_change(entry)


@given(
    'a delete entry for path "{path}" in resource "{res}" with before_hash "{bhash}"'
)
def step_add_delete_with_hash(
    context: Context, path: str, res: str, bhash: str
) -> None:
    entry = _make_entry("delete", path, res, context.plan_id, before_hash=bhash)
    context.changeset.add_change(entry)


@given('a create entry for path "{path}" in resource "{res}" with after_hash "{ahash}"')
def step_add_create_with_hash(
    context: Context, path: str, res: str, ahash: str
) -> None:
    entry = _make_entry("create", path, res, context.plan_id, after_hash=ahash)
    context.changeset.add_change(entry)


@given(
    'a hashed change entry "{op}" path "{path}" '
    'resource "{res}" before_hash "{bhash}" after_hash "{ahash}"'
)
def step_add_entry_with_hashes(
    context: Context,
    op: str,
    path: str,
    res: str,
    bhash: str,
    ahash: str,
) -> None:
    entry = _make_entry(
        op, path, res, context.plan_id, before_hash=bhash, after_hash=ahash
    )
    context.changeset.add_change(entry)


@given('a moded change entry "{op}" path "{path}" resource "{res}" after_mode {mode}')
def step_add_entry_with_mode(
    context: Context, op: str, path: str, res: str, mode: str
) -> None:
    # mode comes as e.g. "0o755" — parse it
    mode_int = int(mode, 0)
    entry = _make_entry(op, path, res, context.plan_id, after_mode=mode_int)
    context.changeset.add_change(entry)


# ---------------------------------------------------------------------------
# Content setup
# ---------------------------------------------------------------------------


@given('before content for "{path}" is "{content}"')
def step_before_content(context: Context, path: str, content: str) -> None:
    # Interpret escape sequences
    context.before_contents[path] = content.replace("\\n", "\n").replace(
        "\\x00", "\x00"
    )


@given('after content for "{path}" is "{content}"')
def step_after_content(context: Context, path: str, content: str) -> None:
    context.after_contents[path] = content.replace("\\n", "\n").replace("\\x00", "\x00")


@given('after content for "{path}" is a string of {count} characters')
def step_after_content_large(context: Context, path: str, count: str) -> None:
    context.after_contents[path] = "x" * int(count)


@given('resource name "{res_id}" is "{name}"')
def step_resource_name(context: Context, res_id: str, name: str) -> None:
    context.resource_names[res_id] = name


# ---------------------------------------------------------------------------
# Build actions
# ---------------------------------------------------------------------------


@when("I build a review artifact from the changeset")
def step_build_artifact(context: Context) -> None:
    builder = DiffBuilder()
    context.artifact = builder.build(
        context.changeset,
        before_contents=context.before_contents,
        after_contents=context.after_contents,
        resource_names=context.resource_names,
    )


@when("I build a review artifact with max diff size {size}")
def step_build_artifact_limited(context: Context, size: str) -> None:
    builder = DiffBuilder(max_diff_size=int(size))
    context.artifact = builder.build(
        context.changeset,
        before_contents=context.before_contents,
        after_contents=context.after_contents,
        resource_names=context.resource_names,
    )


# ---------------------------------------------------------------------------
# Serialization actions
# ---------------------------------------------------------------------------


@when("I serialize the artifact to plain text")
def step_serialize_plain(context: Context) -> None:
    context.plain_text = DiffSerializer.to_plain(context.artifact)


@when("I serialize the artifact to JSON")
def step_serialize_json(context: Context) -> None:
    context.json_text = DiffSerializer.to_json(context.artifact)


@when("I serialize the artifact to rich format")
def step_serialize_rich(context: Context) -> None:
    context.rich_text = DiffSerializer.to_rich(context.artifact)


# ---------------------------------------------------------------------------
# Review artifact assertions
# ---------------------------------------------------------------------------


@then("the review artifact should have {n} total changes")
def step_assert_total_changes(context: Context, n: str) -> None:
    artifact: ReviewArtifact = context.artifact
    assert artifact.total_changes == int(n), (
        f"Expected {n} total changes, got {artifact.total_changes}"
    )


@then("the review artifact should have {n} groups")
def step_assert_groups(context: Context, n: str) -> None:
    artifact: ReviewArtifact = context.artifact
    assert len(artifact.groups) == int(n), (
        f"Expected {n} groups, got {len(artifact.groups)}"
    )


@then("the review artifact should not be truncated")
def step_assert_not_truncated(context: Context) -> None:
    assert not context.artifact.truncated, "Artifact should not be truncated"


@then("the review artifact should be truncated")
def step_assert_truncated(context: Context) -> None:
    assert context.artifact.truncated, "Artifact should be truncated"


@then("the review artifact plan ID should match the changeset plan ID")
def step_plan_id_matches(context: Context) -> None:
    assert context.artifact.plan_id == context.changeset.plan_id


@then("the review artifact changeset ID should match the changeset ID")
def step_changeset_id_matches(context: Context) -> None:
    assert context.artifact.changeset_id == context.changeset.changeset_id


# ---------------------------------------------------------------------------
# Group assertions
# ---------------------------------------------------------------------------


@then("group {g} should have {n} entries")
def step_group_entries(context: Context, g: str, n: str) -> None:
    group = context.artifact.groups[int(g)]
    assert len(group.entries) == int(n), (
        f"Expected {n} entries in group {g}, got {len(group.entries)}"
    )


@then('group {g} should have resource id "{rid}"')
def step_group_resource_id(context: Context, g: str, rid: str) -> None:
    group = context.artifact.groups[int(g)]
    assert group.resource_id == rid, (
        f"Expected resource id '{rid}', got '{group.resource_id}'"
    )


@then("group {g} should have {n} added")
def step_group_added(context: Context, g: str, n: str) -> None:
    group = context.artifact.groups[int(g)]
    assert group.added == int(n), f"Expected {n} added, got {group.added}"


@then("group {g} should have {n} modified")
def step_group_modified(context: Context, g: str, n: str) -> None:
    group = context.artifact.groups[int(g)]
    assert group.modified == int(n), f"Expected {n} modified, got {group.modified}"


@then("group {g} should have {n} deleted")
def step_group_deleted(context: Context, g: str, n: str) -> None:
    group = context.artifact.groups[int(g)]
    assert group.deleted == int(n), f"Expected {n} deleted, got {group.deleted}"


@then("group {g} should have {n} renamed")
def step_group_renamed(context: Context, g: str, n: str) -> None:
    group = context.artifact.groups[int(g)]
    assert group.renamed == int(n), f"Expected {n} renamed, got {group.renamed}"


# ---------------------------------------------------------------------------
# Entry assertions
# ---------------------------------------------------------------------------


@then('entry {e} in group {g} should have change type "{ct}"')
def step_entry_change_type(context: Context, e: str, g: str, ct: str) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert entry.change_type == ct, (
        f"Expected change_type '{ct}', got '{entry.change_type}'"
    )


@then('entry {e} in group {g} should have path "{path}"')
def step_entry_path(context: Context, e: str, g: str, path: str) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert entry.path == path, f"Expected path '{path}', got '{entry.path}'"


@then("entry {e} in group {g} should be binary")
def step_entry_binary(context: Context, e: str, g: str) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert entry.is_binary, "Entry should be binary"


@then("entry {e} in group {g} should be a rename")
def step_entry_is_rename(context: Context, e: str, g: str) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert entry.is_rename, "Entry should be a rename"


@then("entry {e} in group {g} should not be a rename")
def step_entry_not_rename(context: Context, e: str, g: str) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert not entry.is_rename, "Entry should not be a rename"


@then('entry {e} in group {g} rename should be from "{src}" to "{dst}"')
def step_entry_rename_paths(
    context: Context, e: str, g: str, src: str, dst: str
) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert entry.rename_from == src, (
        f"Expected rename_from '{src}', got '{entry.rename_from}'"
    )
    assert entry.rename_to == dst, (
        f"Expected rename_to '{dst}', got '{entry.rename_to}'"
    )


@then("entry {e} in group {g} should be truncated")
def step_entry_truncated(context: Context, e: str, g: str) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert entry.truncated, "Entry should be truncated"


@then('entry {e} in group {g} should have before hash "{h}"')
def step_entry_before_hash(context: Context, e: str, g: str, h: str) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert entry.before_hash == h, (
        f"Expected before_hash '{h}', got '{entry.before_hash}'"
    )


@then('entry {e} in group {g} should have after hash "{h}"')
def step_entry_after_hash(context: Context, e: str, g: str, h: str) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert entry.after_hash == h, f"Expected after_hash '{h}', got '{entry.after_hash}'"


@then("entry {e} in group {g} should have file mode {mode}")
def step_entry_file_mode(context: Context, e: str, g: str, mode: str) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert entry.file_mode == int(mode), (
        f"Expected file_mode {mode}, got {entry.file_mode}"
    )


# ---------------------------------------------------------------------------
# Diff text assertions
# ---------------------------------------------------------------------------


@then('the diff text in entry {e} of group {g} should contain "{text}"')
def step_diff_contains(context: Context, e: str, g: str, text: str) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert text in entry.diff_text, (
        f"Expected diff_text to contain '{text}', got: {entry.diff_text!r}"
    )


@then("the diff text in entry {e} of group {g} should be empty")
def step_diff_empty(context: Context, e: str, g: str) -> None:
    entry = context.artifact.groups[int(g)].entries[int(e)]
    assert entry.diff_text == "", f"Expected empty diff_text, got: {entry.diff_text!r}"


# ---------------------------------------------------------------------------
# Serialization assertions
# ---------------------------------------------------------------------------


@then('the plain text should contain "{text}"')
def step_plain_contains(context: Context, text: str) -> None:
    assert text in context.plain_text, f"Expected plain text to contain '{text}'"


@then("the JSON output should be valid JSON")
def step_json_valid(context: Context) -> None:
    try:
        json.loads(context.json_text)
    except json.JSONDecodeError as exc:
        raise AssertionError(f"Invalid JSON: {exc}") from exc


@then('the JSON output should contain key "{key}"')
def step_json_has_key(context: Context, key: str) -> None:
    data = json.loads(context.json_text)
    assert key in data, f"Expected key '{key}' in JSON output"


@then('the rich text should contain "{text}"')
def step_rich_contains(context: Context, text: str) -> None:
    assert text in context.rich_text, f"Expected rich text to contain '{text}'"


@then('the JSON output should have entry with before hash "{h}"')
def step_json_entry_before_hash(context: Context, h: str) -> None:
    data = json.loads(context.json_text)
    for group in data["groups"]:
        for entry in group["entries"]:
            if entry.get("before_hash") == h:
                return
    raise AssertionError(f"No entry with before_hash '{h}' in JSON output")


@then('the JSON output should have entry with after hash "{h}"')
def step_json_entry_after_hash(context: Context, h: str) -> None:
    data = json.loads(context.json_text)
    for group in data["groups"]:
        for entry in group["entries"]:
            if entry.get("after_hash") == h:
                return
    raise AssertionError(f"No entry with after_hash '{h}' in JSON output")


@then("the JSON output should have entry with file mode")
def step_json_entry_file_mode(context: Context) -> None:
    data = json.loads(context.json_text)
    for group in data["groups"]:
        for entry in group["entries"]:
            if "file_mode" in entry:
                return
    raise AssertionError("No entry with file_mode in JSON output")


@then('the JSON output should have entry with rename from "{path}"')
def step_json_entry_rename_from(context: Context, path: str) -> None:
    data = json.loads(context.json_text)
    for group in data["groups"]:
        for entry in group["entries"]:
            if entry.get("rename_from") == path:
                return
    raise AssertionError(f"No entry with rename_from '{path}' in JSON output")
