use crate::task_executor::pr_detection::{
    build_fix_ci_prompt, build_pr_approved_prompt, parse_harness_mention_command,
    HarnessMentionCommand,
};
use crate::task_runner::CreateTaskRequest;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct GitHubRepositoryRef {
    full_name: String,
}

#[derive(Debug, Deserialize)]
struct GitHubLabelRef {
    #[serde(default)]
    name: String,
}

#[derive(Debug, Deserialize)]
struct GitHubIssueRef {
    number: u64,
    #[serde(default)]
    body: Option<String>,
    #[serde(default)]
    html_url: Option<String>,
    #[serde(default)]
    pull_request: Option<serde_json::Value>,
    #[serde(default)]
    labels: Vec<GitHubLabelRef>,
}

impl GitHubIssueRef {
    fn has_label(&self, label: &str) -> bool {
        self.labels.iter().any(|l| l.name == label)
    }
}

#[derive(Debug, Deserialize)]
struct GitHubCommentRef {
    body: String,
    #[serde(default)]
    html_url: Option<String>,
}

#[derive(Debug, Deserialize)]
struct GitHubIssueCommentEvent {
    action: String,
    issue: GitHubIssueRef,
    comment: GitHubCommentRef,
    repository: GitHubRepositoryRef,
}

#[derive(Debug, Deserialize)]
struct GitHubIssuesEvent {
    action: String,
    issue: GitHubIssueRef,
    repository: GitHubRepositoryRef,
    /// The label that was just added — present on the `labeled` action.
    #[serde(default)]
    label: Option<GitHubLabelRef>,
}

#[derive(Debug, Deserialize)]
struct GitHubPullRequestRef {
    number: u64,
}

#[derive(Debug, Deserialize)]
struct GitHubReviewRef {
    state: String,
    #[serde(default)]
    body: Option<String>,
    #[serde(default)]
    html_url: Option<String>,
}

#[derive(Debug, Deserialize)]
struct GitHubPullRequestReviewEvent {
    action: String,
    review: GitHubReviewRef,
    pull_request: GitHubPullRequestRef,
    repository: GitHubRepositoryRef,
}

fn issue_task_request(issue_number: u64, repo: &str) -> CreateTaskRequest {
    let mut req = CreateTaskRequest::default();
    req.issue = Some(issue_number);
    req.repo = Some(repo.to_string());
    req
}

fn review_task_request(pr_number: u64, repo: &str) -> CreateTaskRequest {
    let mut req = CreateTaskRequest::default();
    req.pr = Some(pr_number);
    req.repo = Some(repo.to_string());
    req
}

fn pr_approved_task_request(event: &GitHubPullRequestReviewEvent) -> CreateTaskRequest {
    let mut req = CreateTaskRequest::default();
    req.prompt = Some(build_pr_approved_prompt(
        &event.repository.full_name,
        event.pull_request.number,
        event.review.html_url.as_deref(),
    ));
    req.repo = Some(event.repository.full_name.clone());
    req
}

fn fix_ci_task_request(payload: &GitHubIssueCommentEvent) -> CreateTaskRequest {
    let mut req = CreateTaskRequest::default();
    req.prompt = Some(build_fix_ci_prompt(
        &payload.repository.full_name,
        payload.issue.number,
        &payload.comment.body,
        payload.comment.html_url.as_deref(),
        payload.issue.html_url.as_deref(),
    ));
    req.repo = Some(payload.repository.full_name.clone());
    req
}

pub(crate) fn is_valid_github_event_name(event: &str) -> bool {
    !event.is_empty()
        && event
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
}

