use uuid::Uuid;

// Row shape for `hashtag`, populated by sqlx from `RETURNING *` in `upsert`. Callers only use
// `id` today; `event_id`/`tag` are the rest of the row and stay part of the struct.
#[allow(dead_code)]
#[derive(Debug, sqlx::FromRow)]
pub struct Hashtag {
    pub id: Uuid,
    pub event_id: Uuid,
    pub tag: String,
}

impl Hashtag {
    /// Upsert a hashtag (insert if not exists, return existing if it does).
    ///
    /// Takes any executor so callers can run it inside a transaction (atomic
    /// upload/comment writes) or standalone against the pool.
    pub async fn upsert<'e, E>(executor: E, event_id: Uuid, tag: &str) -> Result<Self, sqlx::Error>
    where
        E: sqlx::PgExecutor<'e>,
    {
        let normalized = tag.trim().trim_start_matches('#').to_lowercase();
        sqlx::query_as::<_, Self>(
            "INSERT INTO hashtag (event_id, tag) VALUES ($1, $2)
             ON CONFLICT (event_id, tag) DO UPDATE SET tag = EXCLUDED.tag
             RETURNING *",
        )
        .bind(event_id)
        .bind(&normalized)
        .fetch_one(executor)
        .await
    }

    pub async fn link_to_upload<'e, E>(
        executor: E,
        upload_id: Uuid,
        hashtag_id: Uuid,
    ) -> Result<(), sqlx::Error>
    where
        E: sqlx::PgExecutor<'e>,
    {
        sqlx::query(
            "INSERT INTO upload_hashtag (upload_id, hashtag_id) VALUES ($1, $2)
             ON CONFLICT DO NOTHING",
        )
        .bind(upload_id)
        .bind(hashtag_id)
        .execute(executor)
        .await?;
        Ok(())
    }

    pub async fn unlink_all_from_upload<'e, E>(
        executor: E,
        upload_id: Uuid,
    ) -> Result<(), sqlx::Error>
    where
        E: sqlx::PgExecutor<'e>,
    {
        sqlx::query("DELETE FROM upload_hashtag WHERE upload_id = $1")
            .bind(upload_id)
            .execute(executor)
            .await?;
        Ok(())
    }

    /// The upload's current tags, lowercased and sorted — the comparable form.
    ///
    /// Exists so `edit_upload` can tell a real hashtag change from a re-send of the same list.
    /// Without it, `PATCH {"hashtags": []}` in a loop retired the HTML keepsake on every request
    /// (readiness is derived from `event.export_epoch`), and every armed rebuild was superseded
    /// before the debounce let it start — so the viewer 404'd for the rest of the event at zero
    /// cost to the client. One indexed lookup on `upload_hashtag(upload_id)` is a fair price for
    /// closing that.
    pub async fn normalized_for_upload<'e, E>(
        executor: E,
        upload_id: Uuid,
    ) -> Result<Vec<String>, sqlx::Error>
    where
        E: sqlx::PgExecutor<'e>,
    {
        let rows: Vec<(String,)> = sqlx::query_as(
            "SELECT lower(h.tag) FROM hashtag h
             JOIN upload_hashtag uh ON uh.hashtag_id = h.id
             WHERE uh.upload_id = $1
             ORDER BY lower(h.tag)",
        )
        .bind(upload_id)
        .fetch_all(executor)
        .await?;
        Ok(rows.into_iter().map(|(t,)| t).collect())
    }
}

/// Extract `#hashtags` from text (caption or body). Tags are restricted to
/// ASCII letters, digits, and underscore — emoji / punctuation / accented
/// characters are rejected. This is deliberately strict: hashtags are an index
/// (used for filtering and SQL JOINs), and tags like `#🎉` or `#!?` accumulate
/// noise without helping anyone find content.
pub fn extract_hashtags(text: &str) -> Vec<String> {
    const MAX_TAG_LEN: usize = 40;
    text.split_whitespace()
        .filter_map(|w| w.strip_prefix('#'))
        .map(|t| {
            t.chars()
                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
                .collect::<String>()
                .to_lowercase()
        })
        .filter(|t| !t.is_empty() && t.chars().count() <= MAX_TAG_LEN)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::extract_hashtags;

    #[test]
    fn ascii_word_chars_extracted() {
        assert_eq!(
            extract_hashtags("hello #wedding #Day_2 #cake!"),
            vec!["wedding", "day_2", "cake"]
        );
    }

    #[test]
    fn emojis_and_punctuation_excluded() {
        // The 🎉 tag drops out entirely (no ASCII chars after #), the next tag
        // stops at the !? and yields only "fun".
        assert_eq!(extract_hashtags("#🎉 #!? #fun!"), vec!["fun"]);
    }

    #[test]
    fn empty_or_bare_hash_skipped() {
        assert_eq!(extract_hashtags("# #"), Vec::<String>::new());
    }

    #[test]
    fn tag_stops_at_first_non_word_char() {
        // A tag runs until the first char that isn't ascii-alphanumeric or '_'.
        assert_eq!(extract_hashtags("#foo#bar"), vec!["foo"]);
        assert_eq!(extract_hashtags("#foo-bar"), vec!["foo"]);
        assert_eq!(extract_hashtags("##tag"), Vec::<String>::new()); // '#' after the strip is non-word
    }

    #[test]
    fn tag_length_is_capped_at_40_chars() {
        let ok = "a".repeat(40);
        assert_eq!(extract_hashtags(&format!("#{ok}")), vec![ok.clone()]);
        // 41+ chars → dropped entirely (not truncated).
        let too_long = "a".repeat(41);
        assert_eq!(
            extract_hashtags(&format!("#{too_long}")),
            Vec::<String>::new()
        );
    }

    #[test]
    fn duplicate_tags_are_returned_verbatim_not_deduplicated() {
        // Dedup is the DB's job (Hashtag::upsert ON CONFLICT); extraction returns each
        // occurrence so callers can count/link them independently. Case folds to lower.
        assert_eq!(
            extract_hashtags("#fun #Fun #fun!"),
            vec!["fun", "fun", "fun"]
        );
    }

    #[test]
    fn non_ascii_word_chars_truncate_the_tag() {
        // KNOWN LIMITATION for a German app: `is_ascii_alphanumeric` excludes umlauts
        // and ß, so a tag truncates at the first non-ASCII letter. Pinned here so a
        // future Unicode-aware change is a deliberate, test-visible decision.
        assert_eq!(extract_hashtags("#Grüße"), vec!["gr"]);
        assert_eq!(extract_hashtags("#Straße"), vec!["stra"]);
        assert_eq!(extract_hashtags("#café"), vec!["caf"]);
    }
}
