// Shared test helpers. Each integration test binary picks the subset it needs,
// so dead-code lints on the unused helpers fire per-binary; suppress at the
// module level.
#![allow(dead_code)]

use std::sync::Arc;

use axum::body::Body;
use axum::http::{header, Request};
use axum::Router;
use http_body_util::BodyExt;
use serde_json::json;
use sqlx::PgPool;
use tempfile::TempDir;
use tower::ServiceExt;

use mangalord::app::{router, AppState, RuntimeControls};
use mangalord::auth::rate_limit::AuthRateLimiter;
use mangalord::config::{AnalysisConfig, AuthConfig, CrawlerConfig, UploadConfig};
use mangalord::storage::{LocalStorage, PutByteStream, Storage, StorageError, StreamingFile};

use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};

/// The CSRF `Origin` value the default test harness allowlists and the
/// cookie-bearing helpers auto-attach. Keeping it a const here means every
/// non-CSRF-focused test inherits the matching pair without thinking about
/// it; the CSRF-focused tests use `post_json_with_cookie_origin` to drive
/// specific Origin/Referer combinations.
pub const TEST_ORIGIN: &str = "http://test";

pub struct Harness {
    pub app: Router,
    // Kept alive for the lifetime of the test so the temp dir is not dropped.
    pub _storage_dir: TempDir,
}

pub fn harness(pool: PgPool) -> Harness {
    let storage_dir = tempfile::tempdir().expect("tempdir");
    let storage = Arc::new(LocalStorage::new(storage_dir.path()));
    harness_inner(pool, storage, storage_dir)
}

/// Variant of `harness` that swaps in a `Storage` that errors on the
/// `fail_on_put_index`-th `put` call (0-indexed). Used to exercise the
/// upload handlers' transactional rollback path without resorting to
/// fault injection at lower layers.
pub fn harness_with_failing_storage(pool: PgPool, fail_on_put_index: usize) -> Harness {
    let storage_dir = tempfile::tempdir().expect("tempdir");
    let inner = LocalStorage::new(storage_dir.path());
    let storage = Arc::new(FailingStorage::new(inner, fail_on_put_index));
    harness_inner(pool, storage, storage_dir)
}

fn harness_inner(
    pool: PgPool,
    storage: Arc<dyn Storage>,
    storage_dir: TempDir,
) -> Harness {
    harness_with_auth_config(pool, storage, storage_dir, AuthConfig {
        cookie_secure: false,
        ..AuthConfig::default()
    })
}

fn harness_with_auth_config(
    pool: PgPool,
    storage: Arc<dyn Storage>,
    storage_dir: TempDir,
    auth: AuthConfig,
) -> Harness {
    let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
    let state = AppState {
        db: pool,
        storage,
        auth,
        upload: UploadConfig {
            // Keep file caps small in tests so the size-cap path is cheap to
            // exercise without producing tens of MBs of bytes.
            max_request_bytes: 4 * 1024 * 1024,
            max_file_bytes: 256 * 1024,
            max_pages_per_chapter: 2000,
        },
        auth_limiter,
        // Default harness has no crawler daemon wired up; admin resync
        // handlers return 503 in this config. Tests that need a stub
        // resync service swap it in via `harness_with_resync`. No reloader,
        // so settings still persist but spawn no daemon.
        runtime: Arc::new(RuntimeControls::new(false)),
        reloader: None,
        crawler_base: CrawlerConfig::default(),
        analysis_base: AnalysisConfig::default(),
        // The router's CSRF guard now fails-closed on cookie-auth POSTs
        // when the allowlist is empty. Seed the default harness with the
        // sentinel test origin (`TEST_ORIGIN`) so the cookie-auth helpers
        // below — which auto-attach `Origin: TEST_ORIGIN` — pass through.
        // The CSRF-specific tests override via
        // `post_json_with_cookie_origin` and `harness_with_admin_origins`.
        admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
        analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
    };
    Harness { app: router(state), _storage_dir: storage_dir }
}

/// Like [`harness`] but flips `ALLOW_SELF_REGISTER` off so the
/// register-disabled test exercises the 403 branch in
/// `api::auth::register`.
pub fn harness_with_self_register_disabled(pool: PgPool) -> Harness {
    let storage_dir = tempfile::tempdir().expect("tempdir");
    let storage = Arc::new(LocalStorage::new(storage_dir.path()));
    let auth = AuthConfig {
        cookie_secure: false,
        allow_self_register: false,
        ..AuthConfig::default()
    };
    harness_with_auth_config(pool, storage, storage_dir, auth)
}

