//! Integration tests for `htree pr list`
//!
//! Run with: cargo test --package hashtree-cli --test pr_list -- --nocapture

mod common;

use common::{htree_bin, run_git, write_keys_file};

use nostr::{Event, EventBuilder, Keys, Kind, Tag, TagKind, Timestamp, ToBech32};
use serde_json::Value;
use std::fs;
use std::io::Write;
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Command, Output};
use tempfile::TempDir;

fn publish_event(relay_url: &str, event: &Event) {
    use futures::{SinkExt, StreamExt};
    use tokio_tungstenite::{connect_async, tungstenite::Message};

    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("build runtime");

    rt.block_on(async move {
        let (mut ws, _) = connect_async(relay_url).await.expect("connect test relay");
        let msg = serde_json::json!(["EVENT", event]).to_string();
        ws.send(Message::Text(msg)).await.expect("send event");

        let response = ws.next().await.expect("relay ack").expect("ws frame");
        let response = match response {
            Message::Text(text) => text,
            other => panic!("unexpected relay response: {other:?}"),
        };
        let parsed: Vec<Value> = serde_json::from_str(&response).expect("parse relay response");
        assert_eq!(parsed.first().and_then(Value::as_str), Some("OK"));
        assert_eq!(parsed.get(2).and_then(Value::as_bool), Some(true));

        let _ = ws.close(None).await;
    });
}

fn build_pr_event(
    author: &Keys,
    repo_address: &str,
    subject: &str,
    branch: &str,
    target_branch: &str,
    commit_tip: &str,
    created_at_secs: u64,
) -> Event {
    EventBuilder::new(
        Kind::Custom(1618),
        "",
        [
            Tag::custom(TagKind::custom("a"), vec![repo_address.to_string()]),
            Tag::custom(TagKind::custom("subject"), vec![subject.to_string()]),
            Tag::custom(TagKind::custom("branch"), vec![branch.to_string()]),
            Tag::custom(
                TagKind::custom("target-branch"),
                vec![target_branch.to_string()],
            ),
            Tag::custom(TagKind::custom("c"), vec![commit_tip.to_string()]),
        ],
    )
    .custom_created_at(Timestamp::from_secs(created_at_secs))
    .to_event(author)
    .expect("build PR event")
}

fn build_status_event(
    signer: &Keys,
    pr_event_id: &str,
    status_kind: u16,
    created_at_secs: u64,
) -> Event {
    EventBuilder::new(
        Kind::Custom(status_kind),
        "",
        [Tag::custom(
            TagKind::custom("e"),
            vec![pr_event_id.to_string()],
        )],
    )
    .custom_created_at(Timestamp::from_secs(created_at_secs))
    .to_event(signer)
    .expect("build status event")
}

struct PrListFixture {
    relay: common::test_relay::TestRelay,
    _tmp: TempDir,
    config_dir: PathBuf,
    data_dir: PathBuf,
    repo_dir: PathBuf,
    target_keys: Keys,
    target_npub: String,
    target_pubkey_hex: String,
    target_repo_name: String,
}

impl PrListFixture {
    fn target_repo_url(&self) -> String {
        format!("htree://{}/{}", self.target_npub, self.target_repo_name)
    }

    fn target_repo_address(&self) -> String {
        format!("30617:{}:{}", self.target_pubkey_hex, self.target_repo_name)
    }

    fn run_htree(&self, args: &[&str]) -> Output {
        self.run_htree_with_relay(args, &self.relay.url())
    }

    fn run_htree_with_relay(&self, args: &[&str], relay_url: &str) -> Output {
        Command::new(htree_bin())
            .current_dir(&self.repo_dir)
            .env("HOME", self._tmp.path())
            .env("HTREE_CONFIG_DIR", &self.config_dir)
            .env("HTREE_DATA_DIR", &self.data_dir)
            .env("NOSTR_RELAYS", relay_url)
            .env("HTREE_PREFER_LOCAL_RELAY", "0")
            .args(args)
            .output()
            .expect("run htree")
    }

    fn publish(&self, event: &Event) {
        publish_event(&self.relay.url(), event);
    }
}

