use crate::{
    controllers::{admin, auth as auth_controller, i18n::current_lang},
    models::{
        _entities::{audio_albums, audio_tracks},
        users,
    },
};
use axum::{
    body::Body,
    extract::{DefaultBodyLimit, Multipart},
    http::{
        header::{self, HeaderMap},
        StatusCode,
    },
};
use axum_extra::extract::cookie::CookieJar;
use bytes::Bytes;
use chrono::{NaiveDate, Utc};
use loco_rs::{config::Config, prelude::*};
use sea_orm::{
    ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
    QueryOrder, Set,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
    path::{Path as StdPath, PathBuf},
    str::FromStr,
};
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
use uuid::Uuid;

const AUDIO_MAX_BYTES: usize = 50 * 1024 * 1024;
const IMAGE_MAX_BYTES: usize = 10 * 1024 * 1024;
pub const AUDIO_STORAGE_DIR: &str = "audio";
pub const IMAGE_STORAGE_DIR: &str = "images";

/// Album-create form, parsed manually from `multipart/form-data` so the page
/// can both upload a cover image and submit any number of `track_ids`
/// checkboxes (a urlencoded `Form` can't deserialize repeated keys into a Vec).
struct ParsedAlbumForm {
    title: Option<String>,
    artist: Option<String>,
    release_date: Option<String>,
    description: Option<String>,
    published: bool,
    cover: Option<Vec<u8>>,
    track_ids: Vec<Uuid>,
}

#[derive(Debug, Deserialize)]
struct AlbumSongForm {
    track_id: Uuid,
}

#[derive(Debug, Serialize)]
struct UploadResponse {
    filename: String,
    url: String,
    size: usize,
}

pub fn uploads_root(config: &Config) -> Result<PathBuf> {
    config
        .settings
        .as_ref()
        .and_then(|settings| settings.get("uploads_root"))
        .and_then(|value| value.as_str())
        .filter(|value| !value.trim().is_empty())
        .map(PathBuf::from)
        .ok_or_else(|| Error::string("settings.uploads_root must be configured"))
}

fn slugify(title: &str) -> String {
    let mut slug = String::new();
    let mut last_was_dash = false;

    for ch in title.chars().flat_map(char::to_lowercase) {
        if ch.is_ascii_alphanumeric() {
            slug.push(ch);
            last_was_dash = false;
        } else if !last_was_dash && !slug.is_empty() {
            slug.push('-');
            last_was_dash = true;
        }
    }

    let slug = slug.trim_matches('-').to_string();
    if slug.is_empty() {
        Uuid::new_v4().to_string()
    } else {
        slug
    }
}

fn normalize_empty(value: Option<String>) -> Option<String> {
    value.and_then(|value| {
        let value = value.trim().to_string();
        if value.is_empty() {
            None
        } else {
            Some(value)
        }
    })
}

fn safe_filename(filename: &str) -> Result<&str> {
    if filename.is_empty()
        || filename.contains('/')
        || filename.contains('\\')
        || filename.contains("..")
    {
        return Err(Error::BadRequest("invalid filename".to_string()));
    }
    Ok(filename)
}

fn audio_content_type(extension: &str) -> &'static str {
    match extension {
        "aac" => "audio/aac",
        "flac" => "audio/flac",
        "m4a" => "audio/mp4",
        "ogg" => "audio/ogg",
        "wav" => "audio/wav",
        "webm" => "audio/webm",
        _ => "audio/mpeg",
    }
}

fn image_content_type(extension: &str) -> &'static str {
    match extension {
        "gif" => "image/gif",
        "jpg" | "jpeg" => "image/jpeg",
        "png" => "image/png",
        "webp" => "image/webp",
        _ => "application/octet-stream",
    }
}

