use std::collections::{BTreeMap, BTreeSet, HashMap};

use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use chrono::Utc;
use serde::Deserialize;
use uuid::Uuid;

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

#[derive(Deserialize)]
pub struct XmpQuery {
    /// Comma-separated share ids; absent = all shares.
    shares: Option<String>,
    /// Include the photographer's own feedback; defaults to true only when
    /// no share filter is given.
    own: Option<bool>,
}

#[derive(Default)]
struct Sidecar {
    agg: FeedbackAggregate,
    tags: BTreeSet<String>,
}

/// ZIP of XMP sidecars, one per photo that has feedback from the selected
/// sources — drop them next to the RAWs and Capture One / Lightroom pick up
/// rating, color label and keywords on sync.
pub async fn album_xmp(
    State(state): State<AppState>,
    Path(album_id): Path<Uuid>,
    Query(query): Query<XmpQuery>,
) -> ApiResult<Response> {
    let album: Option<(String,)> = sqlx::query_as("select name from albums where id = $1")
        .bind(album_id)
        .fetch_optional(&state.db)
        .await?;
    let Some((album_name,)) = album else {
        return Err(ApiError::not_found());
    };

    let share_filter: Option<Vec<Uuid>> = match &query.shares {
        None => None,
        Some(raw) => Some(
            raw.split(',')
                .map(str::trim)
                .filter(|s| !s.is_empty())
                .map(|s| {
                    Uuid::parse_str(s)
                        .map_err(|_| ApiError::bad_request(format!("invalid share id: {s}")))
                })
                .collect::<Result<Vec<_>, _>>()
                .map(|mut ids| {
                    ids.sort();
                    ids.dedup();
                    ids
                })?,
        ),
    };
    let include_own = query.own.unwrap_or(share_filter.is_none());
    if let Some(ids) = &share_filter {
        if ids.is_empty() && !include_own {
            return Err(ApiError::bad_request("select at least one feedback source"));
        }
        if !ids.is_empty() {
            let (found,): (i64,) =
                sqlx::query_as("select count(*) from shares where album_id = $1 and id = any($2)")
                    .bind(album_id)
                    .bind(ids)
                    .fetch_one(&state.db)
                    .await?;
            if found as usize != ids.len() {
                return Err(ApiError::not_found());
            }
        }
    }

    let photos: Vec<(Uuid, String, Option<i32>, Option<String>)> = sqlx::query_as(
        "select id, filename, owner_rating, owner_verdict
         from photos where album_id = $1 and status = $2 order by filename",
    )
    .bind(album_id)
    .bind(PhotoStatus::Ready.as_str())
    .fetch_all(&state.db)
    .await?;

    let shares = share_filter.as_deref();
    let (ratings, verdicts, tags) = tokio::try_join!(
        super::albums::feedback_rows::<i32>(&state.db, super::albums::RATING_ROWS, album_id, shares),
        super::albums::feedback_rows::<String>(&state.db, super::albums::VERDICT_ROWS, album_id, shares),
        super::albums::feedback_rows::<String>(&state.db, super::albums::TAG_ROWS, album_id, shares),
    )?;

    let mut feedback: HashMap<Uuid, Sidecar> = HashMap::new();
    for (photo_id, _, _, rating) in ratings {
        feedback.entry(photo_id).or_default().agg.add_rating(rating);
    }
    for (photo_id, _, _, verdict) in verdicts {
        feedback.entry(photo_id).or_default().agg.add_verdict(&verdict);
    }
    for (photo_id, _, _, tag) in tags {
        feedback.entry(photo_id).or_default().tags.insert(tag);
    }

    // One sidecar per BASENAME: a RAW+JPEG pair shares its .xmp, so their
    // feedback is merged — a disambiguated "name-2.xmp" would never be
    // matched by any editor and its feedback silently lost.
    let mut sidecars: BTreeMap<String, Sidecar> = BTreeMap::new();
    for (photo_id, filename, owner_rating, owner_verdict) in &photos {
        let mut f = feedback.remove(photo_id).unwrap_or_default();
        if include_own {
            f.agg.add_owner(*owner_rating, owner_verdict.as_deref());
        }
        if f.agg.is_empty() && f.tags.is_empty() {
            continue;
        }
        let base = filename.rsplit_once('.').map(|(b, _)| b).unwrap_or(filename);
        let entry = sidecars.entry(format!("{base}.xmp")).or_default();
        entry.agg.merge(&f.agg);
        entry.tags.extend(f.tags);
    }
    let entries: Vec<(String, Vec<u8>)> = sidecars
        .into_iter()
        .map(|(name, f)| {
            (
                name,
                xmp_document(
                    f.agg.top_rating.map(|r| r.clamp(1, 5)),
                    f.agg.label(),
                    &f.tags,
                )
                .into_bytes(),
            )
        })
        .collect();

    let zip = build_stored_zip(&entries);
    let safe_name: String = album_name
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
        .collect();
    Ok((
        [
            (header::CONTENT_TYPE, "application/zip".to_string()),
            (
                header::CONTENT_DISPOSITION,
                format!("attachment; filename=\"{safe_name}-xmp.zip\""),
            ),
        ],
        zip,
    )
        .into_response())
}

