use std::cmp::Ordering;

use rusqlite::Connection;
use serde::Serialize;

use crate::core::error::{LoreError, Result};

/// The core timeline event. All pipeline stages produce or consume these.
/// Spec ref: Section 3.3 "Event Model"
#[derive(Debug, Clone, Serialize)]
pub struct TimelineEvent {
    pub timestamp: i64,
    pub entity_type: String,
    #[serde(skip)]
    pub entity_id: i64,
    pub entity_iid: i64,
    pub project_path: String,
    pub event_type: TimelineEventType,
    pub summary: String,
    pub actor: Option<String>,
    pub url: Option<String>,
    pub is_seed: bool,
}

impl PartialEq for TimelineEvent {
    fn eq(&self, other: &Self) -> bool {
        self.timestamp == other.timestamp
            && self.entity_type == other.entity_type
            && self.entity_id == other.entity_id
            && self.event_type == other.event_type
    }
}

impl Eq for TimelineEvent {}

impl PartialOrd for TimelineEvent {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for TimelineEvent {
    fn cmp(&self, other: &Self) -> Ordering {
        self.timestamp
            .cmp(&other.timestamp)
            .then_with(|| self.entity_type.cmp(&other.entity_type))
            .then_with(|| self.entity_id.cmp(&other.entity_id))
            .then_with(|| self.event_type.cmp(&other.event_type))
    }
}

/// Maximum characters per note body in a discussion thread.
pub const THREAD_NOTE_MAX_CHARS: usize = 2000;

/// Maximum notes per discussion thread before truncation.
pub const THREAD_MAX_NOTES: usize = 50;

/// A single note within a discussion thread.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct ThreadNote {
    pub note_id: i64,
    pub author: Option<String>,
    pub body: String,
    pub created_at: i64,
}

/// Per spec Section 3.3. Serde tagged enum for JSON output.
///
/// Variant declaration order defines the sort order within a timestamp+entity
/// tiebreak (Created < StateChanged < LabelAdded < ... < CrossReferenced).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TimelineEventType {
    Created,
    StateChanged {
        state: String,
    },
    LabelAdded {
        label: String,
    },
    LabelRemoved {
        label: String,
    },
    MilestoneSet {
        milestone: String,
    },
    MilestoneRemoved {
        milestone: String,
    },
    Merged,
    NoteEvidence {
        note_id: i64,
        snippet: String,
        discussion_id: Option<i64>,
    },
    DiscussionThread {
        discussion_id: i64,
        notes: Vec<ThreadNote>,
    },
    CrossReferenced {
        target: String,
    },
}

/// Truncate a string to at most `max_chars` characters on a safe UTF-8 boundary.
#[allow(dead_code)]
pub(crate) fn truncate_to_chars(s: &str, max_chars: usize) -> String {
    let char_count = s.chars().count();
    if char_count <= max_chars {
        return s.to_owned();
    }

    let byte_end = s
        .char_indices()
        .nth(max_chars)
        .map(|(i, _)| i)
        .unwrap_or(s.len());
    s[..byte_end].to_owned()
}

/// A discussion matched during the seed phase, to be collected as a full thread.
#[derive(Debug, Clone)]
pub struct MatchedDiscussion {
    pub discussion_id: i64,
    pub entity_type: String,
    pub entity_id: i64,
    pub project_id: i64,
}

/// Internal entity reference used across pipeline stages.
#[derive(Debug, Clone, Serialize)]
pub struct EntityRef {
    pub entity_type: String,
    pub entity_id: i64,
    pub entity_iid: i64,
    pub project_path: String,
}

/// An entity discovered via BFS expansion.
/// Spec ref: Section 3.5 "expanded_entities" JSON structure.
#[derive(Debug, Clone, Serialize)]
pub struct ExpandedEntityRef {
    pub entity_ref: EntityRef,
    pub depth: u32,
    pub via_from: EntityRef,
    pub via_reference_type: String,
    pub via_source_method: String,
}

/// Reference to an unsynced external entity.
/// Spec ref: Section 3.5 "unresolved_references" JSON structure.
#[derive(Debug, Clone, Serialize)]
pub struct UnresolvedRef {
    pub source: EntityRef,
    pub target_project: Option<String>,
    pub target_type: String,
    pub target_iid: Option<i64>,
    pub reference_type: String,
}

/// Complete result from the timeline pipeline.
#[derive(Debug, Clone, Serialize)]
pub struct TimelineResult {
    pub query: String,
    /// The search mode actually used for seeding (e.g. "hybrid", "lexical", "lexical (hybrid fallback)").
    pub search_mode: String,
    pub events: Vec<TimelineEvent>,
    /// Total events after filters (e.g., --since) but before --limit was applied.
    /// Use this to show "showing X of Y filtered events".
    #[serde(skip)]
    pub total_filtered_events: usize,
    pub seed_entities: Vec<EntityRef>,
    pub expanded_entities: Vec<ExpandedEntityRef>,
    pub unresolved_references: Vec<UnresolvedRef>,
}

