use std::collections::HashMap;

use argon2::{Argon2, PasswordHash, PasswordVerifier};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use axum_extra::extract::cookie::SignedCookieJar;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::error::{ApiError, ApiResult};
use crate::models::{PhotoStatus, Share, Verdict};
use crate::state::AppState;

pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> {
    let share: Option<Share> = sqlx::query_as("select * from shares where token = $1")
        .bind(token)
        .fetch_optional(&state.db)
        .await?;
    let share = share.ok_or_else(ApiError::not_found)?;
    if share.expires_at.map(|e| e < Utc::now()).unwrap_or(false) {
        return Err(ApiError::gone("this link has expired"));
    }
    Ok(share)
}

/// One signed cookie lists every share id this browser has been granted
/// (by viewing a passwordless share or unlocking a protected one). Image and
/// download requests are authorized from it, so URLs carry no token.
const SHARE_ACCESS_COOKIE: &str = "photos_shares";
const MAX_REMEMBERED_SHARES: usize = 20;

pub fn share_ids_from_jar(jar: &SignedCookieJar) -> Vec<Uuid> {
    jar.get(SHARE_ACCESS_COOKIE)
        .map(|c| {
            c.value()
                .split(',')
                .filter_map(|s| Uuid::parse_str(s).ok())
                .collect()
        })
        .unwrap_or_default()
}

/// Add a share to the browser's access cookie (most recent first). Always
/// re-issues the cookie so the 30-day expiry slides on every visit instead of
/// being fixed at the first one.
fn grant_access(state: &AppState, jar: SignedCookieJar, share_id: Uuid) -> SignedCookieJar {
    let mut ids = share_ids_from_jar(&jar);
    ids.retain(|id| *id != share_id);
    ids.insert(0, share_id);
    ids.truncate(MAX_REMEMBERED_SHARES);
    let value = ids
        .iter()
        .map(Uuid::to_string)
        .collect::<Vec<_>>()
        .join(",");
    let mut cookie =
        crate::auth::base_cookie(SHARE_ACCESS_COOKIE, value, state.config.cookie_secure());
    cookie.set_max_age(time::Duration::days(30));
    jar.add(cookie)
}

pub fn is_unlocked(jar: &SignedCookieJar, share: &Share) -> bool {
    share.password_hash.is_none() || share_ids_from_jar(jar).contains(&share.id)
}

/// Cookie-based authorization for image/download requests, which carry no
/// share token: any remembered, still-valid share covering the album grants
/// access (and must allow downloads when `need_download`).
pub async fn authorize_album_via_cookie(
    state: &AppState,
    jar: &SignedCookieJar,
    album_id: Uuid,
    need_download: bool,
) -> Result<(), ApiError> {
    let ids = share_ids_from_jar(jar);
    if ids.is_empty() {
        return Err(ApiError::unauthorized());
    }
    let shares: Vec<Share> =
        sqlx::query_as("select * from shares where album_id = $1 and id = any($2)")
            .bind(album_id)
            .bind(&ids)
            .fetch_all(&state.db)
            .await?;
    let now = Utc::now();
    let valid: Vec<&Share> = shares
        .iter()
        .filter(|s| s.expires_at.map(|e| e > now).unwrap_or(true))
        .collect();
    if valid.is_empty() {
        return Err(ApiError::unauthorized());
    }
    if need_download && !valid.iter().any(|s| s.allow_download) {
        return Err(ApiError::forbidden("downloads are disabled for this link"));
    }
    Ok(())
}

/// The one place client-share access policy lives: token valid + not expired,
/// unlocked, and (for download endpoints) downloads enabled.
pub async fn authorize_share(
    state: &AppState,
    jar: &SignedCookieJar,
    token: &str,
    need_download: bool,
) -> Result<Share, ApiError> {
    let share = load_share(state, token).await?;
    if !is_unlocked(jar, &share) {
        return Err(ApiError::unauthorized());
    }
    if need_download && !share.allow_download {
        return Err(ApiError::forbidden("downloads are disabled for this link"));
    }
    Ok(share)
}

fn require_unlocked(jar: &SignedCookieJar, share: &Share) -> Result<(), ApiError> {
    if is_unlocked(jar, share) {
        Ok(())
    } else {
        Err(ApiError(
            StatusCode::UNAUTHORIZED,
            "password required".into(),
        ))
    }
}

#[derive(Serialize, sqlx::FromRow)]
struct ClientPhotoRow {
    id: Uuid,
    filename: String,
    size_bytes: i64,
    width: Option<i32>,
    height: Option<i32>,
    taken_at: Option<DateTime<Utc>>,
    processed_at: Option<DateTime<Utc>>,
    my_rating: Option<i32>,
    my_verdict: Option<String>,
}

