"""Authoritative rule for editing an issue's title and body (#781).

The workflow documented a ``gitea_edit_issue`` tool that was never registered,
so an authorized body correction on an issue had no sanctioned path at all: the
only edit tool, ``gitea_edit_pr``, PATCHes the pull-request endpoint and cannot
target an issue. This module is the rule that path is built on, kept separate
from the pull-request edit path by construction.

- :func:`validate_edit_request` rejects structurally invalid requests before any
  credential, network, or profile work happens. A request that names no field,
  or names one with the wrong type, is a pure input error.
- :func:`assess_issue_target` refuses a pull request. Gitea serves pull requests
  from the same ``/issues/{n}`` collection, so without this check the issue edit
  path would quietly become a second, ungated PR edit path.
- :func:`plan_issue_edit` decides the exact PATCH payload from the pre-image. It
  only ever sends fields the caller named, and it reports a request that would
  change nothing as an explicit no-op rather than a silent success.
- :func:`verify_issue_edit` is the read-after-write check. It proves the applied
  title/body match what was requested *and* that every field the caller did not
  name — state, labels, assignees, milestone — is unchanged.

This module performs no I/O — callers own the Gitea API calls.
"""

from __future__ import annotations

from typing import Any, Mapping

import issue_workflow_labels

#: Fields this tool is allowed to change. Anything else must be untouched.
EDITABLE_FIELDS: tuple[str, ...] = ("title", "body")

#: Fields the caller never names and which must survive an edit verbatim.
PRESERVED_FIELDS: tuple[str, ...] = ("state", "labels", "assignees", "milestone")


def validate_edit_request(
    title: str | None = None,
    body: str | None = None,
) -> dict[str, str]:
    """Return the requested field map, failing closed on an invalid request.

    Raises ``ValueError`` when no field is named, when a named field is not a
    string, or when a title is blank. An empty *body* is legitimate — clearing
    an issue description is a real edit — but an empty title is not, because
    Gitea has no issue without one.
    """
    requested: dict[str, str] = {}

    if title is not None:
        if not isinstance(title, str):
            raise ValueError(
                f"Invalid title type {type(title).__name__}: title must be a "
                "string (fail closed)."
            )
        if not title.strip():
            raise ValueError(
                "Invalid title: an issue title cannot be blank. Pass the exact "
                "replacement title, or omit title= to leave it unchanged "
                "(fail closed)."
            )
        requested["title"] = title

    if body is not None:
        if not isinstance(body, str):
            raise ValueError(
                f"Invalid body type {type(body).__name__}: body must be a "
                "string (fail closed)."
            )
        requested["body"] = body

    if not requested:
        raise ValueError(
            "At least one field to edit (title, body) must be provided. "
            "gitea_edit_issue never edits state, labels, assignees, or "
            "milestone (fail closed)."
        )

    return requested


def assess_issue_target(
    issue: Mapping[str, Any],
    *,
    issue_number: int,
) -> dict[str, Any]:
    """Confirm the fetched object is an issue and not a pull request.

    Gitea serves pull requests from ``/issues/{n}`` as well, so a PR number
    reaches this path unchallenged. Issue and pull-request edits stay separate
    capabilities, so a PR target is refused here rather than silently PATCHed.
    """
    is_pull_request = bool(issue.get("pull_request"))
    return {
        "is_issue": not is_pull_request,
        "is_pull_request": is_pull_request,
        "reasons": (
            [
                f"#{issue_number} is a pull request, not an issue; "
                "gitea_edit_issue never edits pull requests"
            ]
            if is_pull_request
            else []
        ),
        "safe_next_action": (
            f"Use gitea_edit_pr for pull request #{issue_number}."
            if is_pull_request
            else ""
        ),
    }


def preserved_snapshot(issue: Mapping[str, Any]) -> dict[str, Any]:
    """Capture the fields an edit must leave alone, in a comparable shape."""
    return {
        "state": issue.get("state"),
        "labels": issue_workflow_labels.label_names(issue),
        "assignees": _assignee_names(issue),
        "milestone": _milestone_key(issue),
    }


