use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::HeaderMap;
use axum::Json;
use futures::StreamExt;
use serde::Deserialize;
use sha2::Digest;
use tokio::io::AsyncWriteExt;
use uuid::Uuid;

use crate::error::{ApiError, ApiResult};
use crate::jobs;
use crate::models::{FeedbackAggregate, JobKind, Photo, PhotoStatus, Verdict};
use crate::s3;
use crate::state::AppState;

#[derive(Deserialize)]
pub struct UploadQuery {
    filename: String,
}

fn sanitize_filename(raw: &str) -> Result<String, ApiError> {
    let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
    let cleaned: String = base
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ' ' | '(' | ')') {
                c
            } else {
                '_'
            }
        })
        .collect();
    let cleaned = cleaned.trim().trim_start_matches('.').to_string();
    if cleaned.is_empty() {
        return Err(ApiError::bad_request("invalid filename"));
    }
    Ok(cleaned.chars().take(150).collect())
}

pub async fn upload(
    State(state): State<AppState>,
    Path(album_id): Path<Uuid>,
    Query(query): Query<UploadQuery>,
    headers: HeaderMap,
    body: Body,
) -> ApiResult<Json<Photo>> {
    let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
        .bind(album_id)
        .fetch_optional(&state.db)
        .await?;
    if album_exists.is_none() {
        return Err(ApiError::not_found());
    }

    let filename = sanitize_filename(&query.filename)?;
    let content_type = headers
        .get(CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("application/octet-stream")
        .to_string();

    // Stream the request body to a temp file so large raws never sit in
    // memory, hashing as it flows: sha256 for duplicate detection, crc32 for
    // spool-free zip downloads.
    let dir = tempfile::tempdir().map_err(anyhow::Error::from)?;
    let path = dir.path().join("upload.bin");
    let mut file = tokio::fs::File::create(&path)
        .await
        .map_err(anyhow::Error::from)?;
    let mut stream = body.into_data_stream();
    let mut sha = sha2::Sha256::new();
    let mut crc = crc32fast::Hasher::new();
    let mut size: i64 = 0;
    while let Some(chunk) = stream.next().await {
        let chunk = chunk.map_err(|e| ApiError::bad_request(format!("upload aborted: {e}")))?;
        size += chunk.len() as i64;
        sha.update(&chunk);
        crc.update(&chunk);
        file.write_all(&chunk).await.map_err(anyhow::Error::from)?;
    }
    file.flush().await.map_err(anyhow::Error::from)?;
    drop(file);
    if size == 0 {
        return Err(ApiError::bad_request("empty upload"));
    }
    let sha256 = hex::encode(sha.finalize());
    let crc32 = i64::from(crc.finalize());

    // Same content already in this album? Return it — re-dragging a folder
    // after a partial upload just fills the gaps instead of duplicating.
    let existing: Option<Photo> =
        sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
            .bind(album_id)
            .bind(&sha256)
            .fetch_optional(&state.db)
            .await?;
    if let Some(existing) = existing {
        return Ok(Json(existing));
    }

    // Upload to S3 first, then create the row and enqueue processing in one
    // transaction — a photo row can never exist without its job, and a failed
    // transaction (e.g. album deleted mid-upload) cleans up the S3 object.
    let photo_id = Uuid::new_v4();
    let key = s3::original_key(photo_id, &filename);
    s3::put_file(&state, &key, &path, &content_type).await?;

    let result: Result<(sqlx::Transaction<'static, sqlx::Postgres>, Photo), sqlx::Error> =
        async {
            let mut tx = state.db.begin().await?;
            let photo: Photo = sqlx::query_as(
                "insert into photos (id, album_id, filename, content_type, size_bytes, status, sha256, crc32)
                 values ($1, $2, $3, $4, $5, $6, $7, $8)
                 returning *",
            )
            .bind(photo_id)
            .bind(album_id)
            .bind(&filename)
            .bind(&content_type)
            .bind(size)
            .bind(PhotoStatus::Uploaded.as_str())
            .bind(&sha256)
            .bind(crc32)
            .fetch_one(&mut *tx)
            .await?;
            jobs::enqueue(
                &mut *tx,
                JobKind::ProcessPhoto,
                serde_json::json!({ "photo_id": photo_id }),
            )
            .await?;
            Ok((tx, photo))
        }
        .await;

    let (tx, photo) = match result {
        Ok(pair) => pair,
        Err(e) => {
            // Nothing committed — safe to remove the freshly stored original.
            if let Err(cleanup) = s3::delete_prefix(&state, &s3::photo_prefix(photo_id)).await {
                tracing::error!("failed to clean up s3 after aborted upload: {cleanup:#}");
            }
            // Concurrent identical upload beat us to the unique index — hand
            // back the winner instead of an error.
            let unique_violation = matches!(
                &e,
                sqlx::Error::Database(db) if db.code().as_deref() == Some("23505")
            );
            if unique_violation {
                let winner: Option<Photo> =
                    sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
                        .bind(album_id)
                        .bind(&sha256)
                        .fetch_optional(&state.db)
                        .await?;
                if let Some(winner) = winner {
                    return Ok(Json(winner));
                }
            }
            return Err(e.into());
        }
    };
    if let Err(e) = tx.commit().await {
        // A failed COMMIT is ambiguous (it may have been applied); deleting
        // the S3 object here could destroy a committed photo's original, so
        // leave it — an orphaned object beats data loss.
        tracing::error!(
            "commit failed after upload of photo {photo_id}; leaving s3 object in place: {e}"
        );
        return Err(anyhow::Error::from(e).context("saving upload").into());
    }
    Ok(Json(photo))
}

/// Client-side dedup support: lets the uploader skip transferring files whose
/// content already exists in the album.
pub async fn by_hash(
    State(state): State<AppState>,
    Path((album_id, sha256)): Path<(Uuid, String)>,
) -> ApiResult<Json<Photo>> {
    let photo: Option<Photo> =
        sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
            .bind(album_id)
            .bind(&sha256)
            .fetch_optional(&state.db)
            .await?;
    photo.map(Json).ok_or_else(ApiError::not_found)
}

/// The one deletion path: delete the given photos and enqueue their S3
/// cleanup jobs in the same transaction. Prefixes come from s3::photo_prefix
/// so the key layout has a single source of truth. Returns how many photos
/// were actually deleted.
pub(super) async fn delete_with_cleanup(
    tx: &mut sqlx::PgConnection,
    ids: &[Uuid],
) -> Result<u64, sqlx::Error> {
    let deleted: Vec<Uuid> = sqlx::query_scalar("delete from photos where id = any($1) returning id")
        .bind(ids)
        .fetch_all(&mut *tx)
        .await?;
    if deleted.is_empty() {
        return Ok(0);
    }
    let prefixes: Vec<String> = deleted.iter().map(|id| s3::photo_prefix(*id)).collect();
    sqlx::query(
        "insert into jobs (kind, payload)
         select $1, jsonb_build_object('prefix', p)
         from unnest($2::text[]) as p",
    )
    .bind(JobKind::DeleteS3Prefix.as_str())
    .bind(&prefixes)
    .execute(&mut *tx)
    .await?;
    Ok(deleted.len() as u64)
}

pub async fn delete(
    State(state): State<AppState>,
    Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
    let mut tx = state.db.begin().await?;
    if delete_with_cleanup(&mut tx, &[photo_id]).await? == 0 {
        return Err(ApiError::not_found());
    }
    tx.commit().await?;
    Ok(Json(serde_json::json!({ "ok": true })))
}

#[derive(Deserialize)]
pub struct DeleteManyBody {
    ids: Vec<Uuid>,
}

pub async fn delete_many(
    State(state): State<AppState>,
    Json(body): Json<DeleteManyBody>,
) -> ApiResult<Json<serde_json::Value>> {
    if body.ids.is_empty() {
        return Err(ApiError::bad_request("ids must not be empty"));
    }
    let mut tx = state.db.begin().await?;
    let deleted = delete_with_cleanup(&mut tx, &body.ids).await?;
    // Consistent with the single-photo route: deleting nothing is an error,
    // not a silent success (e.g. a stale tab re-deleting already-gone photos).
    if deleted == 0 {
        return Err(ApiError::not_found());
    }
    tx.commit().await?;
    Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted })))
}

