#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceType {
    Issue,
    MergeRequest,
    Discussion,
    Note,
}

impl SourceType {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Issue => "issue",
            Self::MergeRequest => "merge_request",
            Self::Discussion => "discussion",
            Self::Note => "note",
        }
    }

    pub fn parse(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "issue" | "issues" => Some(Self::Issue),
            "mr" | "mrs" | "merge_request" | "merge_requests" => Some(Self::MergeRequest),
            "discussion" | "discussions" => Some(Self::Discussion),
            "note" | "notes" => Some(Self::Note),
            _ => None,
        }
    }
}

impl std::fmt::Display for SourceType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

#[derive(Debug, Clone)]
pub struct DocumentData {
    pub source_type: SourceType,
    pub source_id: i64,
    pub project_id: i64,
    pub author_username: Option<String>,
    pub labels: Vec<String>,
    pub paths: Vec<String>,
    pub labels_hash: String,
    pub paths_hash: String,
    pub created_at: i64,
    pub updated_at: i64,
    pub url: Option<String>,
    pub title: Option<String>,
    pub content_text: String,
    pub content_hash: String,
    pub is_truncated: bool,
    pub truncated_reason: Option<String>,
}

pub fn compute_content_hash(content: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(content.as_bytes());
    format!("{:x}", hasher.finalize())
}

pub fn compute_list_hash(items: &[String]) -> String {
    let mut indices: Vec<usize> = (0..items.len()).collect();
    indices.sort_by(|a, b| items[*a].cmp(&items[*b]));
    let mut hasher = Sha256::new();
    for (i, &idx) in indices.iter().enumerate() {
        if i > 0 {
            hasher.update(b"\n");
        }
        hasher.update(items[idx].as_bytes());
    }
    format!("{:x}", hasher.finalize())
}

/// Strip GitLab-generated boilerplate from titles before embedding.
///
/// Common patterns that inflate embedding similarity between unrelated entities:
/// - `Draft: Resolve "Actual Title"` → `Actual Title`
/// - `Resolve "Actual Title"` → `Actual Title`
/// - `Draft: Some Title` → `Some Title`
/// - `WIP: Some Title` → `Some Title`
///
/// The original title is preserved in `DocumentData.title` for display;
/// this function only affects `content_text` (what gets embedded).
fn normalize_title_for_embedding(title: &str) -> &str {
    let mut s = title;

    // Strip leading "Draft: " and/or "WIP: " (case-insensitive, repeatable).
    // Use `get()` for slicing — direct `str[..N]` panics if byte N is mid-character
    // (e.g. titles starting with emoji or accented characters).
    loop {
        let trimmed = s.trim_start();
        if trimmed
            .get(..6)
            .is_some_and(|p| p.eq_ignore_ascii_case("draft:"))
        {
            s = trimmed[6..].trim_start();
        } else if trimmed
            .get(..4)
            .is_some_and(|p| p.eq_ignore_ascii_case("wip:"))
        {
            s = trimmed[4..].trim_start();
        } else {
            break;
        }
    }

    // Strip `Resolve "..."` wrapper (case-insensitive)
    if s.len() >= 10
        && s.get(..8).is_some_and(|p| p.eq_ignore_ascii_case("resolve "))
        && s.as_bytes()[8] == b'"'
        && let Some(end) = s[9..].rfind('"')
    {
        let inner = &s[9..9 + end];
        if !inner.is_empty() {
            return inner;
        }
    }

    // Guard: if stripping left us with nothing, return the original
    if s.is_empty() {
        return title;
    }

    s
}

fn format_date(ms: i64) -> String {
    DateTime::from_timestamp_millis(ms)
        .map(|dt| dt.format("%Y-%m-%d").to_string())
        .unwrap_or_else(|| "unknown".to_string())
}