def _assignee_names(issue: Mapping[str, Any]) -> list[str]:
    names: list[str] = []
    for entry in issue.get("assignees") or []:
        if isinstance(entry, Mapping):
            login = entry.get("login") or entry.get("username")
        else:
            login = entry
        if login:
            names.append(str(login))
    return names


def _milestone_key(issue: Mapping[str, Any]) -> str | None:
    milestone = issue.get("milestone")
    if not milestone:
        return None
    if isinstance(milestone, Mapping):
        key = milestone.get("title") or milestone.get("id")
        return None if key is None else str(key)
    return str(milestone)


def plan_issue_edit(
    current: Mapping[str, Any],
    *,
    title: str | None = None,
    body: str | None = None,
    issue_number: int | None = None,
) -> dict[str, Any]:
    """Plan the PATCH payload for an issue edit against its pre-image.

    Only fields the caller named are ever put in the payload, so unspecified
    fields cannot be overwritten with a stale read. A request whose named fields
    already hold the requested values is reported as a no-op with an actionable
    reason instead of being sent and reported as a success.
    """
    requested = validate_edit_request(title=title, body=body)
    number = issue_number if issue_number is not None else current.get("number")

    changes: dict[str, dict[str, Any]] = {}
    unchanged: list[str] = []
    for field, value in requested.items():
        before = current.get(field)
        if field == "body":
            before = before or ""
        if before == value:
            unchanged.append(field)
        else:
            changes[field] = {"before": before, "after": value}

    no_op = not changes
    payload = {field: requested[field] for field in changes}

    return {
        "issue_number": number,
        "requested_fields": sorted(requested),
        "requested": dict(requested),
        "payload": payload,
        "changes": changes,
        "unchanged_fields": sorted(unchanged),
        "no_op": no_op,
        "preserved_before": preserved_snapshot(current),
        "reasons": (
            [
                "requested "
                + ", ".join(sorted(unchanged))
                + " already match the issue's current content; no edit was sent"
            ]
            if no_op
            else []
        ),
        "safe_next_action": (
            (
                f"Re-read issue #{number} and call gitea_edit_issue only with "
                "content that differs, or drop the call if the issue is already "
                "correct."
            )
            if no_op
            else ""
        ),
    }


def verify_issue_edit(
    observed: Mapping[str, Any],
    *,
    plan: Mapping[str, Any],
) -> dict[str, Any]:
    """Read-after-write proof for an applied issue edit.

    Fails closed on two distinct defects: an edited field whose stored value is
    not what was requested, and an untouched field that moved anyway.
    """
    requested = dict(plan.get("requested") or {})
    number = plan.get("issue_number")

    applied: dict[str, Any] = {}
    mismatches: list[dict[str, Any]] = []
    for field, expected in requested.items():
        actual = observed.get(field)
        if field == "body":
            actual = actual or ""
        applied[field] = actual
        if actual != expected:
            mismatches.append(
                {"field": field, "expected": expected, "observed": actual}
            )

    before = dict(plan.get("preserved_before") or {})
    after = preserved_snapshot(observed)
    preserved_changed: list[dict[str, Any]] = [
        {"field": field, "before": before.get(field), "after": after.get(field)}
        for field in PRESERVED_FIELDS
        if before.get(field) != after.get(field)
    ]

    reasons: list[str] = []
    for entry in mismatches:
        reasons.append(
            f"{entry['field']} was not applied: requested "
            f"{entry['expected']!r} but the issue stores {entry['observed']!r}"
        )
    for entry in preserved_changed:
        reasons.append(
            f"{entry['field']} changed during the edit: {entry['before']!r} "
            f"became {entry['after']!r}; gitea_edit_issue must leave it alone"
        )

    verified = not reasons
    return {
        "verified": verified,
        "applied": applied,
        "mismatches": mismatches,
        "preserved_before": before,
        "preserved_after": after,
        "preserved_changed": preserved_changed,
        "preserved_intact": not preserved_changed,
        "reasons": reasons,
        "safe_next_action": (
            ""
            if verified
            else (
                f"Re-read issue #{number} with gitea_view_issue and reconcile it "
                "before treating the edit as applied. Do not retry blindly — the "
                "stored content does not match what was requested."
            )
        ),
    }