#[derive(Serialize)]
struct ClientPhoto {
    #[serde(flatten)]
    row: ClientPhotoRow,
    my_tags: Vec<String>,
}

#[derive(Serialize)]
pub struct ShareView {
    label: String,
    album_name: String,
    album_description: String,
    locked: bool,
    allow_download: bool,
    photos: Vec<ClientPhoto>,
}

pub async fn get_share(
    State(state): State<AppState>,
    Path(token): Path<String>,
    jar: SignedCookieJar,
) -> ApiResult<(SignedCookieJar, Json<ShareView>)> {
    let share = load_share(&state, &token).await?;
    let (album_name, album_description): (String, String) =
        sqlx::query_as("select name, description from albums where id = $1")
            .bind(share.album_id)
            .fetch_one(&state.db)
            .await?;

    if !is_unlocked(&jar, &share) {
        return Ok((
            jar,
            Json(ShareView {
                label: share.label,
                album_name,
                album_description,
                locked: true,
                allow_download: share.allow_download,
                photos: vec![],
            }),
        ));
    }
    // Grant this browser image/download access for the share's album.
    let jar = grant_access(&state, jar, share.id);

    let rows: Vec<ClientPhotoRow> = sqlx::query_as(
        "select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating, v.verdict as my_verdict
         from photos p
         left join ratings r on r.photo_id = p.id and r.share_id = $2
         left join verdicts v on v.photo_id = p.id and v.share_id = $2
         where p.album_id = $1 and p.status = $3
         order by coalesce(p.taken_at, p.created_at), p.filename",
    )
    .bind(share.album_id)
    .bind(share.id)
    .bind(PhotoStatus::Ready.as_str())
    .fetch_all(&state.db)
    .await?;

    let tag_rows: Vec<(Uuid, String)> =
        sqlx::query_as("select photo_id, tag from tags where share_id = $1 order by created_at")
            .bind(share.id)
            .fetch_all(&state.db)
            .await?;
    let mut tag_map: HashMap<Uuid, Vec<String>> = HashMap::new();
    for (photo_id, tag) in tag_rows {
        tag_map.entry(photo_id).or_default().push(tag);
    }

    let photos = rows
        .into_iter()
        .map(|row| {
            let my_tags = tag_map.remove(&row.id).unwrap_or_default();
            ClientPhoto { row, my_tags }
        })
        .collect();

    Ok((
        jar,
        Json(ShareView {
            label: share.label,
            album_name,
            album_description,
            locked: false,
            allow_download: share.allow_download,
            photos,
        }),
    ))
}

#[derive(Deserialize)]
pub struct UnlockBody {
    password: String,
}

const MAX_UNLOCK_ATTEMPTS: i32 = 10;

pub async fn unlock(
    State(state): State<AppState>,
    Path(token): Path<String>,
    jar: SignedCookieJar,
    Json(body): Json<UnlockBody>,
) -> ApiResult<(SignedCookieJar, StatusCode)> {
    let share = load_share(&state, &token).await?;
    let Some(hash) = share.password_hash.clone() else {
        return Ok((jar, StatusCode::NO_CONTENT));
    };
    if let Some(locked_until) = share.locked_until {
        if locked_until > Utc::now() {
            return Err(ApiError(
                StatusCode::TOO_MANY_REQUESTS,
                "too many attempts — try again in a few minutes".into(),
            ));
        }
        // The lock window has passed: grant a fresh set of attempts, so a
        // legitimate client isn't re-locked by their next single typo.
        sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
            .bind(share.id)
            .execute(&state.db)
            .await?;
    }
    // Argon2 is deliberately slow; keep it off the async runtime threads.
    let password = body.password;
    let verified = tokio::task::spawn_blocking(move || {
        let parsed = PasswordHash::new(&hash).map_err(|e| anyhow::anyhow!("bad hash: {e}"))?;
        Ok::<_, anyhow::Error>(
            Argon2::default()
                .verify_password(password.as_bytes(), &parsed)
                .is_ok(),
        )
    })
    .await
    .map_err(|e| anyhow::anyhow!("verify task failed: {e}"))??;

    if !verified {
        sqlx::query(
            "update shares
             set failed_attempts = failed_attempts + 1,
                 locked_until = case when failed_attempts + 1 >= $2
                                     then now() + interval '15 minutes'
                                     else locked_until end
             where id = $1",
        )
        .bind(share.id)
        .bind(MAX_UNLOCK_ATTEMPTS)
        .execute(&state.db)
        .await?;
        return Err(ApiError(StatusCode::UNAUTHORIZED, "wrong password".into()));
    }
    sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
        .bind(share.id)
        .execute(&state.db)
        .await?;
    Ok((grant_access(&state, jar, share.id), StatusCode::NO_CONTENT))
}