fn detect_audio_extension(data: &[u8]) -> Result<&'static str> {
    if data.len() < 12 {
        return Err(Error::BadRequest("audio file is too small".to_string()));
    }
    if data.starts_with(b"ID3") || (data[0] == 0xff && (data[1] & 0xe0) == 0xe0) {
        return Ok("mp3");
    }
    if data.starts_with(b"RIFF") && &data[8..12] == b"WAVE" {
        return Ok("wav");
    }
    if data.starts_with(b"OggS") {
        return Ok("ogg");
    }
    if data.starts_with(b"fLaC") {
        return Ok("flac");
    }
    if data.len() >= 12 && &data[4..8] == b"ftyp" {
        return Ok("m4a");
    }
    if data.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
        return Ok("webm");
    }
    if data.starts_with(&[0xff, 0xf1]) || data.starts_with(&[0xff, 0xf9]) {
        return Ok("aac");
    }
    Err(Error::BadRequest("unsupported audio format".to_string()))
}

fn detect_image_extension(data: &[u8]) -> Result<&'static str> {
    if data.len() < 12 {
        return Err(Error::BadRequest("image file is too small".to_string()));
    }
    if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
        return Ok("png");
    }
    if data.starts_with(&[0xff, 0xd8, 0xff]) {
        return Ok("jpg");
    }
    if data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" {
        return Ok("webp");
    }
    if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
        return Ok("gif");
    }
    Err(Error::BadRequest("unsupported image format".to_string()))
}

async fn read_multipart_file(mut multipart: Multipart, max_bytes: usize) -> Result<Vec<u8>> {
    while let Some(mut field) = multipart
        .next_field()
        .await
        .map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
    {
        if field.name() != Some("file") {
            continue;
        }

        let mut data = Vec::new();
        while let Some(chunk) = field
            .chunk()
            .await
            .map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
        {
            data.extend_from_slice(&chunk);
            if data.len() > max_bytes {
                return Err(Error::BadRequest(format!(
                    "file is larger than {} MB",
                    max_bytes / 1024 / 1024
                )));
            }
        }

        if data.is_empty() {
            return Err(Error::BadRequest("empty file upload".to_string()));
        }
        return Ok(data);
    }

    Err(Error::BadRequest(
        "multipart field `file` is required".to_string(),
    ))
}

async fn read_track_upload(
    mut multipart: Multipart,
) -> Result<(Vec<u8>, Option<String>, Option<i32>, bool, bool)> {
    let mut data = None;
    let mut title = None;
    let mut track_number = None;
    let mut featured = false;
    let mut published = false;

    while let Some(mut field) = multipart
        .next_field()
        .await
        .map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
    {
        let name = field.name().unwrap_or("").to_string();
        if name == "file" {
            let mut file = Vec::new();
            while let Some(chunk) = field
                .chunk()
                .await
                .map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
            {
                file.extend_from_slice(&chunk);
                if file.len() > AUDIO_MAX_BYTES {
                    return Err(Error::BadRequest("file is larger than 50 MB".to_string()));
                }
            }
            data = Some(file);
        } else {
            let value = field
                .text()
                .await
                .map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
            match name.as_str() {
                "title" => title = normalize_empty(Some(value)),
                "track_number" => {
                    track_number = value
                        .trim()
                        .parse::<i32>()
                        .ok()
                        .filter(|number| *number > 0)
                }
                "featured" => featured = value == "on" || value == "true" || value == "1",
                "published" => published = value == "on" || value == "true" || value == "1",
                _ => {}
            }
        }
    }

    let data =
        data.ok_or_else(|| Error::BadRequest("multipart field `file` is required".to_string()))?;
    if data.is_empty() {
        return Err(Error::BadRequest("empty file upload".to_string()));
    }

    Ok((data, title, track_number, featured, published))
}