fn setup_pr_list_fixture() -> PrListFixture {
    let relay = common::test_relay::TestRelay::new();

    let tmp = TempDir::new().expect("temp dir");
    let config_dir = tmp.path().join("config");
    let data_dir = tmp.path().join("data");
    fs::create_dir_all(&data_dir).expect("create data dir");

    let self_keys = Keys::generate();
    let self_nsec = self_keys
        .secret_key()
        .to_bech32()
        .expect("self nsec bech32");
    write_keys_file(&config_dir, &self_nsec).expect("write keys file");

    let target_keys = Keys::generate();
    let target_npub = target_keys.public_key().to_bech32().expect("target npub");
    let target_pubkey_hex = hex::encode(target_keys.public_key().to_bytes());

    let repo_dir = tmp.path().join("repo");
    fs::create_dir_all(&repo_dir).expect("create repo dir");
    run_git(&repo_dir, &["init", "-b", "master"]);
    run_git(&repo_dir, &["config", "user.email", "test@example.com"]);
    run_git(&repo_dir, &["config", "user.name", "Test User"]);
    fs::write(repo_dir.join("README.md"), "# pr-list-test\n").expect("write readme");
    run_git(&repo_dir, &["add", "README.md"]);
    run_git(&repo_dir, &["commit", "-m", "initial"]);

    let target_repo_name = "target-repo".to_string();
    let target_repo_url = format!("htree://{}/{}", target_npub, target_repo_name);
    run_git(&repo_dir, &["remote", "add", "htree", &target_repo_url]);

    PrListFixture {
        relay,
        _tmp: tmp,
        config_dir,
        data_dir,
        repo_dir,
        target_keys,
        target_npub,
        target_pubkey_hex,
        target_repo_name,
    }
}

fn publish_open_pr(fixture: &PrListFixture, subject: &str, commit_tip: &str, created_at_secs: u64) {
    let repo_address = fixture.target_repo_address();
    let pr_author = Keys::generate();
    let pr_open = build_pr_event(
        &pr_author,
        &repo_address,
        subject,
        "feature/test",
        "master",
        commit_tip,
        created_at_secs,
    );
    fixture.publish(&pr_open);
}

fn assert_list_output_contains_subject(fixture: &PrListFixture, repo_arg: &str, subject: &str) {
    let output = fixture.run_htree(&["pr", "list", repo_arg]);
    assert!(
        output.status.success(),
        "htree pr list {repo_arg} failed.\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains(subject), "stdout:\n{stdout}");
}

fn append_key_alias(fixture: &PrListFixture, nsec: &str, alias: &str) {
    let mut file = fs::OpenOptions::new()
        .append(true)
        .open(fixture.config_dir.join("keys"))
        .expect("open keys file for append");
    writeln!(file, "{nsec} {alias}").expect("append key alias");
}

fn unused_localhost_ws_url() -> String {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral localhost port");
    let port = listener
        .local_addr()
        .expect("get ephemeral listener address")
        .port();
    drop(listener);
    format!("ws://127.0.0.1:{port}")
}

#[test]
fn test_pr_list_defaults_to_open() {
    let fixture = setup_pr_list_fixture();
    let repo_address = fixture.target_repo_address();
    let pr_author = Keys::generate();
    let attacker = Keys::generate();

    let pr_open = build_pr_event(
        &pr_author,
        &repo_address,
        "Open PR",
        "feature/open",
        "master",
        "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
        1_700_200_000,
    );
    fixture.publish(&pr_open);

    let pr_closed = build_pr_event(
        &pr_author,
        &repo_address,
        "Closed PR",
        "feature/closed",
        "master",
        "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
        1_700_200_010,
    );
    fixture.publish(&pr_closed);
    fixture.publish(&build_status_event(
        &pr_author,
        &pr_closed.id.to_hex(),
        1632,
        1_700_200_020,
    ));

    let pr_applied = build_pr_event(
        &pr_author,
        &repo_address,
        "Applied PR",
        "feature/applied",
        "master",
        "cccccccccccccccccccccccccccccccccccccccc",
        1_700_200_030,
    );
    fixture.publish(&pr_applied);
    fixture.publish(&build_status_event(
        &fixture.target_keys,
        &pr_applied.id.to_hex(),
        1631,
        1_700_200_040,
    ));

    let pr_spoofed = build_pr_event(
        &pr_author,
        &repo_address,
        "Spoofed Status PR",
        "feature/spoofed",
        "master",
        "dddddddddddddddddddddddddddddddddddddddd",
        1_700_200_050,
    );
    fixture.publish(&pr_spoofed);
    fixture.publish(&build_status_event(
        &attacker,
        &pr_spoofed.id.to_hex(),
        1632,
        1_700_200_060,
    ));

    let output = fixture.run_htree(&["pr", "list", &fixture.target_repo_url()]);
    assert!(
        output.status.success(),
        "htree pr list failed.\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Open PR"), "stdout:\n{stdout}");
    assert!(stdout.contains("Spoofed Status PR"), "stdout:\n{stdout}");
    assert!(!stdout.contains("Closed PR"), "stdout:\n{stdout}");
    assert!(!stdout.contains("Applied PR"), "stdout:\n{stdout}");
}