async fn feedback_aggregate(
    db: &sqlx::PgPool,
    photo_id: Uuid,
) -> Result<FeedbackAggregate, sqlx::Error> {
    let (top, accepts, rejects, owner_rating, owner_verdict): (
        Option<i32>,
        i64,
        i64,
        Option<i32>,
        Option<String>,
    ) = sqlx::query_as(
        "select
            (select max(r.rating) from ratings r where r.photo_id = p.id),
            (select count(*) from verdicts v where v.photo_id = p.id and v.verdict = 'accept'),
            (select count(*) from verdicts v where v.photo_id = p.id and v.verdict = 'reject'),
            p.owner_rating, p.owner_verdict
         from photos p where p.id = $1",
    )
    .bind(photo_id)
    .fetch_one(db)
    .await?;
    let mut agg = FeedbackAggregate {
        top_rating: None,
        accepts: accepts as i32,
        rejects: rejects as i32,
    };
    if let Some(top) = top {
        agg.add_rating(top);
    }
    agg.add_owner(owner_rating, owner_verdict.as_deref());
    Ok(agg)
}

#[derive(Deserialize)]
pub struct OwnerRatingBody {
    rating: i32,
}

pub async fn set_owner_rating(
    State(state): State<AppState>,
    Path(photo_id): Path<Uuid>,
    Json(body): Json<OwnerRatingBody>,
) -> ApiResult<Json<serde_json::Value>> {
    if !(0..=5).contains(&body.rating) {
        return Err(ApiError::bad_request("rating must be between 0 and 5"));
    }
    let updated = sqlx::query("update photos set owner_rating = $2 where id = $1")
        .bind(photo_id)
        .bind((body.rating > 0).then_some(body.rating))
        .execute(&state.db)
        .await?;
    if updated.rows_affected() == 0 {
        return Err(ApiError::not_found());
    }
    let aggregate = feedback_aggregate(&state.db, photo_id).await?;
    Ok(Json(serde_json::json!({ "ok": true, "aggregate": aggregate })))
}

