fn now() -> std::time::SystemTime {
	// Non-epoch so duration subtraction works in both directions.
	std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000)
}

#[tokio::test]
async fn fast_rule_priority_last_config_rule_wins_for_pin() {
	// Two fast rules with conflicting `pin` both match; reverse iteration
	// makes the last-in-config (pin=false) finalize first and win.
	let t = crate::Torrent {
		category: "linux".into(),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		rules: vec![
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				pin: Some(true),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				pin: Some(false),
				..Default::default()
			},
		],
		..crate::Config::empty()
	};

	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(res.pinned.is_none());
}

#[tokio::test]
async fn fast_rules_compose_across_independent_fields() {
	// Two fast rules touching different fields (include_in_score vs pin)
	// both apply.
	let t = crate::Torrent {
		category: "linux".into(),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		rules: vec![
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				include_in_score: Some(false),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				pin: Some(true),
				..Default::default()
			},
		],
		..crate::Config::empty()
	};

	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(matches!(res.pinned, Some(crate::PinReason::Explicit)));
	assert!(!res.include_in_score);
}

#[tokio::test]
async fn slow_phase2_resolves_when_phase1_has_multiple_possible_pin_values() {
	// Two slow rules with conflicting `pin`. Phase 1 sees both Some(true)
	// and Some(false) as possible, so it punts to phase 2, which fetches
	// trackers and resolves definitively.
	let t = crate::Torrent {
		category: "linux".into(),
		seeding_time: std::time::Duration::from_secs(1),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		categories_allowed: ["linux".into()].into(),
		rules: vec![
			crate::Rule {
				match_: crate::Match {
					tracker_msg: Some(regex::RegexSet::new([r"^ok$"]).unwrap()),
					..Default::default()
				},
				pin: Some(true),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					tracker_url: Some(regex::RegexSet::new([r"^http://bad/.*$"]).unwrap()),
					..Default::default()
				},
				pin: Some(false),
				..Default::default()
			},
		],
		..crate::Config::empty()
	};

	// pin matches.
	let g = crate::qbt_mock::global_with_trackers(c.clone(), now(), vec![
		crate::Tracker {
			msg: "ok".into(),
			status: crate::TrackerStatus::Working,
			url: "http://t/".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(matches!(res.pinned, Some(crate::PinReason::Explicit)));

	// unpin matches.
	let g = crate::qbt_mock::global_with_trackers(c.clone(), now(), vec![
		crate::Tracker {
			msg: "nope".into(),
			status: crate::TrackerStatus::Working,
			url: "http://bad/x".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(res.pinned.is_none());

	// Both match — last in config wins.
	let g = crate::qbt_mock::global_with_trackers(c.clone(), now(), vec![
		crate::Tracker {
			msg: "ok".into(),
			status: crate::TrackerStatus::Working,
			url: "http://bad/x".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(res.pinned.is_none());

	// Neither matches.
	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![
		crate::Tracker {
			msg: "nope".into(),
			status: crate::TrackerStatus::Working,
			url: "http://t/".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(res.pinned.is_none());
}

#[tokio::test]
async fn pin_reason_only_from_matching_rule() {
	let t = crate::Torrent {
		category: "linux".into(),
		..crate::Torrent::zero()
	};

	let non_matching = crate::Rule {
		match_: crate::Match {
			categories: Some(["mac".into()].into()),
			..Default::default()
		},
		pin: Some(true),
		pin_reason: Some("should-not-leak".into()),
		..Default::default()
	};
	let matching = crate::Rule {
		match_: crate::Match {
			categories: Some(["linux".into()].into()),
			..Default::default()
		},
		pin: Some(true),
		pin_reason: Some("keep linux".into()),
		..Default::default()
	};

	// Non-matching rule first.
	let c = crate::Config {
		rules: vec![non_matching.clone(), matching.clone()],
		..crate::Config::empty()
	};
	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![]);
	let res = g.rules(&t, false).await.unwrap();
	assert_eq!(res.pinned, Some(crate::PinReason::Explicit));
	assert_eq!(res.pin_reason, Some("keep linux".into()));

	// Non-matching rule last.
	let c = crate::Config {
		rules: vec![matching, non_matching],
		..crate::Config::empty()
	};
	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![]);
	let res = g.rules(&t, false).await.unwrap();
	assert_eq!(res.pinned, Some(crate::PinReason::Explicit));
	assert_eq!(res.pin_reason, Some("keep linux".into()));
}

#[tokio::test]
async fn pin_reason_from_applied_pin_surrounded_by_opposite() {
	let t = crate::Torrent {
		category: "linux".into(),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		rules: vec![
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				pin: Some(true),
				pin_reason: Some("before".into()),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				pin: Some(false),
				pin_reason: Some("applied".into()),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					categories: Some(["mac".into()].into()),
					..Default::default()
				},
				pin: Some(true),
				pin_reason: Some("after".into()),
				..Default::default()
			},
		],
		..crate::Config::empty()
	};

	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(res.pinned.is_none());
	assert_eq!(res.pin_reason, Some("applied".into()));
}

#[tokio::test]
async fn pin_reason_from_applied_pin_true_surrounded_by_false() {
	let t = crate::Torrent {
		category: "linux".into(),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		rules: vec![
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				pin: Some(false),
				pin_reason: Some("before".into()),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				pin: Some(true),
				pin_reason: Some("applied".into()),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					categories: Some(["mac".into()].into()),
					..Default::default()
				},
				pin: Some(false),
				pin_reason: Some("after".into()),
				..Default::default()
			},
		],
		..crate::Config::empty()
	};

	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![]);
	let res = g.rules(&t, false).await.unwrap();
	assert_eq!(res.pinned, Some(crate::PinReason::Explicit));
	assert_eq!(res.pin_reason, Some("applied".into()));
}

#[tokio::test]
async fn group_pinned_torrent_reports_no_pin_reason() {
	let t = crate::Torrent {
		category: "linux".into(),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		rules: vec![crate::Rule {
			match_: crate::Match {
				categories: Some(["linux".into()].into()),
				..Default::default()
			},
			pin: Some(false),
			pin_reason: Some("should-not-leak".into()),
			..Default::default()
		}],
		..crate::Config::empty()
	};

	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![]);
	let res = g.rules(&t, true).await.unwrap();
	assert_eq!(res.pinned, None);
	assert_eq!(res.pin_reason, None);
}

#[tokio::test]
async fn pin_reason_from_slow_matched_rule() {
	// The deciding pin rule resolves only in the slow (phase 2) path. Its
	// reason must be captured, and a non-matching slow rule's reason must not.
	let t = crate::Torrent {
		category: "linux".into(),
		seeding_time: std::time::Duration::from_secs(1),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		categories_allowed: ["linux".into()].into(),
		rules: vec![
			crate::Rule {
				match_: crate::Match {
					tracker_url: Some(regex::RegexSet::new([r"^http://other/.*$"]).unwrap()),
					..Default::default()
				},
				pin: Some(false),
				pin_reason: Some("should-not-leak".into()),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					tracker_url: Some(regex::RegexSet::new([r"^http://keep/.*$"]).unwrap()),
					..Default::default()
				},
				pin: Some(true),
				pin_reason: Some("slow keep".into()),
				..Default::default()
			},
		],
		..crate::Config::empty()
	};

	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![
		crate::Tracker {
			msg: "ok".into(),
			status: crate::TrackerStatus::Working,
			url: "http://keep/x".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert_eq!(res.pinned, Some(crate::PinReason::Explicit));
	assert_eq!(res.pin_reason, Some("slow keep".into()));
}

#[tokio::test]
async fn mixed_fast_and_slow_rules() {
	// A fast rule sets `include_in_score=false` unconditionally; a slow
	// rule raises `seeder_count_min` when its tracker matches. Phase 1
	// can't resolve `seeder_count_min`, so phase 2 fetches trackers and
	// re-evaluates both rules.
	let t = crate::Torrent {
		category: "linux".into(),
		seeders: 5,
		seeding_time: std::time::Duration::from_secs(1),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		categories_allowed: ["linux".into()].into(),
		rules: vec![
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				include_in_score: Some(false),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					tracker_msg: Some(regex::RegexSet::new([r"^private$"]).unwrap()),
					..Default::default()
				},
				seeder_count_min: Some(50),
				..Default::default()
			},
		],
		..crate::Config::empty()
	};

	// Slow rule applies; seeder_count_min=50 violated.
	let g = crate::qbt_mock::global_with_trackers(c.clone(), now(), vec![
		crate::Tracker {
			msg: "private".into(),
			status: crate::TrackerStatus::Working,
			url: "http://t/".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(matches!(res.pinned, Some(crate::PinReason::Seeders(5))));
	assert!(!res.include_in_score);

	// Slow rule skipped; only the fast rule applies.
	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![
		crate::Tracker {
			msg: "public".into(),
			status: crate::TrackerStatus::Working,
			url: "http://t/".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(res.pinned.is_none());
	assert!(!res.include_in_score);
}