/// Parse the new-album `multipart/form-data` body: text fields, an optional
/// cover image file, and zero or more `track_ids` checkbox values.
async fn read_album_form(mut multipart: Multipart) -> Result<ParsedAlbumForm> {
    let mut form = ParsedAlbumForm {
        title: None,
        artist: None,
        release_date: None,
        description: None,
        published: false,
        cover: None,
        track_ids: Vec::new(),
    };

    while let Some(mut field) = multipart
        .next_field()
        .await
        .map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
    {
        let name = field.name().unwrap_or("").to_string();
        if name == "cover" {
            let mut data = Vec::new();
            while let Some(chunk) = field
                .chunk()
                .await
                .map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
            {
                data.extend_from_slice(&chunk);
                if data.len() > IMAGE_MAX_BYTES {
                    return Err(Error::BadRequest(format!(
                        "cover image is larger than {} MB",
                        IMAGE_MAX_BYTES / 1024 / 1024
                    )));
                }
            }
            // An unselected file input still sends an empty `cover` part.
            if !data.is_empty() {
                form.cover = Some(data);
            }
        } else {
            let value = field
                .text()
                .await
                .map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
            match name.as_str() {
                "title" => form.title = normalize_empty(Some(value)),
                "artist" => form.artist = normalize_empty(Some(value)),
                "release_date" => form.release_date = normalize_empty(Some(value)),
                "description" => form.description = normalize_empty(Some(value)),
                "published" => {
                    form.published = value == "on" || value == "true" || value == "1";
                }
                "track_ids" => {
                    let trimmed = value.trim();
                    if !trimmed.is_empty() {
                        let id = Uuid::parse_str(trimmed)
                            .map_err(|_| Error::BadRequest("invalid song selection".to_string()))?;
                        form.track_ids.push(id);
                    }
                }
                _ => {}
            }
        }
    }

    Ok(form)
}

async fn unique_album_slug(ctx: &AppContext, title: &str) -> Result<String> {
    let base = slugify(title);
    let mut slug = base.clone();
    let mut suffix = 2;

    while audio_albums::Entity::find()
        .filter(audio_albums::Column::Slug.eq(&slug))
        .count(&ctx.db)
        .await?
        > 0
    {
        slug = format!("{base}-{suffix}");
        suffix += 1;
    }

    Ok(slug)
}

async fn unique_track_slug(
    ctx: &AppContext,
    album_id: Option<Uuid>,
    title: &str,
) -> Result<String> {
    let base = slugify(title);
    let mut slug = base.clone();
    let mut suffix = 2;

    loop {
        let mut query = audio_tracks::Entity::find().filter(audio_tracks::Column::Slug.eq(&slug));
        query = if let Some(album_id) = album_id {
            query.filter(audio_tracks::Column::AlbumId.eq(album_id))
        } else {
            query.filter(audio_tracks::Column::AlbumId.is_null())
        };

        if query.count(&ctx.db).await? == 0 {
            break;
        }
        slug = format!("{base}-{suffix}");
        suffix += 1;
    }

    Ok(slug)
}

async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool {
    let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else {
        return false;
    };
    let Ok(jwt_config) = ctx.config.get_jwt_config() else {
        return false;
    };
    let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
    else {
        return false;
    };
    let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
        return false;
    };

    admin::is_admin(ctx, &user)
}

async fn album_by_id(ctx: &AppContext, id: Uuid) -> Result<audio_albums::Model> {
    audio_albums::Entity::find_by_id(id)
        .one(&ctx.db)
        .await?
        .ok_or_else(|| Error::NotFound)
}

async fn track_by_id(ctx: &AppContext, id: Uuid) -> Result<audio_tracks::Model> {
    audio_tracks::Entity::find_by_id(id)
        .one(&ctx.db)
        .await?
        .ok_or_else(|| Error::NotFound)
}

async fn store_upload(
    ctx: &AppContext,
    folder: &str,
    extension: &str,
    data: Vec<u8>,
) -> Result<String> {
    let filename = format!("{}.{}", Uuid::new_v4(), extension);
    let key = format!("{folder}/{filename}");
    ctx.storage
        .upload(StdPath::new(&key), &Bytes::from(data))
        .await?;
    Ok(filename)
}