pub(crate) fn parse_github_webhook_task_request(
    event: &str,
    payload: &[u8],
    autonomous_issues: bool,
    autonomous_label: Option<&str>,
) -> Result<(Option<CreateTaskRequest>, String), String> {
    match event {
        "ping" => Ok((None, "ping".to_string())),
        "issues" => {
            let parsed: GitHubIssuesEvent = serde_json::from_slice(payload)
                .map_err(|err| format!("invalid issues payload: {err}"))?;
            if parsed.issue.pull_request.is_some() {
                return Ok((None, "issues event references pull request".to_string()));
            }
            let enqueue = || issue_task_request(parsed.issue.number, &parsed.repository.full_name);
            match parsed.action.as_str() {
                "opened" | "reopened" => {
                    match parse_harness_mention_command(parsed.issue.body.as_deref().unwrap_or(""))
                    {
                        Some(HarnessMentionCommand::Mention) => {
                            Ok((Some(enqueue()), "issue mention".to_string()))
                        }
                        Some(HarnessMentionCommand::Review) => {
                            Ok((None, "review command ignored on issue body".to_string()))
                        }
                        Some(HarnessMentionCommand::FixCi) => {
                            Ok((None, "fix ci command ignored on issue body".to_string()))
                        }
                        // In webhook/hybrid intake mode, an opened/reopened issue is
                        // enqueued directly — no `@harness` mention required — so the
                        // webhook drives autonomous intake the way the poller does. The
                        // configured label filter is honored: with a non-empty label,
                        // only issues carrying it are enqueued.
                        None if autonomous_issues => match autonomous_label {
                            Some(label) if !label.is_empty() && !parsed.issue.has_label(label) => {
                                Ok((
                                    None,
                                    format!(
                                        "autonomous intake skipped: issue lacks label '{label}'"
                                    ),
                                ))
                            }
                            _ => Ok((Some(enqueue()), "autonomous issue intake".to_string())),
                        },
                        None => Ok((None, "no @harness command in issue body".to_string())),
                    }
                }
                // In webhook-only mode the poller is off, so an issue that gains the
                // configured intake label *after* it was opened must be picked up via
                // the `labeled` event (the poller would have caught it on its next
                // scan). Only enqueue when the just-added label is the configured one.
                "labeled" if autonomous_issues => match autonomous_label {
                    Some(want) if !want.is_empty() => {
                        let added = parsed.label.as_ref().map(|l| l.name.as_str());
                        if added == Some(want) {
                            Ok((
                                Some(enqueue()),
                                "autonomous issue intake (labeled)".to_string(),
                            ))
                        } else {
                            Ok((
                                None,
                                "labeled action: not the configured intake label".to_string(),
                            ))
                        }
                    }
                    // No label filter configured → `labeled` events are noise.
                    _ => Ok((
                        None,
                        "labeled action ignored: no intake label configured".to_string(),
                    )),
                },
                _ => Ok((None, "ignored issues action".to_string())),
            }
        }
        "issue_comment" => {
            let parsed: GitHubIssueCommentEvent = serde_json::from_slice(payload)
                .map_err(|err| format!("invalid issue_comment payload: {err}"))?;
            if parsed.action != "created" {
                return Ok((None, "ignored issue_comment action".to_string()));
            }

            let command = match parse_harness_mention_command(&parsed.comment.body) {
                Some(command) => command,
                None => return Ok((None, "no @harness command in comment".to_string())),
            };

            if parsed.issue.pull_request.is_some() {
                return match command {
                    HarnessMentionCommand::Mention | HarnessMentionCommand::Review => Ok((
                        Some(review_task_request(
                            parsed.issue.number,
                            &parsed.repository.full_name,
                        )),
                        "pr review command".to_string(),
                    )),
                    HarnessMentionCommand::FixCi => Ok((
                        Some(fix_ci_task_request(&parsed)),
                        "pr fix ci command".to_string(),
                    )),
                };
            }

            match command {
                HarnessMentionCommand::Mention => Ok((
                    Some(issue_task_request(
                        parsed.issue.number,
                        &parsed.repository.full_name,
                    )),
                    "issue mention command".to_string(),
                )),
                HarnessMentionCommand::Review => {
                    Ok((None, "review command ignored on issue comment".to_string()))
                }
                HarnessMentionCommand::FixCi => {
                    Ok((None, "fix ci command ignored on issue comment".to_string()))
                }
            }
        }
        "pull_request_review" => {
            let parsed: GitHubPullRequestReviewEvent = serde_json::from_slice(payload)
                .map_err(|err| format!("invalid pull_request_review payload: {err}"))?;
            if parsed.action != "submitted" {
                return Ok((None, "ignored pull_request_review action".to_string()));
            }
            // Actionable review feedback (changes requested, or a non-empty
            // commented review) is routed into the structured per-PR feedback
            // workflow via `review_task_request` (which sets `pr`). That path is
            // keyed by the PR and deduped (ActiveCommandExists), so multiple
            // reviews on the same PR coalesce into one inspect_pr_feedback turn
            // instead of spawning concurrent freeform reworks that race on the
            // branch. The activity re-reads all PR feedback itself, so the review
            // body does not need to be threaded through here.
            match parsed.review.state.to_ascii_lowercase().as_str() {
                "changes_requested" => Ok((
                    Some(review_task_request(
                        parsed.pull_request.number,
                        &parsed.repository.full_name,
                    )),
                    "pr review changes_requested".to_string(),
                )),
                "approved" => Ok((
                    Some(pr_approved_task_request(&parsed)),
                    "pr review approved".to_string(),
                )),
                "commented" => {
                    let body = parsed.review.body.as_deref().unwrap_or("").trim();
                    if body.is_empty() {
                        return Ok((None, "pr review comment: empty body ignored".to_string()));
                    }
                    Ok((
                        Some(review_task_request(
                            parsed.pull_request.number,
                            &parsed.repository.full_name,
                        )),
                        "pr review comment: actionable feedback".to_string(),
                    ))
                }
                _ => Ok((None, "unsupported review state".to_string())),
            }
        }
        _ => Ok((None, "unsupported event".to_string())),
    }
}