#[test]
fn test_pr_list_state_filters() {
    let fixture = setup_pr_list_fixture();
    let repo_address = fixture.target_repo_address();
    let pr_author = Keys::generate();

    let pr_open = build_pr_event(
        &pr_author,
        &repo_address,
        "Open PR",
        "feature/open",
        "master",
        "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
        1_700_201_000,
    );
    fixture.publish(&pr_open);

    let pr_closed = build_pr_event(
        &pr_author,
        &repo_address,
        "Closed PR",
        "feature/closed",
        "master",
        "ffffffffffffffffffffffffffffffffffffffff",
        1_700_201_010,
    );
    fixture.publish(&pr_closed);
    fixture.publish(&build_status_event(
        &pr_author,
        &pr_closed.id.to_hex(),
        1632,
        1_700_201_020,
    ));

    let pr_applied = build_pr_event(
        &pr_author,
        &repo_address,
        "Applied PR",
        "feature/applied",
        "master",
        "1111111111111111111111111111111111111111",
        1_700_201_030,
    );
    fixture.publish(&pr_applied);
    fixture.publish(&build_status_event(
        &fixture.target_keys,
        &pr_applied.id.to_hex(),
        1631,
        1_700_201_040,
    ));

    let pr_draft = build_pr_event(
        &pr_author,
        &repo_address,
        "Draft PR",
        "feature/draft",
        "master",
        "2222222222222222222222222222222222222222",
        1_700_201_050,
    );
    fixture.publish(&pr_draft);
    fixture.publish(&build_status_event(
        &pr_author,
        &pr_draft.id.to_hex(),
        1633,
        1_700_201_060,
    ));

    let output_all =
        fixture.run_htree(&["pr", "list", &fixture.target_repo_url(), "--state", "all"]);
    assert!(
        output_all.status.success(),
        "htree pr list --state all failed.\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output_all.stdout),
        String::from_utf8_lossy(&output_all.stderr)
    );
    let stdout_all = String::from_utf8_lossy(&output_all.stdout);
    assert!(stdout_all.contains("Open PR"), "stdout:\n{stdout_all}");
    assert!(stdout_all.contains("Closed PR"), "stdout:\n{stdout_all}");
    assert!(stdout_all.contains("Applied PR"), "stdout:\n{stdout_all}");
    assert!(stdout_all.contains("Draft PR"), "stdout:\n{stdout_all}");

    let output_applied = fixture.run_htree(&[
        "pr",
        "list",
        &fixture.target_repo_url(),
        "--state",
        "applied",
    ]);
    assert!(
        output_applied.status.success(),
        "htree pr list --state applied failed.\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output_applied.stdout),
        String::from_utf8_lossy(&output_applied.stderr)
    );
    let stdout_applied = String::from_utf8_lossy(&output_applied.stdout);
    assert!(
        stdout_applied.contains("Applied PR"),
        "stdout:\n{stdout_applied}"
    );
    assert!(
        !stdout_applied.contains("Open PR"),
        "stdout:\n{stdout_applied}"
    );
    assert!(
        !stdout_applied.contains("Closed PR"),
        "stdout:\n{stdout_applied}"
    );
    assert!(
        !stdout_applied.contains("Draft PR"),
        "stdout:\n{stdout_applied}"
    );
}