#[derive(Deserialize)]
pub struct OwnerVerdictBody {
    verdict: Option<Verdict>,
}

pub async fn set_owner_verdict(
    State(state): State<AppState>,
    Path(photo_id): Path<Uuid>,
    Json(body): Json<OwnerVerdictBody>,
) -> ApiResult<Json<serde_json::Value>> {
    let updated = sqlx::query("update photos set owner_verdict = $2 where id = $1")
        .bind(photo_id)
        .bind(body.verdict.map(Verdict::as_str))
        .execute(&state.db)
        .await?;
    if updated.rows_affected() == 0 {
        return Err(ApiError::not_found());
    }
    let aggregate = feedback_aggregate(&state.db, photo_id).await?;
    Ok(Json(serde_json::json!({ "ok": true, "aggregate": aggregate })))
}

pub async fn reprocess(
    State(state): State<AppState>,
    Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
    let mut tx = state.db.begin().await?;
    let updated = sqlx::query("update photos set status = $2, error = null where id = $1")
        .bind(photo_id)
        .bind(PhotoStatus::Uploaded.as_str())
        .execute(&mut *tx)
        .await?;
    if updated.rows_affected() == 0 {
        return Err(ApiError::not_found());
    }
    jobs::ensure_process_photo(&mut tx, photo_id).await?;
    tx.commit().await?;
    Ok(Json(serde_json::json!({ "ok": true })))
}