#[debug_handler]
async fn image_upload(
    auth: auth::JWT,
    State(ctx): State<AppContext>,
    multipart: Multipart,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    let data = read_multipart_file(multipart, IMAGE_MAX_BYTES).await?;
    let extension = detect_image_extension(&data)?;
    let size = data.len();
    let filename = store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?;

    format::json(UploadResponse {
        url: format!("/images/{filename}"),
        filename,
        size,
    })
}

#[debug_handler]
async fn image_serve(
    Path(filename): Path<String>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let filename = safe_filename(&filename)?;
    let extension = filename.rsplit('.').next().unwrap_or("");
    let key = format!("{IMAGE_STORAGE_DIR}/{filename}");
    let body: Vec<u8> = ctx.storage.download(StdPath::new(&key)).await?;
    Response::builder()
        .header(header::CONTENT_TYPE, image_content_type(extension))
        .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
        .body(Body::from(body))
        .map_err(Error::from)
}

#[debug_handler]
async fn audio_upload(
    auth: auth::JWT,
    State(ctx): State<AppContext>,
    multipart: Multipart,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    let data = read_multipart_file(multipart, AUDIO_MAX_BYTES).await?;
    let extension = detect_audio_extension(&data)?;
    let size = data.len();
    let filename = store_upload(&ctx, AUDIO_STORAGE_DIR, extension, data).await?;

    format::json(UploadResponse {
        url: format!("/audio/stream/{filename}"),
        filename,
        size,
    })
}

#[debug_handler]
async fn public_albums(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let albums = audio_albums::Entity::find()
        .filter(audio_albums::Column::Published.eq(true))
        .order_by_desc(audio_albums::Column::PublishedAt)
        .all(&ctx.db)
        .await?;

    format::view(
        &v,
        "audio/albums.html",
        json!({
            "albums": albums,
            "logged_in_admin": logged_in_admin(&ctx, &jar).await,
            "lang": current_lang(&jar),
        }),
    )
}

#[debug_handler]
async fn public_album(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Path(slug): Path<String>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let album = audio_albums::Entity::find()
        .filter(audio_albums::Column::Slug.eq(slug))
        .filter(audio_albums::Column::Published.eq(true))
        .one(&ctx.db)
        .await?
        .ok_or_else(|| Error::NotFound)?;

    let mut active = album.clone().into_active_model();
    active.view_count = Set(album.view_count + 1);
    let album = active.update(&ctx.db).await?;

    let tracks = audio_tracks::Entity::find()
        .filter(audio_tracks::Column::AlbumId.eq(album.id))
        .filter(audio_tracks::Column::Published.eq(true))
        .order_by_asc(audio_tracks::Column::TrackNumber)
        .order_by_asc(audio_tracks::Column::Title)
        .all(&ctx.db)
        .await?;

    format::view(
        &v,
        "audio/album.html",
        json!({
            "album": album,
            "tracks": tracks,
            "logged_in_admin": logged_in_admin(&ctx, &jar).await,
            "lang": current_lang(&jar),
        }),
    )
}

/// Published tracks of an album as JSON, so the audio listing page can
/// queue a whole album without navigating to its detail page.
#[debug_handler]
async fn public_album_tracks(
    Path(slug): Path<String>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let album = audio_albums::Entity::find()
        .filter(audio_albums::Column::Slug.eq(slug))
        .filter(audio_albums::Column::Published.eq(true))
        .one(&ctx.db)
        .await?
        .ok_or_else(|| Error::NotFound)?;

    let tracks = audio_tracks::Entity::find()
        .filter(audio_tracks::Column::AlbumId.eq(album.id))
        .filter(audio_tracks::Column::Published.eq(true))
        .order_by_asc(audio_tracks::Column::TrackNumber)
        .order_by_asc(audio_tracks::Column::Title)
        .all(&ctx.db)
        .await?;

    let items: Vec<serde_json::Value> = tracks
        .into_iter()
        .map(|t| {
            json!({
                "src": format!("/audio/tracks/{}/stream", t.id),
                "title": t.title,
            })
        })
        .collect();

    format::json(json!({ "tracks": items }))
}