/// Like [`harness`] but flips `PRIVATE_MODE` on so the site-wide auth
/// gate is exercised. `allow_self_register` stays at its default `true`
/// to verify that private mode force-disables self-registration on top
/// of whatever `ALLOW_SELF_REGISTER` says.
pub fn harness_with_private_mode(pool: PgPool) -> Harness {
    let storage_dir = tempfile::tempdir().expect("tempdir");
    let storage = Arc::new(LocalStorage::new(storage_dir.path()));
    let auth = AuthConfig {
        cookie_secure: false,
        private_mode: true,
        ..AuthConfig::default()
    };
    harness_with_auth_config(pool, storage, storage_dir, auth)
}

/// Like [`harness`] but configures a tight auth rate limit. Used by
/// the brute-force-rate-limiting test.
pub fn harness_with_auth_rate_limit(
    pool: PgPool,
    per_sec: u32,
    burst: u32,
) -> Harness {
    let storage_dir = tempfile::tempdir().expect("tempdir");
    let storage = Arc::new(LocalStorage::new(storage_dir.path()));
    let auth = AuthConfig {
        cookie_secure: false,
        rate_limit: mangalord::auth::rate_limit::RateLimitConfig { per_sec, burst },
        ..AuthConfig::default()
    };
    harness_with_auth_config(pool, storage, storage_dir, auth)
}

/// Like [`harness`] but slots a caller-supplied [`ResyncService`] stub
/// into `AppState.resync`. Used by the admin resync tests so the
/// endpoint path is exercised without standing up a real Chromium.
pub fn harness_with_resync(
    pool: PgPool,
    resync: Arc<dyn mangalord::crawler::resync::ResyncService>,
) -> Harness {
    let storage_dir = tempfile::tempdir().expect("tempdir");
    let storage = Arc::new(LocalStorage::new(storage_dir.path()));
    let auth = AuthConfig {
        cookie_secure: false,
        ..AuthConfig::default()
    };
    let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
    let runtime = Arc::new(RuntimeControls::new(false));
    runtime.set_resync(Some(resync));
    let state = AppState {
        db: pool,
        storage,
        auth,
        upload: UploadConfig {
            max_request_bytes: 4 * 1024 * 1024,
            max_file_bytes: 256 * 1024,
            max_pages_per_chapter: 2000,
        },
        auth_limiter,
        runtime,
        reloader: None,
        crawler_base: CrawlerConfig::default(),
        analysis_base: AnalysisConfig::default(),
        admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
        analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
    };
    Harness {
        app: router(state),
        _storage_dir: storage_dir,
    }
}

/// Like [`harness`] but flips `analysis_enabled` on so the page-create
/// paths enqueue `analyze_page` jobs and the admin analysis endpoints are
/// active (rather than returning 503).
pub fn harness_with_analysis(pool: PgPool) -> Harness {
    let storage_dir = tempfile::tempdir().expect("tempdir");
    let storage = Arc::new(LocalStorage::new(storage_dir.path()));
    let auth = AuthConfig {
        cookie_secure: false,
        ..AuthConfig::default()
    };
    let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
    let state = AppState {
        db: pool,
        storage,
        auth,
        upload: UploadConfig {
            max_request_bytes: 4 * 1024 * 1024,
            max_file_bytes: 256 * 1024,
            max_pages_per_chapter: 2000,
        },
        auth_limiter,
        runtime: Arc::new(RuntimeControls::new(true)),
        reloader: None,
        crawler_base: CrawlerConfig::default(),
        analysis_base: AnalysisConfig::default(),
        admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
        analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
    };
    Harness {
        app: router(state),
        _storage_dir: storage_dir,
    }
}

/// Like [`harness`] but with a low `max_pages_per_chapter` so the chapter
/// upload page-count cap is cheap to exercise.
pub fn harness_with_page_cap(pool: PgPool, max_pages_per_chapter: usize) -> Harness {
    let storage_dir = tempfile::tempdir().expect("tempdir");
    let storage = Arc::new(LocalStorage::new(storage_dir.path()));
    let auth = AuthConfig {
        cookie_secure: false,
        ..AuthConfig::default()
    };
    let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
    let state = AppState {
        db: pool,
        storage,
        auth,
        upload: UploadConfig {
            max_request_bytes: 4 * 1024 * 1024,
            max_file_bytes: 256 * 1024,
            max_pages_per_chapter,
        },
        auth_limiter,
        runtime: Arc::new(RuntimeControls::new(false)),
        reloader: None,
        crawler_base: CrawlerConfig::default(),
        analysis_base: AnalysisConfig::default(),
        admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
        analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
    };
    Harness {
        app: router(state),
        _storage_dir: storage_dir,
    }
}