async fn share_photo(
    state: &AppState,
    jar: &SignedCookieJar,
    token: &str,
    photo_id: Uuid,
) -> Result<Share, ApiError> {
    let share = load_share(state, token).await?;
    require_unlocked(jar, &share)?;
    let exists: Option<(Uuid,)> = sqlx::query_as(
        "select id from photos where id = $1 and album_id = $2 and status = $3",
    )
    .bind(photo_id)
    .bind(share.album_id)
    .bind(PhotoStatus::Ready.as_str())
    .fetch_optional(&state.db)
    .await?;
    if exists.is_none() {
        return Err(ApiError::not_found());
    }
    Ok(share)
}

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

pub async fn set_rating(
    State(state): State<AppState>,
    Path((token, photo_id)): Path<(String, Uuid)>,
    jar: SignedCookieJar,
    Json(body): Json<RatingBody>,
) -> ApiResult<Json<serde_json::Value>> {
    if !(0..=5).contains(&body.rating) {
        return Err(ApiError::bad_request("rating must be between 0 and 5"));
    }
    let share = share_photo(&state, &jar, &token, photo_id).await?;
    if body.rating == 0 {
        sqlx::query("delete from ratings where share_id = $1 and photo_id = $2")
            .bind(share.id)
            .bind(photo_id)
            .execute(&state.db)
            .await?;
    } else {
        sqlx::query(
            "insert into ratings (share_id, photo_id, rating) values ($1, $2, $3)
             on conflict (share_id, photo_id)
             do update set rating = excluded.rating, updated_at = now()",
        )
        .bind(share.id)
        .bind(photo_id)
        .bind(body.rating)
        .execute(&state.db)
        .await?;
    }
    Ok(Json(serde_json::json!({ "ok": true })))
}

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

pub async fn set_verdict(
    State(state): State<AppState>,
    Path((token, photo_id)): Path<(String, Uuid)>,
    jar: SignedCookieJar,
    Json(body): Json<VerdictBody>,
) -> ApiResult<Json<serde_json::Value>> {
    let share = share_photo(&state, &jar, &token, photo_id).await?;
    match body.verdict {
        None => {
            sqlx::query("delete from verdicts where share_id = $1 and photo_id = $2")
                .bind(share.id)
                .bind(photo_id)
                .execute(&state.db)
                .await?;
        }
        Some(verdict) => {
            sqlx::query(
                "insert into verdicts (share_id, photo_id, verdict) values ($1, $2, $3)
                 on conflict (share_id, photo_id)
                 do update set verdict = excluded.verdict, updated_at = now()",
            )
            .bind(share.id)
            .bind(photo_id)
            .bind(verdict.as_str())
            .execute(&state.db)
            .await?;
        }
    }
    Ok(Json(serde_json::json!({ "ok": true })))
}

#[derive(Deserialize)]
pub struct TagsBody {
    tags: Vec<String>,
}

pub async fn set_tags(
    State(state): State<AppState>,
    Path((token, photo_id)): Path<(String, Uuid)>,
    jar: SignedCookieJar,
    Json(body): Json<TagsBody>,
) -> ApiResult<Json<serde_json::Value>> {
    let share = share_photo(&state, &jar, &token, photo_id).await?;

    let mut tags: Vec<String> = Vec::new();
    for tag in body.tags {
        let tag = tag.trim().to_lowercase();
        if tag.is_empty() || tag.chars().count() > 40 {
            continue;
        }
        if !tags.contains(&tag) {
            tags.push(tag);
        }
        if tags.len() >= 20 {
            break;
        }
    }

    let mut tx = state.db.begin().await?;
    sqlx::query("delete from tags where share_id = $1 and photo_id = $2")
        .bind(share.id)
        .bind(photo_id)
        .execute(&mut *tx)
        .await?;
    if !tags.is_empty() {
        sqlx::query("insert into tags (share_id, photo_id, tag) select $1, $2, unnest($3::text[])")
            .bind(share.id)
            .bind(photo_id)
            .bind(&tags)
            .execute(&mut *tx)
            .await?;
    }
    tx.commit().await?;
    Ok(Json(serde_json::json!({ "ok": true, "tags": tags })))
}