#[debug_handler]
async fn public_tracks(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let tracks = audio_tracks::Entity::find()
        .filter(audio_tracks::Column::Published.eq(true))
        .order_by_desc(audio_tracks::Column::PublishedAt)
        .order_by_desc(audio_tracks::Column::CreatedAt)
        .all(&ctx.db)
        .await?;

    format::view(
        &v,
        "audio/tracks.html",
        json!({
            "tracks": tracks,
            "logged_in_admin": logged_in_admin(&ctx, &jar).await,
            "lang": current_lang(&jar),
        }),
    )
}

#[debug_handler]
async fn admin_albums(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    let albums = audio_albums::Entity::find()
        .order_by_desc(audio_albums::Column::CreatedAt)
        .all(&ctx.db)
        .await?;
    let mut rows = Vec::new();
    for album in albums {
        let track_count = audio_tracks::Entity::find()
            .filter(audio_tracks::Column::AlbumId.eq(album.id))
            .count(&ctx.db)
            .await?;
        rows.push(json!({ "album": album, "track_count": track_count }));
    }

    format::view(
        &v,
        "admin/audio/albums.html",
        json!({ "albums": rows, "lang": current_lang(&jar) }),
    )
}

#[debug_handler]
async fn admin_tracks(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    let tracks = audio_tracks::Entity::find()
        .order_by_desc(audio_tracks::Column::CreatedAt)
        .all(&ctx.db)
        .await?;

    format::view(
        &v,
        "admin/audio/songs.html",
        json!({ "tracks": tracks, "lang": current_lang(&jar) }),
    )
}

#[debug_handler]
async fn admin_album_new(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    let available_tracks = audio_tracks::Entity::find()
        .filter(audio_tracks::Column::AlbumId.is_null())
        .order_by_asc(audio_tracks::Column::Title)
        .all(&ctx.db)
        .await?;
    format::view(
        &v,
        "admin/audio/new_album.html",
        json!({
            "available_tracks": available_tracks,
            "lang": current_lang(&jar),
        }),
    )
}

#[debug_handler]
async fn admin_album_create(
    auth: auth::JWT,
    State(ctx): State<AppContext>,
    multipart: Multipart,
) -> Result<Response> {
    let admin_user = admin::current_admin(auth, &ctx).await?;
    let form = read_album_form(multipart).await?;

    let title = form
        .title
        .ok_or_else(|| Error::BadRequest("album title is required".to_string()))?;
    let release_date = form
        .release_date
        .and_then(|date| NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok());

    // Store the uploaded cover (if any) and keep its filename as cover_image_id.
    let cover_image_id = match form.cover {
        Some(data) => {
            let extension = detect_image_extension(&data)?;
            Some(store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?)
        }
        None => None,
    };

    let album = audio_albums::ActiveModel {
        id: Set(Uuid::new_v4()),
        title: Set(title.clone()),
        slug: Set(unique_album_slug(&ctx, &title).await?),
        description: Set(form.description),
        cover_image_id: Set(cover_image_id),
        artist: Set(form.artist),
        release_date: Set(release_date),
        published: Set(form.published),
        uploader_id: Set(admin_user.id),
        view_count: Set(0),
        published_at: Set(form.published.then(|| Utc::now().into())),
        ..Default::default()
    }
    .insert(&ctx.db)
    .await?;

    for track_id in form.track_ids {
        let track = track_by_id(&ctx, track_id).await?;
        if track.album_id.is_some() {
            return Err(Error::BadRequest(
                "selected song already belongs to an album".to_string(),
            ));
        }

        let mut active = track.into_active_model();
        active.album_id = Set(Some(album.id));
        active.update(&ctx.db).await?;
    }

    format::redirect("/admin/audio/albums")
}