/// A [`DaemonReloader`] stub that records the configs it was asked to apply
/// and flips the shared analysis gate, without spawning any real daemon. Lets
/// settings tests assert that a `PUT` triggers a reload with the converted
/// config (and that the analysis enable gate moves).
pub struct StubReloader {
    pub runtime: Arc<RuntimeControls>,
    pub crawler: std::sync::Mutex<Option<CrawlerConfig>>,
    pub analysis: std::sync::Mutex<Option<AnalysisConfig>>,
}

#[async_trait]
impl mangalord::app::DaemonReloader for StubReloader {
    async fn reload_crawler(&self, cfg: CrawlerConfig) -> anyhow::Result<()> {
        *self.crawler.lock().unwrap() = Some(cfg);
        Ok(())
    }
    async fn reload_analysis(&self, cfg: AnalysisConfig) -> anyhow::Result<()> {
        self.runtime.set_analysis_enabled(cfg.enabled);
        *self.analysis.lock().unwrap() = Some(cfg);
        Ok(())
    }
}

/// Like [`harness`] but wires a [`StubReloader`] so the settings `PUT`
/// endpoints exercise the reload path. Returns the harness plus the shared
/// stub so the test can inspect what was applied.
pub fn harness_with_settings_reloader(pool: PgPool) -> (Harness, Arc<StubReloader>) {
    let storage_dir = tempfile::tempdir().expect("tempdir");
    let storage = Arc::new(LocalStorage::new(storage_dir.path()));
    let auth = AuthConfig {
        cookie_secure: false,
        ..AuthConfig::default()
    };
    let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
    let runtime = Arc::new(RuntimeControls::new(false));
    let reloader = Arc::new(StubReloader {
        runtime: Arc::clone(&runtime),
        crawler: std::sync::Mutex::new(None),
        analysis: std::sync::Mutex::new(None),
    });
    let state = AppState {
        db: pool,
        storage,
        auth,
        upload: UploadConfig {
            max_request_bytes: 4 * 1024 * 1024,
            max_file_bytes: 256 * 1024,
            max_pages_per_chapter: 2000,
        },
        auth_limiter,
        runtime,
        reloader: Some(Arc::clone(&reloader) as Arc<dyn mangalord::app::DaemonReloader>),
        crawler_base: CrawlerConfig::default(),
        analysis_base: AnalysisConfig::default(),
        admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
        analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
    };
    (
        Harness {
            app: router(state),
            _storage_dir: storage_dir,
        },
        reloader,
    )
}

/// Like [`harness`] but configures an admin CSRF allowlist so the
/// `/admin/*` mutating endpoints reject cross-origin browser POSTs.
/// Used by the admin CSRF integration tests.
pub fn harness_with_admin_origins(pool: PgPool, origins: Vec<String>) -> Harness {
    let storage_dir = tempfile::tempdir().expect("tempdir");
    let storage = Arc::new(LocalStorage::new(storage_dir.path()));
    let auth_limiter = Arc::new(AuthRateLimiter::new(Default::default()));
    let state = AppState {
        db: pool,
        storage,
        auth: AuthConfig {
            cookie_secure: false,
            ..AuthConfig::default()
        },
        upload: UploadConfig {
            max_request_bytes: 4 * 1024 * 1024,
            max_file_bytes: 256 * 1024,
            max_pages_per_chapter: 2000,
        },
        auth_limiter,
        runtime: Arc::new(RuntimeControls::new(false)),
        reloader: None,
        crawler_base: CrawlerConfig::default(),
        analysis_base: AnalysisConfig::default(),
        admin_allowed_origins: Arc::new(origins),
        analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
    };
    Harness {
        app: router(state),
        _storage_dir: storage_dir,
    }
}

/// Wraps a real `Storage` and fails on the N-th `put` call so tests can
/// assert that handlers roll their DB writes back when storage errors
/// mid-upload. Reads and other operations delegate to `inner`.
pub struct FailingStorage {
    inner: LocalStorage,
    counter: AtomicUsize,
    fail_on_put_index: usize,
}