#[test]
fn test_pr_list_infers_repo_from_single_htree_remote() {
    let fixture = setup_pr_list_fixture();
    let repo_address = fixture.target_repo_address();
    let pr_author = Keys::generate();

    let pr_open = build_pr_event(
        &pr_author,
        &repo_address,
        "Inferred Repo PR",
        "feature/inferred",
        "master",
        "3333333333333333333333333333333333333333",
        1_700_202_000,
    );
    fixture.publish(&pr_open);

    let output = fixture.run_htree(&["pr", "list"]);
    assert!(
        output.status.success(),
        "htree pr list without repo failed.\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Inferred Repo PR"), "stdout:\n{stdout}");
}

#[test]
fn test_pr_list_accepts_git_remote_alias() {
    let fixture = setup_pr_list_fixture();
    publish_open_pr(
        &fixture,
        "Alias PR",
        "4444444444444444444444444444444444444444",
        1_700_203_000,
    );

    assert_list_output_contains_subject(&fixture, "htree", "Alias PR");
}

#[test]
fn test_pr_list_accepts_git_remote_alias_with_slash() {
    let fixture = setup_pr_list_fixture();
    run_git(
        &fixture.repo_dir,
        &["remote", "add", "team/htree", &fixture.target_repo_url()],
    );

    publish_open_pr(
        &fixture,
        "Slash Alias PR",
        "4444444444444444444444444444444444444444",
        1_700_203_000,
    );

    assert_list_output_contains_subject(&fixture, "team/htree", "Slash Alias PR");
}

#[test]
fn test_pr_list_accepts_npub_repo_without_scheme() {
    let fixture = setup_pr_list_fixture();
    publish_open_pr(
        &fixture,
        "Npub Repo PR",
        "5555555555555555555555555555555555555555",
        1_700_204_000,
    );

    let target = format!("{}/{}", fixture.target_npub, fixture.target_repo_name);
    assert_list_output_contains_subject(&fixture, &target, "Npub Repo PR");
}

#[test]
fn test_pr_list_accepts_hex_pubkey_repo_without_scheme() {
    let fixture = setup_pr_list_fixture();
    publish_open_pr(
        &fixture,
        "Hex Repo PR",
        "6666666666666666666666666666666666666666",
        1_700_205_000,
    );

    let target = format!("{}/{}", fixture.target_pubkey_hex, fixture.target_repo_name);
    assert_list_output_contains_subject(&fixture, &target, "Hex Repo PR");
}

#[test]
fn test_pr_list_accepts_petname_repo_without_scheme() {
    let fixture = setup_pr_list_fixture();
    let target_nsec = fixture
        .target_keys
        .secret_key()
        .to_bech32()
        .expect("target nsec bech32");
    append_key_alias(&fixture, &target_nsec, "target");
    publish_open_pr(
        &fixture,
        "Petname Repo PR",
        "7777777777777777777777777777777777777777",
        1_700_206_000,
    );

    assert_list_output_contains_subject(&fixture, "target/target-repo", "Petname Repo PR");
}