#[debug_handler]
async fn admin_album_tracks(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Path(album_id): Path<Uuid>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    let album = album_by_id(&ctx, album_id).await?;
    let tracks = audio_tracks::Entity::find()
        .filter(audio_tracks::Column::AlbumId.eq(album_id))
        .order_by_asc(audio_tracks::Column::TrackNumber)
        .order_by_asc(audio_tracks::Column::Title)
        .all(&ctx.db)
        .await?;
    let available_tracks = audio_tracks::Entity::find()
        .filter(audio_tracks::Column::AlbumId.is_null())
        .order_by_asc(audio_tracks::Column::Title)
        .all(&ctx.db)
        .await?;

    format::view(
        &v,
        "admin/audio/tracks.html",
        json!({
            "album": album,
            "tracks": tracks,
            "available_tracks": available_tracks,
            "lang": current_lang(&jar),
        }),
    )
}

#[debug_handler]
async fn admin_album_add_track(
    auth: auth::JWT,
    Path(album_id): Path<Uuid>,
    State(ctx): State<AppContext>,
    Form(params): Form<AlbumSongForm>,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    album_by_id(&ctx, album_id).await?;
    let track = track_by_id(&ctx, params.track_id).await?;

    if track.album_id.is_some() {
        return Err(Error::BadRequest(
            "song already belongs to an album".to_string(),
        ));
    }

    let mut active = track.into_active_model();
    active.album_id = Set(Some(album_id));
    active.update(&ctx.db).await?;

    format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
}

#[debug_handler]
async fn admin_track_remove_from_album(
    auth: auth::JWT,
    Path(id): Path<Uuid>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    let track = track_by_id(&ctx, id).await?;
    let album_id = track.album_id;
    let mut active = track.into_active_model();
    active.album_id = Set(None);
    active.track_number = Set(None);
    active.update(&ctx.db).await?;

    if let Some(album_id) = album_id {
        format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
    } else {
        format::redirect("/admin/audio/tracks")
    }
}

#[debug_handler]
async fn admin_track_upload_form(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    Path(album_id): Path<Uuid>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    format::view(
        &v,
        "admin/audio/upload_track.html",
        json!({ "album": album_by_id(&ctx, album_id).await?, "lang": current_lang(&jar) }),
    )
}

#[debug_handler]
async fn admin_song_upload_form(
    auth: auth::JWT,
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    format::view(
        &v,
        "admin/audio/upload_track.html",
        json!({ "album": null, "lang": current_lang(&jar) }),
    )
}

async fn create_uploaded_track(
    ctx: &AppContext,
    album_id: Option<Uuid>,
    multipart: Multipart,
) -> Result<audio_tracks::Model> {
    let (data, title, track_number, featured, published) = read_track_upload(multipart).await?;
    let extension = detect_audio_extension(&data)?;
    let filename = store_upload(ctx, AUDIO_STORAGE_DIR, extension, data).await?;
    let title = title.unwrap_or_else(|| {
        filename
            .trim_end_matches(&format!(".{extension}"))
            .to_string()
    });

    audio_tracks::ActiveModel {
        id: Set(Uuid::new_v4()),
        album_id: Set(album_id),
        title: Set(title.clone()),
        slug: Set(unique_track_slug(ctx, album_id, &title).await?),
        audio_file_id: Set(filename),
        track_number: Set(track_number),
        duration: Set(None),
        featured: Set(featured),
        published: Set(published),
        play_count: Set(0),
        published_at: Set(published.then(|| Utc::now().into())),
        ..Default::default()
    }
    .insert(&ctx.db)
    .await
    .map_err(Error::from)
}