impl FailingStorage {
    pub fn new(inner: LocalStorage, fail_on_put_index: usize) -> Self {
        Self {
            inner,
            counter: AtomicUsize::new(0),
            fail_on_put_index,
        }
    }
}

#[async_trait]
impl Storage for FailingStorage {
    async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
        let n = self.counter.fetch_add(1, Ordering::SeqCst);
        if n == self.fail_on_put_index {
            return Err(StorageError::Io(std::io::Error::other(
                "FailingStorage: injected put failure",
            )));
        }
        self.inner.put(key, bytes).await
    }
    async fn put_stream(
        &self,
        key: &str,
        stream: PutByteStream<'_>,
    ) -> Result<u64, StorageError> {
        // Count put_stream towards the same fail-index so tests that
        // expect "the Nth put fails" don't care which entry point
        // the caller took.
        let n = self.counter.fetch_add(1, Ordering::SeqCst);
        if n == self.fail_on_put_index {
            return Err(StorageError::Io(std::io::Error::other(
                "FailingStorage: injected put_stream failure",
            )));
        }
        self.inner.put_stream(key, stream).await
    }
    async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
        self.inner.get(key).await
    }
    async fn get_stream(&self, key: &str) -> Result<StreamingFile, StorageError> {
        self.inner.get_stream(key).await
    }
    async fn delete(&self, key: &str) -> Result<(), StorageError> {
        self.inner.delete(key).await
    }
    async fn exists(&self, key: &str) -> Result<bool, StorageError> {
        self.inner.exists(key).await
    }
    async fn size(&self, key: &str) -> Result<u64, StorageError> {
        self.inner.size(key).await
    }
    // Delegate straight to the inner filesystem rename — the fault
    // injection counts `put`/`put_stream` only, so promoting a staged page
    // to its final key never spuriously trips the injected failure.
    async fn rename(&self, from: &str, to: &str) -> Result<(), StorageError> {
        self.inner.rename(from, to).await
    }
}

pub async fn body_json(response: axum::response::Response) -> serde_json::Value {
    let bytes = response.into_body().collect().await.unwrap().to_bytes();
    serde_json::from_slice(&bytes).expect("body is JSON")
}

pub fn get(uri: &str) -> Request<Body> {
    Request::builder().uri(uri).body(Body::empty()).unwrap()
}

pub fn get_with_cookie(uri: &str, cookie: &str) -> Request<Body> {
    Request::builder()
        .uri(uri)
        .header(header::COOKIE, cookie)
        .body(Body::empty())
        .unwrap()
}

pub fn get_with_bearer(uri: &str, token: &str) -> Request<Body> {
    Request::builder()
        .uri(uri)
        .header(header::AUTHORIZATION, format!("Bearer {token}"))
        .body(Body::empty())
        .unwrap()
}

pub fn post_json(uri: &str, body: serde_json::Value) -> Request<Body> {
    Request::builder()
        .method("POST")
        .uri(uri)
        .header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(body.to_string()))
        .unwrap()
}

pub fn post_json_with_cookie(
    uri: &str,
    body: serde_json::Value,
    cookie: &str,
) -> Request<Body> {
    // Origin matches the default-harness allowlist (see TEST_ORIGIN) so
    // cookie-auth POSTs survive the production-grade CSRF guard. CSRF-
    // focused tests use `post_json_with_cookie_origin` to drive bespoke
    // Origin/Referer values.
    Request::builder()
        .method("POST")
        .uri(uri)
        .header(header::CONTENT_TYPE, "application/json")
        .header(header::COOKIE, cookie)
        .header(header::ORIGIN, TEST_ORIGIN)
        .body(Body::from(body.to_string()))
        .unwrap()
}

/// Same as [`post_json_with_cookie`] but also attaches `Origin` (and
/// optionally `Referer`) headers. Used by the admin CSRF tests to drive
/// the cross-origin reject + allowed-origin accept paths.
pub fn post_json_with_cookie_origin(
    uri: &str,
    body: serde_json::Value,
    cookie: &str,
    origin: Option<&str>,
    referer: Option<&str>,
) -> Request<Body> {
    let mut b = Request::builder()
        .method("POST")
        .uri(uri)
        .header(header::CONTENT_TYPE, "application/json")
        .header(header::COOKIE, cookie);
    if let Some(o) = origin {
        b = b.header(header::ORIGIN, o);
    }
    if let Some(r) = referer {
        b = b.header(header::REFERER, r);
    }
    b.body(Body::from(body.to_string())).unwrap()
}