/// Resolve an entity's internal DB id to a full [`EntityRef`] with iid and project path.
///
/// When `project_id` is `Some`, the query is scoped to that project.
/// Returns `Ok(None)` for unknown entity types or when no matching row exists.
pub fn resolve_entity_ref(
    conn: &Connection,
    entity_type: &str,
    entity_id: i64,
    project_id: Option<i64>,
) -> Result<Option<EntityRef>> {
    let table = match entity_type {
        "issue" => "issues",
        "merge_request" => "merge_requests",
        _ => return Ok(None),
    };

    let sql = format!(
        "SELECT e.iid, p.path_with_namespace
         FROM {table} e
         JOIN projects p ON p.id = e.project_id
         WHERE e.id = ?1 AND (?2 IS NULL OR e.project_id = ?2)"
    );

    let result = conn.query_row(&sql, rusqlite::params![entity_id, project_id], |row| {
        Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
    });

    match result {
        Ok((iid, project_path)) => Ok(Some(EntityRef {
            entity_type: entity_type.to_owned(),
            entity_id,
            entity_iid: iid,
            project_path,
        })),
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
        Err(e) => Err(e.into()),
    }
}

/// Resolve an entity by its user-facing IID (e.g. issue #42) to a full [`EntityRef`].
///
/// Unlike [`resolve_entity_ref`] which takes an internal DB id, this takes the
/// GitLab IID that users see. Used by entity-direct timeline seeding (`issue:42`).
///
/// When `project_id` is `Some`, the query is scoped to that project (disambiguates
/// duplicate IIDs across projects).
///
/// Returns `LoreError::NotFound` when no match exists, `LoreError::Ambiguous` when
/// the same IID exists in multiple projects (suggest `--project`).
pub fn resolve_entity_by_iid(
    conn: &Connection,
    entity_type: &str,
    iid: i64,
    project_id: Option<i64>,
) -> Result<EntityRef> {
    let table = match entity_type {
        "issue" => "issues",
        "merge_request" => "merge_requests",
        _ => {
            return Err(LoreError::NotFound(format!(
                "Unknown entity type: {entity_type}"
            )));
        }
    };

    let sql = format!(
        "SELECT e.id, e.iid, p.path_with_namespace
         FROM {table} e
         JOIN projects p ON p.id = e.project_id
         WHERE e.iid = ?1 AND (?2 IS NULL OR e.project_id = ?2)"
    );

    let mut stmt = conn.prepare(&sql)?;
    let rows: Vec<(i64, i64, String)> = stmt
        .query_map(rusqlite::params![iid, project_id], |row| {
            Ok((
                row.get::<_, i64>(0)?,
                row.get::<_, i64>(1)?,
                row.get::<_, String>(2)?,
            ))
        })?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    match rows.len() {
        0 => {
            let sigil = if entity_type == "issue" { "#" } else { "!" };
            Err(LoreError::NotFound(format!(
                "{entity_type} {sigil}{iid} not found"
            )))
        }
        1 => {
            let (entity_id, entity_iid, project_path) = rows.into_iter().next().unwrap();
            Ok(EntityRef {
                entity_type: entity_type.to_owned(),
                entity_id,
                entity_iid,
                project_path,
            })
        }
        _ => {
            let projects: Vec<&str> = rows.iter().map(|(_, _, p)| p.as_str()).collect();
            let sigil = if entity_type == "issue" { "#" } else { "!" };
            Err(LoreError::Ambiguous(format!(
                "{entity_type} {sigil}{iid} exists in multiple projects: {}. Use --project to specify.",
                projects.join(", ")
            )))
        }
    }
}

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

    fn make_event(timestamp: i64, entity_id: i64, event_type: TimelineEventType) -> TimelineEvent {
        TimelineEvent {
            timestamp,
            entity_type: "issue".to_owned(),
            entity_id,
            entity_iid: 1,
            project_path: "group/project".to_owned(),
            event_type,
            summary: "test".to_owned(),
            actor: None,
            url: None,
            is_seed: true,
        }
    }

    #[test]
    fn test_timeline_event_sort_by_timestamp() {
        let mut events = [
            make_event(3000, 1, TimelineEventType::Created),
            make_event(1000, 2, TimelineEventType::Created),
            make_event(2000, 3, TimelineEventType::Merged),
        ];
        events.sort();
        assert_eq!(events[0].timestamp, 1000);
        assert_eq!(events[1].timestamp, 2000);
        assert_eq!(events[2].timestamp, 3000);
    }

    #[test]
    fn test_timeline_event_sort_tiebreak() {
        let mut events = [
            make_event(1000, 5, TimelineEventType::Created),
            make_event(1000, 2, TimelineEventType::Merged),
            make_event(1000, 2, TimelineEventType::Created),
        ];
        events.sort();
        // Same timestamp: sort by entity_id first, then event_type discriminant
        assert_eq!(events[0].entity_id, 2);
        assert!(matches!(events[0].event_type, TimelineEventType::Created));
        assert_eq!(events[1].entity_id, 2);
        assert!(matches!(events[1].event_type, TimelineEventType::Merged));
        assert_eq!(events[2].entity_id, 5);
    }

    #[test]
    fn test_timeline_event_type_serializes_tagged() {
        let event_type = TimelineEventType::StateChanged {
            state: "closed".to_owned(),
        };
        let json = serde_json::to_value(&event_type).unwrap();
        assert_eq!(json["kind"], "state_changed");
        assert_eq!(json["state"], "closed");
    }

    #[test]
    fn test_note_evidence_has_note_id() {
        let event_type = TimelineEventType::NoteEvidence {
            note_id: 42,
            snippet: "some text".to_owned(),
            discussion_id: Some(7),
        };
        let json = serde_json::to_value(&event_type).unwrap();
        assert_eq!(json["kind"], "note_evidence");
        assert_eq!(json["note_id"], 42);
        assert_eq!(json["snippet"], "some text");
        assert_eq!(json["discussion_id"], 7);
    }

    #[test]
    fn test_entity_id_skipped_in_serialization() {
        let event = make_event(1000, 99, TimelineEventType::Created);
        let json = serde_json::to_value(&event).unwrap();
        assert!(json.get("entity_id").is_none());
        assert_eq!(json["entity_iid"], 1);
    }

    #[test]
    fn test_timeline_event_type_variant_count() {
        // Verify all 10 variants serialize without panic
        let variants: Vec<TimelineEventType> = vec![
            TimelineEventType::Created,
            TimelineEventType::StateChanged {
                state: "closed".to_owned(),
            },
            TimelineEventType::LabelAdded {
                label: "bug".to_owned(),
            },
            TimelineEventType::LabelRemoved {
                label: "bug".to_owned(),
            },
            TimelineEventType::MilestoneSet {
                milestone: "v1".to_owned(),
            },
            TimelineEventType::MilestoneRemoved {
                milestone: "v1".to_owned(),
            },
            TimelineEventType::Merged,
            TimelineEventType::NoteEvidence {
                note_id: 1,
                snippet: "text".to_owned(),
                discussion_id: None,
            },
            TimelineEventType::DiscussionThread {
                discussion_id: 1,
                notes: vec![ThreadNote {
                    note_id: 1,
                    author: Some("alice".to_owned()),
                    body: "hello".to_owned(),
                    created_at: 1000,
                }],
            },
            TimelineEventType::CrossReferenced {
                target: "!567".to_owned(),
            },
        ];
        assert_eq!(variants.len(), 10);
        for v in &variants {
            serde_json::to_value(v).unwrap();
        }
    }

    #[test]
    fn test_discussion_thread_serializes_tagged() {
        let event_type = TimelineEventType::DiscussionThread {
            discussion_id: 42,
            notes: vec![
                ThreadNote {
                    note_id: 1,
                    author: Some("alice".to_owned()),
                    body: "first note".to_owned(),
                    created_at: 1000,
                },
                ThreadNote {
                    note_id: 2,
                    author: Some("bob".to_owned()),
                    body: "second note".to_owned(),
                    created_at: 2000,
                },
            ],
        };
        let json = serde_json::to_value(&event_type).unwrap();
        assert_eq!(json["kind"], "discussion_thread");
        assert_eq!(json["discussion_id"], 42);
        assert_eq!(json["notes"].as_array().unwrap().len(), 2);
        assert_eq!(json["notes"][0]["note_id"], 1);
        assert_eq!(json["notes"][0]["author"], "alice");
        assert_eq!(json["notes"][0]["body"], "first note");
        assert_eq!(json["notes"][1]["note_id"], 2);
    }

    #[test]
    fn test_discussion_thread_sort_order() {
        // DiscussionThread should sort after NoteEvidence, before CrossReferenced
        let note_ev = TimelineEventType::NoteEvidence {
            note_id: 1,
            snippet: "a".to_owned(),
            discussion_id: None,
        };
        let thread = TimelineEventType::DiscussionThread {
            discussion_id: 1,
            notes: vec![],
        };
        let cross_ref = TimelineEventType::CrossReferenced {
            target: "!1".to_owned(),
        };

        assert!(note_ev < thread);
        assert!(thread < cross_ref);
    }

    #[test]
    fn test_thread_note_ord() {
        let a = ThreadNote {
            note_id: 1,
            author: Some("alice".to_owned()),
            body: "first".to_owned(),
            created_at: 1000,
        };
        let b = ThreadNote {
            note_id: 2,
            author: Some("bob".to_owned()),
            body: "second".to_owned(),
            created_at: 2000,
        };
        // ThreadNote derives Ord — note_id is the first field, so ordering is by note_id
        assert!(a < b);
    }

    #[test]
    fn test_truncate_to_chars() {
        assert_eq!(truncate_to_chars("hello", 200), "hello");
        let long = "a".repeat(300);
        assert_eq!(truncate_to_chars(&long, 200).chars().count(), 200);
    }

    // ─── resolve_entity_by_iid tests ────────────────────────────────────────

    use crate::core::db::{create_connection, run_migrations};
    use std::path::Path;

    fn setup_db() -> Connection {
        let conn = create_connection(Path::new(":memory:")).unwrap();
        run_migrations(&conn).unwrap();
        conn
    }

    fn insert_project(conn: &Connection, gitlab_id: i64, path: &str) -> i64 {
        conn.execute(
            "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (?1, ?2, ?3)",
            rusqlite::params![gitlab_id, path, format!("https://gitlab.com/{path}")],
        )
        .unwrap();
        conn.last_insert_rowid()
    }

    fn insert_issue(conn: &Connection, project_id: i64, iid: i64) -> i64 {
        conn.execute(
            "INSERT INTO issues (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test issue', 'opened', 'alice', 1000, 2000, 3000)",
            rusqlite::params![project_id * 10000 + iid, project_id, iid],
        )
        .unwrap();
        conn.last_insert_rowid()
    }

    fn insert_mr(conn: &Connection, project_id: i64, iid: i64) -> i64 {
        conn.execute(
            "INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test MR', 'opened', 'bob', 1000, 2000, 3000)",
            rusqlite::params![project_id * 10000 + iid, project_id, iid],
        )
        .unwrap();
        conn.last_insert_rowid()
    }

    #[test]
    fn test_resolve_entity_by_iid_issue() {
        let conn = setup_db();
        let project_id = insert_project(&conn, 1, "group/project");
        let entity_id = insert_issue(&conn, project_id, 42);

        let result = resolve_entity_by_iid(&conn, "issue", 42, None).unwrap();
        assert_eq!(result.entity_type, "issue");
        assert_eq!(result.entity_id, entity_id);
        assert_eq!(result.entity_iid, 42);
        assert_eq!(result.project_path, "group/project");
    }

    #[test]
    fn test_resolve_entity_by_iid_mr() {
        let conn = setup_db();
        let project_id = insert_project(&conn, 1, "group/project");
        let entity_id = insert_mr(&conn, project_id, 99);

        let result = resolve_entity_by_iid(&conn, "merge_request", 99, None).unwrap();
        assert_eq!(result.entity_type, "merge_request");
        assert_eq!(result.entity_id, entity_id);
        assert_eq!(result.entity_iid, 99);
        assert_eq!(result.project_path, "group/project");
    }

    #[test]
    fn test_resolve_entity_by_iid_not_found() {
        let conn = setup_db();
        insert_project(&conn, 1, "group/project");

        let result = resolve_entity_by_iid(&conn, "issue", 999, None);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(matches!(err, crate::core::error::LoreError::NotFound(_)));
    }

    #[test]
    fn test_resolve_entity_by_iid_ambiguous() {
        let conn = setup_db();
        let proj1 = insert_project(&conn, 1, "group/project-a");
        let proj2 = insert_project(&conn, 2, "group/project-b");
        insert_issue(&conn, proj1, 42);
        insert_issue(&conn, proj2, 42);

        let result = resolve_entity_by_iid(&conn, "issue", 42, None);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(matches!(err, crate::core::error::LoreError::Ambiguous(_)));
    }

    #[test]
    fn test_resolve_entity_by_iid_project_scoped() {
        let conn = setup_db();
        let proj1 = insert_project(&conn, 1, "group/project-a");
        let proj2 = insert_project(&conn, 2, "group/project-b");
        insert_issue(&conn, proj1, 42);
        let entity_id_b = insert_issue(&conn, proj2, 42);

        let result = resolve_entity_by_iid(&conn, "issue", 42, Some(proj2)).unwrap();
        assert_eq!(result.entity_id, entity_id_b);
        assert_eq!(result.project_path, "group/project-b");
    }
}