#[debug_handler]
async fn admin_track_upload(
    auth: auth::JWT,
    Path(album_id): Path<Uuid>,
    State(ctx): State<AppContext>,
    multipart: Multipart,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    album_by_id(&ctx, album_id).await?;

    create_uploaded_track(&ctx, Some(album_id), multipart).await?;

    format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
}

#[debug_handler]
async fn admin_song_upload(
    auth: auth::JWT,
    State(ctx): State<AppContext>,
    multipart: Multipart,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    create_uploaded_track(&ctx, None, multipart).await?;
    format::redirect("/admin/audio/tracks")
}

#[debug_handler]
async fn admin_track_delete(
    auth: auth::JWT,
    Path(id): Path<Uuid>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    let track = track_by_id(&ctx, id).await?;
    let album_id = track.album_id;
    let _ = ctx
        .storage
        .delete(StdPath::new(&format!(
            "{AUDIO_STORAGE_DIR}/{}",
            track.audio_file_id
        )))
        .await;
    track.delete(&ctx.db).await?;
    if let Some(album_id) = album_id {
        format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
    } else {
        format::redirect("/admin/audio/tracks")
    }
}

#[debug_handler]
async fn admin_track_publish(
    auth: auth::JWT,
    Path(id): Path<Uuid>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    let track = track_by_id(&ctx, id).await?;
    let album_id = track.album_id;
    let mut active = track.into_active_model();
    active.published = Set(true);
    active.published_at = Set(Some(Utc::now().into()));
    active.update(&ctx.db).await?;

    if let Some(album_id) = album_id {
        format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
    } else {
        format::redirect("/admin/audio/tracks")
    }
}

#[debug_handler]
async fn admin_track_unpublish(
    auth: auth::JWT,
    Path(id): Path<Uuid>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    admin::current_admin(auth, &ctx).await?;
    let track = track_by_id(&ctx, id).await?;
    let album_id = track.album_id;
    let mut active = track.into_active_model();
    active.published = Set(false);
    active.published_at = Set(None);
    active.update(&ctx.db).await?;

    if let Some(album_id) = album_id {
        format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
    } else {
        format::redirect("/admin/audio/tracks")
    }
}

async fn stream_audio_file(
    config: &Config,
    filename: &str,
    headers: &HeaderMap,
) -> Result<Response> {
    let filename = safe_filename(filename)?;
    let path = uploads_root(config)?.join(AUDIO_STORAGE_DIR).join(filename);
    let mut file = tokio::fs::File::open(&path)
        .await
        .map_err(|_| Error::NotFound)?;
    let total_len = file.metadata().await?.len();
    let extension = filename.rsplit('.').next().unwrap_or("mp3");
    let content_type = audio_content_type(extension);

    let (status, start, end) = parse_range(headers, total_len)?;
    let len = end.saturating_sub(start) + 1;
    file.seek(SeekFrom::Start(start)).await?;

    let mut body = vec![0; len as usize];
    file.read_exact(&mut body).await?;

    let mut builder = Response::builder()
        .status(status)
        .header(header::CONTENT_TYPE, content_type)
        .header(header::ACCEPT_RANGES, "bytes")
        .header(header::CONTENT_LENGTH, len.to_string());

    if status == StatusCode::PARTIAL_CONTENT {
        builder = builder.header(
            header::CONTENT_RANGE,
            format!("bytes {start}-{end}/{total_len}"),
        );
    }

    builder.body(Body::from(body)).map_err(Error::from)
}