pub(crate) fn decode_hex(value: &str) -> Option<Vec<u8>> {
    if value.len() % 2 != 0 {
        return None;
    }
    let mut out = Vec::with_capacity(value.len() / 2);
    let bytes = value.as_bytes();
    for pair in bytes.chunks_exact(2) {
        let high = (pair[0] as char).to_digit(16)? as u8;
        let low = (pair[1] as char).to_digit(16)? as u8;
        out.push((high << 4) | low);
    }
    Some(out)
}

pub(crate) fn verify_github_signature(
    secret: &str,
    signature_header: &str,
    payload: &[u8],
) -> bool {
    use hmac::{Hmac, Mac};
    use sha2::Sha256;

    let Some(signature_hex) = signature_header.strip_prefix("sha256=") else {
        return false;
    };
    let Some(signature_bytes) = decode_hex(signature_hex) else {
        return false;
    };

    let mut mac = match Hmac::<Sha256>::new_from_slice(secret.as_bytes()) {
        Ok(mac) => mac,
        Err(_) => return false,
    };
    mac.update(payload);
    mac.verify_slice(&signature_bytes).is_ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decode_hex_accepts_valid_input() {
        assert_eq!(decode_hex("00ff10"), Some(vec![0x00, 0xff, 0x10]));
    }

    #[test]
    fn decode_hex_rejects_invalid_input() {
        assert!(decode_hex("abc").is_none());
        assert!(decode_hex("zz").is_none());
    }

    #[test]
    fn verify_github_signature_accepts_valid_hmac() {
        use hmac::{Hmac, Mac};
        use sha2::Sha256;

        let secret = "top-secret";
        let payload = br#"{"ok":true}"#;
        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
        mac.update(payload);
        let signature = mac.finalize().into_bytes();
        let signature_hex: String = signature.iter().map(|byte| format!("{byte:02x}")).collect();
        let header = format!("sha256={signature_hex}");

        assert!(verify_github_signature(secret, &header, payload));
    }

    #[test]
    fn verify_github_signature_rejects_invalid_hmac() {
        let secret = "top-secret";
        let payload = br#"{"ok":true}"#;
        assert!(!verify_github_signature(
            secret,
            "sha256=0000000000000000000000000000000000000000000000000000000000000000",
            payload,
        ));
    }

    #[test]
    fn parse_issue_comment_issue_mention_maps_to_issue_request() {
        let payload = serde_json::json!({
            "action": "created",
            "issue": { "number": 106 },
            "comment": { "body": "@harness please handle this issue" },
            "repository": { "full_name": "majiayu000/harness" }
        });

        let (request, _) = parse_github_webhook_task_request(
            "issue_comment",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        let request = request.expect("request should exist");
        assert_eq!(request.issue, Some(106));
        assert_eq!(request.pr, None);
        assert_eq!(request.prompt, None);
        assert_eq!(request.repo.as_deref(), Some("majiayu000/harness"));
    }

    #[test]
    fn parse_issue_comment_pr_review_maps_to_pr_request() {
        let payload = serde_json::json!({
            "action": "created",
            "issue": { "number": 42, "pull_request": { "url": "https://api.github.com/repos/majiayu000/harness/pulls/42" } },
            "comment": { "body": "@harness review" },
            "repository": { "full_name": "majiayu000/harness" }
        });

        let (request, _) = parse_github_webhook_task_request(
            "issue_comment",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        let request = request.expect("request should exist");
        assert_eq!(request.issue, None);
        assert_eq!(request.pr, Some(42));
        assert_eq!(request.prompt, None);
        assert_eq!(request.repo.as_deref(), Some("majiayu000/harness"));
    }

    #[test]
    fn parse_issue_comment_fix_ci_maps_to_prompt_request() {
        let payload = serde_json::json!({
            "action": "created",
            "issue": {
                "number": 42,
                "html_url": "https://github.com/majiayu000/harness/pull/42",
                "pull_request": { "url": "https://api.github.com/repos/majiayu000/harness/pulls/42" }
            },
            "comment": {
                "body": "@harness fix ci",
                "html_url": "https://github.com/majiayu000/harness/issues/42#issuecomment-1"
            },
            "repository": { "full_name": "majiayu000/harness" }
        });

        let (request, _) = parse_github_webhook_task_request(
            "issue_comment",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        let request = request.expect("request should exist");
        assert_eq!(request.issue, None);
        assert_eq!(request.pr, None);
        assert_eq!(request.repo.as_deref(), Some("majiayu000/harness"));
        assert!(request
            .prompt
            .as_deref()
            .unwrap_or_default()
            .contains("CI failure repair requested for PR #42"));
    }

    #[test]
    fn parse_issues_opened_with_harness_mention_creates_issue_task() {
        let payload = serde_json::json!({
            "action": "opened",
            "issue": {
                "number": 77,
                "body": "@harness please implement this feature"
            },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "issues",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        let request = request.expect("request should exist");
        assert_eq!(reason, "issue mention");
        assert_eq!(request.issue, Some(77));
        assert_eq!(request.pr, None);
        assert_eq!(request.prompt, None);
        assert_eq!(request.repo.as_deref(), Some("org/repo"));
    }

    #[test]
    fn parse_issues_reopened_with_harness_mention_creates_issue_task() {
        let payload = serde_json::json!({
            "action": "reopened",
            "issue": {
                "number": 88,
                "body": "This is still broken, @harness fix it"
            },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "issues",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        let request = request.expect("request should exist");
        assert_eq!(reason, "issue mention");
        assert_eq!(request.issue, Some(88));
        assert_eq!(request.repo.as_deref(), Some("org/repo"));
    }

    #[test]
    fn parse_issues_opened_without_harness_mention_is_ignored() {
        let payload = serde_json::json!({
            "action": "opened",
            "issue": {
                "number": 99,
                "body": "This issue has no harness mention"
            },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "issues",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        assert!(request.is_none());
        assert_eq!(reason, "no @harness command in issue body");
    }

    #[test]
    fn parse_issues_opened_with_no_body_is_ignored() {
        let payload = serde_json::json!({
            "action": "opened",
            "issue": { "number": 100 },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "issues",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        assert!(request.is_none());
        assert_eq!(reason, "no @harness command in issue body");
    }

    #[test]
    fn parse_issues_opened_autonomous_enqueues_without_mention() {
        let payload = serde_json::json!({
            "action": "opened",
            "issue": { "number": 100, "body": "plain issue, no mention" },
            "repository": { "full_name": "org/repo" }
        });

        // autonomous_issues=true (webhook/hybrid mode) enqueues without @harness.
        let (request, reason) =
            parse_github_webhook_task_request("issues", payload.to_string().as_bytes(), true, None)
                .unwrap();
        let request = request.expect("autonomous mode should enqueue the issue");
        assert_eq!(request.issue, Some(100));
        assert_eq!(request.repo.as_deref(), Some("org/repo"));
        assert_eq!(reason, "autonomous issue intake");
    }

    #[test]
    fn parse_issues_autonomous_honors_label_filter() {
        let labeled = serde_json::json!({
            "action": "opened",
            "issue": { "number": 101, "body": "x", "labels": [{ "name": "harness" }] },
            "repository": { "full_name": "org/repo" }
        });
        let unlabeled = serde_json::json!({
            "action": "opened",
            "issue": { "number": 102, "body": "x", "labels": [{ "name": "bug" }] },
            "repository": { "full_name": "org/repo" }
        });

        // With a configured label, only the matching issue is enqueued.
        let (req, _) = parse_github_webhook_task_request(
            "issues",
            labeled.to_string().as_bytes(),
            true,
            Some("harness"),
        )
        .unwrap();
        assert_eq!(req.expect("labeled issue enqueued").issue, Some(101));

        let (req, reason) = parse_github_webhook_task_request(
            "issues",
            unlabeled.to_string().as_bytes(),
            true,
            Some("harness"),
        )
        .unwrap();
        assert!(
            req.is_none(),
            "issue without the configured label is skipped"
        );
        assert!(reason.contains("lacks label"));
    }

    #[test]
    fn parse_issues_labeled_action_enqueues_on_configured_label() {
        // Issue gains the configured intake label after being opened: in
        // webhook-only mode the poller is off, so the `labeled` event must enqueue.
        let matching = serde_json::json!({
            "action": "labeled",
            "issue": { "number": 200, "labels": [{ "name": "harness" }] },
            "label": { "name": "harness" },
            "repository": { "full_name": "org/repo" }
        });
        let (req, reason) = parse_github_webhook_task_request(
            "issues",
            matching.to_string().as_bytes(),
            true,
            Some("harness"),
        )
        .unwrap();
        assert_eq!(
            req.expect("labeled with intake label enqueues").issue,
            Some(200)
        );
        assert!(reason.contains("labeled"));

        // A different label being added is not an intake trigger.
        let other = serde_json::json!({
            "action": "labeled",
            "issue": { "number": 201, "labels": [{ "name": "bug" }] },
            "label": { "name": "bug" },
            "repository": { "full_name": "org/repo" }
        });
        let (req, _) = parse_github_webhook_task_request(
            "issues",
            other.to_string().as_bytes(),
            true,
            Some("harness"),
        )
        .unwrap();
        assert!(req.is_none(), "unrelated label add must not enqueue");
    }

    #[test]
    fn parse_issues_edited_action_is_ignored() {
        let payload = serde_json::json!({
            "action": "edited",
            "issue": {
                "number": 106,
                "body": "@harness please handle this issue"
            },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "issues",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        assert!(request.is_none());
        assert_eq!(reason, "ignored issues action");
    }

    #[test]
    fn parse_unsupported_event_returns_static_reason() {
        let (request, reason) =
            parse_github_webhook_task_request("unknown_event", br#"{}"#, false, None).unwrap();
        assert!(request.is_none());
        assert_eq!(reason, "unsupported event");
    }

    #[test]
    fn github_event_name_validation_is_strict() {
        assert!(is_valid_github_event_name("issue_comment"));
        assert!(is_valid_github_event_name("pull_request"));
        assert!(!is_valid_github_event_name("issue-comment"));
        assert!(!is_valid_github_event_name("IssueComment"));
        assert!(!is_valid_github_event_name("issue comment"));
    }

    #[test]
    fn github_event_name_rejects_empty() {
        assert!(!is_valid_github_event_name(""));
    }

    #[test]
    fn ping_event_returns_none_request() {
        let (request, reason) =
            parse_github_webhook_task_request("ping", br#"{"zen":"anything"}"#, false, None)
                .unwrap();
        assert!(request.is_none());
        assert_eq!(reason, "ping");
    }

    #[test]
    fn issue_comment_non_created_action_is_ignored() {
        let payload = serde_json::json!({
            "action": "deleted",
            "issue": { "number": 1 },
            "comment": { "body": "@harness review" },
            "repository": { "full_name": "org/repo" }
        });
        let (request, reason) = parse_github_webhook_task_request(
            "issue_comment",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        assert!(request.is_none());
        assert_eq!(reason, "ignored issue_comment action");
    }

    #[test]
    fn issue_comment_without_mention_is_ignored() {
        let payload = serde_json::json!({
            "action": "created",
            "issue": { "number": 1 },
            "comment": { "body": "just a regular comment" },
            "repository": { "full_name": "org/repo" }
        });
        let (request, reason) = parse_github_webhook_task_request(
            "issue_comment",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        assert!(request.is_none());
        assert_eq!(reason, "no @harness command in comment");
    }

    #[test]
    fn issues_event_with_pull_request_ref_is_ignored() {
        let payload = serde_json::json!({
            "action": "opened",
            "issue": {
                "number": 5,
                "body": "@harness handle",
                "pull_request": { "url": "https://api.github.com/repos/org/repo/pulls/5" }
            },
            "repository": { "full_name": "org/repo" }
        });
        let (request, reason) = parse_github_webhook_task_request(
            "issues",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        assert!(request.is_none());
        assert_eq!(reason, "issues event references pull request");
    }

    #[test]
    fn invalid_payload_json_returns_error() {
        let result = parse_github_webhook_task_request("issues", b"not valid json", false, None);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("invalid issues payload"));
    }

    #[test]
    fn verify_github_signature_rejects_missing_prefix() {
        assert!(!verify_github_signature("secret", "deadbeef", b"payload"));
    }

    #[test]
    fn verify_github_signature_rejects_invalid_hex() {
        assert!(!verify_github_signature(
            "secret",
            "sha256=zzzz",
            b"payload"
        ));
    }

    #[test]
    fn pull_request_review_changes_requested_creates_rework_task() {
        let payload = serde_json::json!({
            "action": "submitted",
            "review": {
                "state": "changes_requested",
                "body": "Please fix the error handling in this function.",
                "html_url": "https://github.com/org/repo/pull/10#pullrequestreview-1"
            },
            "pull_request": {
                "number": 10,
                "html_url": "https://github.com/org/repo/pull/10"
            },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "pull_request_review",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        let request = request.expect("request should exist");
        assert_eq!(reason, "pr review changes_requested");
        assert_eq!(request.repo.as_deref(), Some("org/repo"));
        // Actionable reviews now route through the structured per-PR feedback
        // sweep (pr set → deduped), not a freeform rework prompt.
        assert_eq!(request.pr, Some(10));
        assert!(request.prompt.is_none());
    }

    #[test]
    fn pull_request_review_approved_creates_approved_task() {
        let payload = serde_json::json!({
            "action": "submitted",
            "review": {
                "state": "approved",
                "body": "LGTM!",
                "html_url": "https://github.com/org/repo/pull/10#pullrequestreview-2"
            },
            "pull_request": {
                "number": 10,
                "html_url": "https://github.com/org/repo/pull/10"
            },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "pull_request_review",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        let request = request.expect("request should exist");
        assert_eq!(reason, "pr review approved");
        assert_eq!(request.repo.as_deref(), Some("org/repo"));
        let prompt = request.prompt.unwrap();
        assert!(prompt.contains("PR #10"));
        assert!(prompt.contains("org/repo"));
        assert!(prompt.contains("approved"));
        assert!(prompt.contains("ready to merge"));
    }

    #[test]
    fn pull_request_review_commented_with_body_creates_rework_task() {
        let payload = serde_json::json!({
            "action": "submitted",
            "review": {
                "state": "commented",
                "body": "Consider renaming this variable for clarity.",
                "html_url": "https://github.com/org/repo/pull/10#pullrequestreview-3"
            },
            "pull_request": {
                "number": 10,
                "html_url": "https://github.com/org/repo/pull/10"
            },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "pull_request_review",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        let request = request.expect("request should exist");
        assert_eq!(reason, "pr review comment: actionable feedback");
        assert_eq!(request.repo.as_deref(), Some("org/repo"));
        // Routed through the structured per-PR feedback sweep, not a prompt.
        assert_eq!(request.pr, Some(10));
        assert!(request.prompt.is_none());
    }

    #[test]
    fn pull_request_review_commented_with_empty_body_is_ignored() {
        let payload = serde_json::json!({
            "action": "submitted",
            "review": {
                "state": "commented",
                "body": "",
                "html_url": "https://github.com/org/repo/pull/10#pullrequestreview-4"
            },
            "pull_request": {
                "number": 10,
                "html_url": "https://github.com/org/repo/pull/10"
            },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "pull_request_review",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        assert!(request.is_none());
        assert_eq!(reason, "pr review comment: empty body ignored");
    }

    #[test]
    fn pull_request_review_non_submitted_action_is_ignored() {
        let payload = serde_json::json!({
            "action": "dismissed",
            "review": {
                "state": "changes_requested",
                "body": "Some feedback"
            },
            "pull_request": { "number": 10 },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "pull_request_review",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        assert!(request.is_none());
        assert_eq!(reason, "ignored pull_request_review action");
    }

    #[test]
    fn pull_request_review_invalid_payload_returns_error() {
        let result = parse_github_webhook_task_request(
            "pull_request_review",
            b"not valid json",
            false,
            None,
        );
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .contains("invalid pull_request_review payload"));
    }

    #[test]
    fn pull_request_review_state_matching_is_case_insensitive() {
        let payload = serde_json::json!({
            "action": "submitted",
            "review": {
                "state": "APPROVED",
                "body": "Looks good",
                "html_url": "https://github.com/org/repo/pull/10#pullrequestreview-5"
            },
            "pull_request": {
                "number": 10,
                "html_url": "https://github.com/org/repo/pull/10"
            },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "pull_request_review",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        assert!(request.is_some());
        assert_eq!(reason, "pr review approved");
    }

    #[test]
    fn pull_request_review_unsupported_state_is_ignored() {
        let payload = serde_json::json!({
            "action": "submitted",
            "review": {
                "state": "pending",
                "body": "Still reviewing"
            },
            "pull_request": { "number": 10 },
            "repository": { "full_name": "org/repo" }
        });

        let (request, reason) = parse_github_webhook_task_request(
            "pull_request_review",
            payload.to_string().as_bytes(),
            false,
            None,
        )
        .unwrap();
        assert!(request.is_none());
        assert_eq!(reason, "unsupported review state");
    }
}