fn escape_xml(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn xmp_document(rating: Option<i32>, label: Option<&str>, tags: &BTreeSet<String>) -> String {
    let mut attrs = String::new();
    if let Some(rating) = rating {
        attrs.push_str(&format!(" xmp:Rating=\"{rating}\""));
    }
    if let Some(label) = label {
        attrs.push_str(&format!(" xmp:Label=\"{label}\""));
    }
    let subject = if tags.is_empty() {
        String::new()
    } else {
        let items: String = tags
            .iter()
            .map(|t| format!("<rdf:li>{}</rdf:li>", escape_xml(t)))
            .collect();
        format!("<dc:subject><rdf:Bag>{items}</rdf:Bag></dc:subject>")
    };
    format!(
        "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
         <x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n \
         <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n  \
         <rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" \
         xmlns:dc=\"http://purl.org/dc/elements/1.1/\"{attrs}>{subject}</rdf:Description>\n \
         </rdf:RDF>\n\
         </x:xmpmeta>\n\
         <?xpacket end=\"w\"?>"
    )
}

/// Minimal stored (uncompressed) in-memory ZIP — the entries are a handful
/// of small text files, the streaming writer in zip.rs is for S3 payloads.
fn build_stored_zip(entries: &[(String, Vec<u8>)]) -> Vec<u8> {
    let (dos_time, dos_date) = super::zip::dos_datetime(Utc::now());
    let mut out: Vec<u8> = Vec::new();
    let mut central: Vec<u8> = Vec::new();
    for (name, data) in entries {
        let offset = out.len() as u32;
        let crc = crc32fast::hash(data);
        let name_bytes = name.as_bytes();
        let size = data.len() as u32;

        out.extend_from_slice(&0x04034b50u32.to_le_bytes());
        out.extend_from_slice(&20u16.to_le_bytes());
        out.extend_from_slice(&0x0800u16.to_le_bytes()); // UTF-8 names
        out.extend_from_slice(&0u16.to_le_bytes()); // stored
        out.extend_from_slice(&dos_time.to_le_bytes());
        out.extend_from_slice(&dos_date.to_le_bytes());
        out.extend_from_slice(&crc.to_le_bytes());
        out.extend_from_slice(&size.to_le_bytes());
        out.extend_from_slice(&size.to_le_bytes());
        out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
        out.extend_from_slice(&0u16.to_le_bytes());
        out.extend_from_slice(name_bytes);
        out.extend_from_slice(data);

        central.extend_from_slice(&0x02014b50u32.to_le_bytes());
        central.extend_from_slice(&20u16.to_le_bytes());
        central.extend_from_slice(&20u16.to_le_bytes());
        central.extend_from_slice(&0x0800u16.to_le_bytes());
        central.extend_from_slice(&0u16.to_le_bytes());
        central.extend_from_slice(&dos_time.to_le_bytes());
        central.extend_from_slice(&dos_date.to_le_bytes());
        central.extend_from_slice(&crc.to_le_bytes());
        central.extend_from_slice(&size.to_le_bytes());
        central.extend_from_slice(&size.to_le_bytes());
        central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
        central.extend_from_slice(&0u16.to_le_bytes());
        central.extend_from_slice(&0u16.to_le_bytes());
        central.extend_from_slice(&0u16.to_le_bytes());
        central.extend_from_slice(&0u16.to_le_bytes());
        central.extend_from_slice(&0u32.to_le_bytes());
        central.extend_from_slice(&offset.to_le_bytes());
        central.extend_from_slice(name_bytes);
    }
    let cd_offset = out.len() as u32;
    out.extend_from_slice(&central);
    let count = entries.len() as u16;
    out.extend_from_slice(&0x06054b50u32.to_le_bytes());
    out.extend_from_slice(&0u16.to_le_bytes());
    out.extend_from_slice(&0u16.to_le_bytes());
    out.extend_from_slice(&count.to_le_bytes());
    out.extend_from_slice(&count.to_le_bytes());
    out.extend_from_slice(&(central.len() as u32).to_le_bytes());
    out.extend_from_slice(&cd_offset.to_le_bytes());
    out.extend_from_slice(&0u16.to_le_bytes());
    out
}