fn parse_range(headers: &HeaderMap, total_len: u64) -> Result<(StatusCode, u64, u64)> {
    if total_len == 0 {
        return Ok((StatusCode::OK, 0, 0));
    }

    let Some(range_header) = headers.get(header::RANGE) else {
        return Ok((StatusCode::OK, 0, total_len - 1));
    };
    let range = range_header
        .to_str()
        .map_err(|_| Error::BadRequest("invalid range header".to_string()))?;
    let Some(range) = range.strip_prefix("bytes=") else {
        return Err(Error::BadRequest("invalid range header".to_string()));
    };
    let Some((start, end)) = range.split_once('-') else {
        return Err(Error::BadRequest("invalid range header".to_string()));
    };

    let suffix_range = start.is_empty();
    let start = if suffix_range {
        let suffix = u64::from_str(end)
            .map_err(|_| Error::BadRequest("invalid range header".to_string()))?;
        total_len.saturating_sub(suffix)
    } else {
        u64::from_str(start).map_err(|_| Error::BadRequest("invalid range header".to_string()))?
    };
    let end = if suffix_range || end.is_empty() {
        total_len - 1
    } else {
        u64::from_str(end).map_err(|_| Error::BadRequest("invalid range header".to_string()))?
    };

    if start >= total_len || end >= total_len || start > end {
        return Err(Error::CustomError(
            StatusCode::RANGE_NOT_SATISFIABLE,
            loco_rs::controller::ErrorDetail::new("range-not-satisfiable", "range not satisfiable"),
        ));
    }

    Ok((StatusCode::PARTIAL_CONTENT, start, end))
}

#[debug_handler]
async fn raw_audio_stream(
    Path(filename): Path<String>,
    headers: HeaderMap,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    stream_audio_file(&ctx.config, &filename, &headers).await
}

#[debug_handler]
async fn track_stream(
    Path(id): Path<Uuid>,
    headers: HeaderMap,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let track = track_by_id(&ctx, id).await?;
    if !track.published {
        return Err(Error::NotFound);
    }

    let mut active = track.clone().into_active_model();
    active.play_count = Set(track.play_count + 1);
    let track = active.update(&ctx.db).await?;

    stream_audio_file(&ctx.config, &track.audio_file_id, &headers).await
}

pub fn routes() -> Routes {
    Routes::new()
        .add(
            "/images/upload",
            post(image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
        )
        .add("/images/{filename}", get(image_serve))
        .add(
            "/audio/upload",
            post(audio_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)),
        )
        .add("/audio/stream/{filename}", get(raw_audio_stream))
        .add("/audio/albums", get(public_albums))
        .add("/audio/albums/{slug}", get(public_album))
        .add("/audio/albums/{slug}/tracks", get(public_album_tracks))
        .add("/audio/tracks", get(public_tracks))
        .add("/audio/tracks/{id}/stream", get(track_stream))
        .add("/admin/audio/albums", get(admin_albums))
        .add("/admin/audio/albums/create", get(admin_album_new))
        .add(
            "/admin/audio/albums/create",
            post(admin_album_create).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
        )
        .add("/admin/audio/tracks", get(admin_tracks))
        .add("/admin/audio/tracks/upload", get(admin_song_upload_form))
        .add(
            "/admin/audio/tracks/upload-file",
            post(admin_song_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)),
        )
        .add(
            "/admin/audio/albums/{album_id}/tracks",
            get(admin_album_tracks),
        )
        .add(
            "/admin/audio/albums/{album_id}/tracks/add",
            post(admin_album_add_track),
        )
        .add(
            "/admin/audio/albums/{album_id}/tracks/upload",
            get(admin_track_upload_form),
        )
        .add(
            "/admin/audio/albums/{album_id}/tracks/upload-file",
            post(admin_track_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)),
        )
        .add(
            "/admin/audio/tracks/{id}/publish",
            post(admin_track_publish),
        )
        .add(
            "/admin/audio/tracks/{id}/unpublish",
            post(admin_track_unpublish),
        )
        .add(
            "/admin/audio/tracks/{id}/remove-from-album",
            post(admin_track_remove_from_album),
        )
        .add("/admin/audio/tracks/{id}/delete", post(admin_track_delete))
}