pub fn get_with_cookie_origin(
    uri: &str,
    cookie: &str,
    origin: Option<&str>,
) -> Request<Body> {
    let mut b = Request::builder()
        .uri(uri)
        .header(header::COOKIE, cookie);
    if let Some(o) = origin {
        b = b.header(header::ORIGIN, o);
    }
    b.body(Body::empty()).unwrap()
}

pub fn post_json_with_bearer(
    uri: &str,
    body: serde_json::Value,
    token: &str,
) -> Request<Body> {
    Request::builder()
        .method("POST")
        .uri(uri)
        .header(header::CONTENT_TYPE, "application/json")
        .header(header::AUTHORIZATION, format!("Bearer {token}"))
        .body(Body::from(body.to_string()))
        .unwrap()
}

pub fn patch_json(uri: &str, body: serde_json::Value) -> Request<Body> {
    Request::builder()
        .method("PATCH")
        .uri(uri)
        .header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(body.to_string()))
        .unwrap()
}

pub fn patch_json_with_cookie(
    uri: &str,
    body: serde_json::Value,
    cookie: &str,
) -> Request<Body> {
    Request::builder()
        .method("PATCH")
        .uri(uri)
        .header(header::CONTENT_TYPE, "application/json")
        .header(header::COOKIE, cookie)
        .header(header::ORIGIN, TEST_ORIGIN)
        .body(Body::from(body.to_string()))
        .unwrap()
}

pub fn put_json_with_cookie(
    uri: &str,
    body: serde_json::Value,
    cookie: &str,
) -> Request<Body> {
    Request::builder()
        .method("PUT")
        .uri(uri)
        .header(header::CONTENT_TYPE, "application/json")
        .header(header::COOKIE, cookie)
        .header(header::ORIGIN, TEST_ORIGIN)
        .body(Body::from(body.to_string()))
        .unwrap()
}

pub fn delete_with_cookie(uri: &str, cookie: &str) -> Request<Body> {
    Request::builder()
        .method("DELETE")
        .uri(uri)
        .header(header::COOKIE, cookie)
        .header(header::ORIGIN, TEST_ORIGIN)
        .body(Body::empty())
        .unwrap()
}

/// Extracts the `mangalord_session` cookie from a response's Set-Cookie
/// headers as a `name=value` pair suitable for use in a follow-up `Cookie`
/// request header. Returns `None` if no such cookie was set.
pub fn extract_session_cookie(response: &axum::response::Response) -> Option<String> {
    response
        .headers()
        .get_all(header::SET_COOKIE)
        .iter()
        .find_map(|v| {
            let s = v.to_str().ok()?;
            if s.starts_with("mangalord_session=") {
                let end = s.find(';').unwrap_or(s.len());
                Some(s[..end].to_string())
            } else {
                None
            }
        })
}

/// Minimal multipart builder for tests. Real clients would use a real
/// library; we hand-roll a small one so the test crate stays free of
/// http-client dependencies.
pub struct MultipartBuilder {
    boundary: String,
    body: Vec<u8>,
}

impl Default for MultipartBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl MultipartBuilder {
    pub fn new() -> Self {
        Self {
            boundary: format!("----mangalord-test-{}", uuid::Uuid::new_v4().simple()),
            body: Vec::new(),
        }
    }

    pub fn add_json(mut self, name: &str, value: serde_json::Value) -> Self {
        self.write_part_header(name, None, Some("application/json"));
        self.body.extend(value.to_string().as_bytes());
        self.body.extend(b"\r\n");
        self
    }

    pub fn add_file(
        mut self,
        name: &str,
        filename: &str,
        content_type: &str,
        bytes: &[u8],
    ) -> Self {
        self.write_part_header(name, Some(filename), Some(content_type));
        self.body.extend(bytes);
        self.body.extend(b"\r\n");
        self
    }

    fn write_part_header(
        &mut self,
        name: &str,
        filename: Option<&str>,
        ct: Option<&str>,
    ) {
        self.body
            .extend(format!("--{}\r\n", self.boundary).as_bytes());
        let disposition = if let Some(fname) = filename {
            format!(
                "Content-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\n"
            )
        } else {
            format!("Content-Disposition: form-data; name=\"{name}\"\r\n")
        };
        self.body.extend(disposition.as_bytes());
        if let Some(ct) = ct {
            self.body.extend(format!("Content-Type: {ct}\r\n").as_bytes());
        }
        self.body.extend(b"\r\n");
    }