#[test]
fn test_pr_list_accepts_htree_url_with_fragment() {
    let fixture = setup_pr_list_fixture();
    publish_open_pr(
        &fixture,
        "Fragment URL PR",
        "8888888888888888888888888888888888888888",
        1_700_207_000,
    );

    let target_with_fragment = format!("{}#k=super-secret", fixture.target_repo_url());
    let output = fixture.run_htree(&["pr", "list", &target_with_fragment]);
    assert!(
        output.status.success(),
        "htree pr list with fragment URL failed.\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stdout.contains("Fragment URL PR"), "stdout:\n{stdout}");
    assert!(
        !stdout.contains("super-secret") && !stderr.contains("super-secret"),
        "fragment leaked into output.\nstdout:\n{stdout}\nstderr:\n{stderr}"
    );
}

#[test]
fn test_pr_list_rejects_non_htree_remote_alias() {
    let fixture = setup_pr_list_fixture();
    run_git(
        &fixture.repo_dir,
        &[
            "remote",
            "add",
            "web-origin",
            "https://example.com/repo.git",
        ],
    );

    let output = fixture.run_htree(&["pr", "list", "web-origin"]);
    assert!(
        !output.status.success(),
        "expected non-htree alias to fail.\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("not an htree remote"),
        "stderr missing non-htree remote error.\nstderr:\n{stderr}"
    );
}

#[test]
fn test_pr_list_rejects_non_htree_remote_alias_with_slash() {
    let fixture = setup_pr_list_fixture();
    run_git(
        &fixture.repo_dir,
        &["remote", "add", "team/web", "https://example.com/repo.git"],
    );

    let output = fixture.run_htree(&["pr", "list", "team/web"]);
    assert!(
        !output.status.success(),
        "expected non-htree slash alias to fail.\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("not an htree remote"),
        "stderr missing non-htree remote error.\nstderr:\n{stderr}"
    );
}

#[test]
fn test_pr_list_prefers_git_remote_over_identity_repo_when_input_has_slash() {
    let fixture = setup_pr_list_fixture();
    let team_keys = Keys::generate();
    let team_nsec = team_keys
        .secret_key()
        .to_bech32()
        .expect("team nsec bech32");
    append_key_alias(&fixture, &team_nsec, "team");

    run_git(
        &fixture.repo_dir,
        &["remote", "add", "team/htree", &fixture.target_repo_url()],
    );
    publish_open_pr(
        &fixture,
        "Remote Wins PR",
        "9999999999999999999999999999999999999999",
        1_700_208_000,
    );

    assert_list_output_contains_subject(&fixture, "team/htree", "Remote Wins PR");
}

#[test]
fn test_pr_list_reports_empty_results() {
    let fixture = setup_pr_list_fixture();

    let output = fixture.run_htree(&["pr", "list", &fixture.target_repo_url()]);
    assert!(
        output.status.success(),
        "htree pr list failed.\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("No pull requests"),
        "expected empty-list message.\nstdout:\n{}",
        stdout
    );
}

#[test]
fn test_pr_list_includes_self_authored_browser_style_pr() {
    let fixture = setup_pr_list_fixture();
    let repo_address = fixture.target_repo_address();
    let clone_url = fixture.target_repo_url();

    let browser_style_pr = EventBuilder::new(
        Kind::Custom(1618),
        "interop test PR",
        [
            Tag::custom(TagKind::custom("a"), vec![repo_address]),
            Tag::custom(
                TagKind::custom("p"),
                vec![fixture.target_pubkey_hex.clone()],
            ),
            Tag::custom(
                TagKind::custom("subject"),
                vec!["iris-git created PR".to_string()],
            ),
            Tag::custom(TagKind::custom("clone"), vec![clone_url]),
            Tag::custom(TagKind::custom("branch"), vec!["feature-ui".to_string()]),
            Tag::custom(TagKind::custom("target-branch"), vec!["master".to_string()]),
        ],
    )
    .custom_created_at(Timestamp::from_secs(1_700_209_000))
    .to_event(&fixture.target_keys)
    .expect("build browser-style self PR");
    fixture.publish(&browser_style_pr);

    let output = fixture.run_htree(&["pr", "list", &fixture.target_repo_url()]);
    assert!(
        output.status.success(),
        "htree pr list failed.\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("iris-git created PR"),
        "expected browser-style self-authored PR in output.\nstdout:\n{}",
        stdout
    );
}

#[test]
fn test_pr_list_surfaces_fetch_failure_instead_of_empty_results() {
    let fixture = setup_pr_list_fixture();
    let unreachable_relay = unused_localhost_ws_url();

    let output = fixture.run_htree_with_relay(
        &["pr", "list", &fixture.target_repo_url()],
        &unreachable_relay,
    );
    assert!(
        !output.status.success(),
        "expected pr list to fail when relay is unreachable.\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        !stdout.contains("No pull requests found."),
        "stdout incorrectly reported empty PR list.\nstdout:\n{stdout}"
    );
    assert!(
        stderr.contains("Failed to connect to any relay")
            || stderr.contains("Failed to fetch PR events from relays")
            || stderr.contains("Timed out fetching PR events from relays"),
        "stderr missing fetch/connect failure message.\nstderr:\n{stderr}"
    );
}