    /// Pub so a test can mint a multipart body for a bearer-auth Request
    /// without going through the cookie helpers. Returns
    /// `(boundary, body)` ready to attach as `Content-Type:
    /// multipart/form-data; boundary={boundary}` and the request body.
    pub fn finalize(self) -> (String, Vec<u8>) {
        let mut body = self.body;
        body.extend(format!("--{}--\r\n", self.boundary).as_bytes());
        (self.boundary, body)
    }
}

pub fn post_multipart(uri: &str, builder: MultipartBuilder) -> Request<Body> {
    let (boundary, body) = builder.finalize();
    Request::builder()
        .method("POST")
        .uri(uri)
        .header(
            header::CONTENT_TYPE,
            format!("multipart/form-data; boundary={boundary}"),
        )
        .body(Body::from(body))
        .unwrap()
}

pub fn post_multipart_with_cookie(
    uri: &str,
    builder: MultipartBuilder,
    cookie: &str,
) -> Request<Body> {
    let (boundary, body) = builder.finalize();
    Request::builder()
        .method("POST")
        .uri(uri)
        .header(
            header::CONTENT_TYPE,
            format!("multipart/form-data; boundary={boundary}"),
        )
        .header(header::COOKIE, cookie)
        .header(header::ORIGIN, TEST_ORIGIN)
        .body(Body::from(body))
        .unwrap()
}

pub fn put_multipart_with_cookie(
    uri: &str,
    builder: MultipartBuilder,
    cookie: &str,
) -> Request<Body> {
    let (boundary, body) = builder.finalize();
    Request::builder()
        .method("PUT")
        .uri(uri)
        .header(
            header::CONTENT_TYPE,
            format!("multipart/form-data; boundary={boundary}"),
        )
        .header(header::COOKIE, cookie)
        .header(header::ORIGIN, TEST_ORIGIN)
        .body(Body::from(body))
        .unwrap()
}

pub fn put_multipart(uri: &str, builder: MultipartBuilder) -> Request<Body> {
    let (boundary, body) = builder.finalize();
    Request::builder()
        .method("PUT")
        .uri(uri)
        .header(
            header::CONTENT_TYPE,
            format!("multipart/form-data; boundary={boundary}"),
        )
        .body(Body::from(body))
        .unwrap()
}

/// Realistic PNG file header bytes — enough for `infer` to identify.
pub fn fake_png_bytes() -> Vec<u8> {
    vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]
}

/// Realistic JPEG file header bytes — enough for `infer` to identify.
pub fn fake_jpeg_bytes() -> Vec<u8> {
    vec![
        0xff, 0xd8, 0xff, 0xe0, 0, 0x10, b'J', b'F', b'I', b'F', 0, 0,
    ]
}

/// Create a manga via the upload API and return its id. Used by tests
/// that need a manga to exist before they exercise chapters / etc.
pub async fn seed_manga_via_api(app: &Router, cookie: &str, title: &str) -> uuid::Uuid {
    let resp = app
        .clone()
        .oneshot(post_multipart_with_cookie(
            "/api/v1/mangas",
            MultipartBuilder::new().add_json("metadata", serde_json::json!({ "title": title })),
            cookie,
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        axum::http::StatusCode::CREATED,
        "seed_manga_via_api failed"
    );
    let body = body_json(resp).await;
    uuid::Uuid::parse_str(body["id"].as_str().unwrap()).unwrap()
}

/// Register a brand-new user and return (username, session cookie value).
/// The username is unique per call so tests can run in parallel against a
/// single DB without colliding.
pub async fn register_user(app: &Router) -> (String, String) {
    // 12-hex-digit suffix keeps the username under the 32-char cap.
    let suffix: String = uuid::Uuid::new_v4().simple().to_string().chars().take(12).collect();
    let username = format!("u-{suffix}");
    let resp = app
        .clone()
        .oneshot(post_json(
            "/api/v1/auth/register",
            json!({ "username": username, "password": "hunter2hunter2" }),
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        axum::http::StatusCode::CREATED,
        "register failed in test harness"
    );
    let cookie = extract_session_cookie(&resp).expect("session cookie on register");
    (username, cookie)
}
