use crate::cli::render::{self, Align, StyledCell, Table as LoreTable, Theme};
use rusqlite::Connection;
use serde::Serialize;

use crate::Config;
use crate::cli::robot::{RobotMeta, expand_fields_preset, filter_fields};
use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result};
use crate::core::path_resolver::escape_like as note_escape_like;
use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::{ms_to_iso, parse_since};

#[derive(Debug, Serialize)]
pub struct IssueListRow {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub author_username: String,
    pub created_at: i64,
    pub updated_at: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub web_url: Option<String>,
    pub project_path: String,
    pub labels: Vec<String>,
    pub assignees: Vec<String>,
    pub discussion_count: i64,
    pub unresolved_count: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status_name: Option<String>,
    #[serde(skip_serializing)]
    pub status_category: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status_color: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status_icon_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status_synced_at: Option<i64>,
}

#[derive(Serialize)]
pub struct IssueListRowJson {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub author_username: String,
    pub labels: Vec<String>,
    pub assignees: Vec<String>,
    pub discussion_count: i64,
    pub unresolved_count: i64,
    pub created_at_iso: String,
    pub updated_at_iso: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub web_url: Option<String>,
    pub project_path: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status_name: Option<String>,
    #[serde(skip_serializing)]
    pub status_category: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status_color: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status_icon_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status_synced_at_iso: Option<String>,
}

impl From<&IssueListRow> for IssueListRowJson {
    fn from(row: &IssueListRow) -> Self {
        Self {
            iid: row.iid,
            title: row.title.clone(),
            state: row.state.clone(),
            author_username: row.author_username.clone(),
            labels: row.labels.clone(),
            assignees: row.assignees.clone(),
            discussion_count: row.discussion_count,
            unresolved_count: row.unresolved_count,
            created_at_iso: ms_to_iso(row.created_at),
            updated_at_iso: ms_to_iso(row.updated_at),
            web_url: row.web_url.clone(),
            project_path: row.project_path.clone(),
            status_name: row.status_name.clone(),
            status_category: row.status_category.clone(),
            status_color: row.status_color.clone(),
            status_icon_name: row.status_icon_name.clone(),
            status_synced_at_iso: row.status_synced_at.map(ms_to_iso),
        }
    }
}

#[derive(Serialize)]
pub struct ListResult {
    pub issues: Vec<IssueListRow>,
    pub total_count: usize,
    pub available_statuses: Vec<String>,
}

#[derive(Serialize)]
pub struct ListResultJson {
    pub issues: Vec<IssueListRowJson>,
    pub total_count: usize,
    pub showing: usize,
}

impl From<&ListResult> for ListResultJson {
    fn from(result: &ListResult) -> Self {
        Self {
            issues: result.issues.iter().map(IssueListRowJson::from).collect(),
            total_count: result.total_count,
            showing: result.issues.len(),
        }
    }
}

#[derive(Debug, Serialize)]
pub struct MrListRow {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub draft: bool,
    pub author_username: String,
    pub source_branch: String,
    pub target_branch: String,
    pub created_at: i64,
    pub updated_at: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub web_url: Option<String>,
    pub project_path: String,
    pub labels: Vec<String>,
    pub assignees: Vec<String>,
    pub reviewers: Vec<String>,
    pub discussion_count: i64,
    pub unresolved_count: i64,
}

#[derive(Serialize)]
pub struct MrListRowJson {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub draft: bool,
    pub author_username: String,
    pub source_branch: String,
    pub target_branch: String,
    pub labels: Vec<String>,
    pub assignees: Vec<String>,
    pub reviewers: Vec<String>,
    pub discussion_count: i64,
    pub unresolved_count: i64,
    pub created_at_iso: String,
    pub updated_at_iso: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub web_url: Option<String>,
    pub project_path: String,
}

impl From<&MrListRow> for MrListRowJson {
    fn from(row: &MrListRow) -> Self {
        Self {
            iid: row.iid,
            title: row.title.clone(),
            state: row.state.clone(),
            draft: row.draft,
            author_username: row.author_username.clone(),
            source_branch: row.source_branch.clone(),
            target_branch: row.target_branch.clone(),
            labels: row.labels.clone(),
            assignees: row.assignees.clone(),
            reviewers: row.reviewers.clone(),
            discussion_count: row.discussion_count,
            unresolved_count: row.unresolved_count,
            created_at_iso: ms_to_iso(row.created_at),
            updated_at_iso: ms_to_iso(row.updated_at),
            web_url: row.web_url.clone(),
            project_path: row.project_path.clone(),
        }
    }
}

#[derive(Serialize)]
pub struct MrListResult {
    pub mrs: Vec<MrListRow>,
    pub total_count: usize,
}

#[derive(Serialize)]
pub struct MrListResultJson {
    pub mrs: Vec<MrListRowJson>,
    pub total_count: usize,
    pub showing: usize,
}

impl From<&MrListResult> for MrListResultJson {
    fn from(result: &MrListResult) -> Self {
        Self {
            mrs: result.mrs.iter().map(MrListRowJson::from).collect(),
            total_count: result.total_count,
            showing: result.mrs.len(),
        }
    }
}

pub struct ListFilters<'a> {
    pub limit: usize,
    pub project: Option<&'a str>,
    pub state: Option<&'a str>,
    pub author: Option<&'a str>,
    pub assignee: Option<&'a str>,
    pub labels: Option<&'a [String]>,
    pub milestone: Option<&'a str>,
    pub since: Option<&'a str>,
    pub due_before: Option<&'a str>,
    pub has_due_date: bool,
    pub statuses: &'a [String],
    pub sort: &'a str,
    pub order: &'a str,
}

pub struct MrListFilters<'a> {
    pub limit: usize,
    pub project: Option<&'a str>,
    pub state: Option<&'a str>,
    pub author: Option<&'a str>,
    pub assignee: Option<&'a str>,
    pub reviewer: Option<&'a str>,
    pub labels: Option<&'a [String]>,
    pub since: Option<&'a str>,
    pub draft: bool,
    pub no_draft: bool,
    pub target_branch: Option<&'a str>,
    pub source_branch: Option<&'a str>,
    pub sort: &'a str,
    pub order: &'a str,
}

pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResult> {
    let db_path = get_db_path(config.storage.db_path.as_deref());
    let conn = create_connection(&db_path)?;

    let mut result = query_issues(&conn, &filters)?;
    result.available_statuses = query_available_statuses(&conn)?;
    Ok(result)
}

fn query_available_statuses(conn: &Connection) -> Result<Vec<String>> {
    let mut stmt = conn.prepare(
        "SELECT DISTINCT status_name FROM issues WHERE status_name IS NOT NULL ORDER BY status_name",
    )?;
    let statuses = stmt
        .query_map([], |row| row.get::<_, String>(0))?
        .collect::<std::result::Result<Vec<_>, _>>()?;
    Ok(statuses)
}

fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult> {
    let mut where_clauses = Vec::new();
    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();

    if let Some(project) = filters.project {
        let project_id = resolve_project(conn, project)?;
        where_clauses.push("i.project_id = ?");
        params.push(Box::new(project_id));
    }

    if let Some(state) = filters.state
        && state != "all"
    {
        where_clauses.push("i.state = ?");
        params.push(Box::new(state.to_string()));
    }

    if let Some(author) = filters.author {
        let username = author.strip_prefix('@').unwrap_or(author);
        where_clauses.push("i.author_username = ?");
        params.push(Box::new(username.to_string()));
    }

    if let Some(assignee) = filters.assignee {
        let username = assignee.strip_prefix('@').unwrap_or(assignee);
        where_clauses.push(
            "EXISTS (SELECT 1 FROM issue_assignees ia
             WHERE ia.issue_id = i.id AND ia.username = ?)",
        );
        params.push(Box::new(username.to_string()));
    }

    if let Some(since_str) = filters.since {
        let cutoff_ms = parse_since(since_str).ok_or_else(|| {
            LoreError::Other(format!(
                "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
                since_str
            ))
        })?;
        where_clauses.push("i.updated_at >= ?");
        params.push(Box::new(cutoff_ms));
    }

    if let Some(labels) = filters.labels {
        for label in labels {
            where_clauses.push(
                "EXISTS (SELECT 1 FROM issue_labels il
                 JOIN labels l ON il.label_id = l.id
                 WHERE il.issue_id = i.id AND l.name = ?)",
            );
            params.push(Box::new(label.clone()));
        }
    }

    if let Some(milestone) = filters.milestone {
        where_clauses.push("i.milestone_title = ?");
        params.push(Box::new(milestone.to_string()));
    }

    if let Some(due_before) = filters.due_before {
        where_clauses.push("i.due_date IS NOT NULL AND i.due_date <= ?");
        params.push(Box::new(due_before.to_string()));
    }

    if filters.has_due_date {
        where_clauses.push("i.due_date IS NOT NULL");
    }

    let status_in_clause;
    if filters.statuses.len() == 1 {
        where_clauses.push("i.status_name = ? COLLATE NOCASE");
        params.push(Box::new(filters.statuses[0].clone()));
    } else if filters.statuses.len() > 1 {
        let placeholders: Vec<&str> = filters.statuses.iter().map(|_| "?").collect();
        status_in_clause = format!(
            "i.status_name COLLATE NOCASE IN ({})",
            placeholders.join(", ")
        );
        where_clauses.push(&status_in_clause);
        for s in filters.statuses {
            params.push(Box::new(s.clone()));
        }
    }

    let where_sql = if where_clauses.is_empty() {
        String::new()
    } else {
        format!("WHERE {}", where_clauses.join(" AND "))
    };

    let count_sql = format!(
        "SELECT COUNT(*) FROM issues i
         JOIN projects p ON i.project_id = p.id
         {where_sql}"
    );

    let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
    let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
    let total_count = total_count as usize;

    let sort_column = match filters.sort {
        "created" => "i.created_at",
        "iid" => "i.iid",
        _ => "i.updated_at",
    };
    let order = if filters.order == "asc" {
        "ASC"
    } else {
        "DESC"
    };

    let query_sql = format!(
        "SELECT
            i.iid,
            i.title,
            i.state,
            i.author_username,
            i.created_at,
            i.updated_at,
            i.web_url,
            p.path_with_namespace,
            (SELECT GROUP_CONCAT(l.name, X'1F')
             FROM issue_labels il
             JOIN labels l ON il.label_id = l.id
             WHERE il.issue_id = i.id) AS labels_csv,
            (SELECT GROUP_CONCAT(ia.username, X'1F')
             FROM issue_assignees ia
             WHERE ia.issue_id = i.id) AS assignees_csv,
            (SELECT COUNT(*) FROM discussions d
             WHERE d.issue_id = i.id) AS discussion_count,
            (SELECT COUNT(*) FROM discussions d
             WHERE d.issue_id = i.id AND d.resolvable = 1 AND d.resolved = 0) AS unresolved_count,
            i.status_name,
            i.status_category,
            i.status_color,
            i.status_icon_name,
            i.status_synced_at
         FROM issues i
         JOIN projects p ON i.project_id = p.id
         {where_sql}
         ORDER BY {sort_column} {order}
         LIMIT ?"
    );

    params.push(Box::new(filters.limit as i64));
    let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();

    let mut stmt = conn.prepare(&query_sql)?;
    let issues: Vec<IssueListRow> = stmt
        .query_map(param_refs.as_slice(), |row| {
            let labels_csv: Option<String> = row.get(8)?;
            let labels = labels_csv
                .map(|s| s.split('\x1F').map(String::from).collect())
                .unwrap_or_default();

            let assignees_csv: Option<String> = row.get(9)?;
            let assignees = assignees_csv
                .map(|s| s.split('\x1F').map(String::from).collect())
                .unwrap_or_default();

            Ok(IssueListRow {
                iid: row.get(0)?,
                title: row.get(1)?,
                state: row.get(2)?,
                author_username: row.get(3)?,
                created_at: row.get(4)?,
                updated_at: row.get(5)?,
                web_url: row.get(6)?,
                project_path: row.get(7)?,
                labels,
                assignees,
                discussion_count: row.get(10)?,
                unresolved_count: row.get(11)?,
                status_name: row.get(12)?,
                status_category: row.get(13)?,
                status_color: row.get(14)?,
                status_icon_name: row.get(15)?,
                status_synced_at: row.get(16)?,
            })
        })?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    Ok(ListResult {
        issues,
        total_count,
        available_statuses: Vec::new(),
    })
}

pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result<MrListResult> {
    let db_path = get_db_path(config.storage.db_path.as_deref());
    let conn = create_connection(&db_path)?;

    let result = query_mrs(&conn, &filters)?;
    Ok(result)
}

fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult> {
    let mut where_clauses = Vec::new();
    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();

    if let Some(project) = filters.project {
        let project_id = resolve_project(conn, project)?;
        where_clauses.push("m.project_id = ?");
        params.push(Box::new(project_id));
    }

    if let Some(state) = filters.state
        && state != "all"
    {
        where_clauses.push("m.state = ?");
        params.push(Box::new(state.to_string()));
    }

    if let Some(author) = filters.author {
        let username = author.strip_prefix('@').unwrap_or(author);
        where_clauses.push("m.author_username = ?");
        params.push(Box::new(username.to_string()));
    }

    if let Some(assignee) = filters.assignee {
        let username = assignee.strip_prefix('@').unwrap_or(assignee);
        where_clauses.push(
            "EXISTS (SELECT 1 FROM mr_assignees ma
             WHERE ma.merge_request_id = m.id AND ma.username = ?)",
        );
        params.push(Box::new(username.to_string()));
    }

    if let Some(reviewer) = filters.reviewer {
        let username = reviewer.strip_prefix('@').unwrap_or(reviewer);
        where_clauses.push(
            "EXISTS (SELECT 1 FROM mr_reviewers mr
             WHERE mr.merge_request_id = m.id AND mr.username = ?)",
        );
        params.push(Box::new(username.to_string()));
    }

    if let Some(since_str) = filters.since {
        let cutoff_ms = parse_since(since_str).ok_or_else(|| {
            LoreError::Other(format!(
                "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
                since_str
            ))
        })?;
        where_clauses.push("m.updated_at >= ?");
        params.push(Box::new(cutoff_ms));
    }

    if let Some(labels) = filters.labels {
        for label in labels {
            where_clauses.push(
                "EXISTS (SELECT 1 FROM mr_labels ml
                 JOIN labels l ON ml.label_id = l.id
                 WHERE ml.merge_request_id = m.id AND l.name = ?)",
            );
            params.push(Box::new(label.clone()));
        }
    }

    if filters.draft {
        where_clauses.push("m.draft = 1");
    } else if filters.no_draft {
        where_clauses.push("m.draft = 0");
    }

    if let Some(target_branch) = filters.target_branch {
        where_clauses.push("m.target_branch = ?");
        params.push(Box::new(target_branch.to_string()));
    }

    if let Some(source_branch) = filters.source_branch {
        where_clauses.push("m.source_branch = ?");
        params.push(Box::new(source_branch.to_string()));
    }

    let where_sql = if where_clauses.is_empty() {
        String::new()
    } else {
        format!("WHERE {}", where_clauses.join(" AND "))
    };

    let count_sql = format!(
        "SELECT COUNT(*) FROM merge_requests m
         JOIN projects p ON m.project_id = p.id
         {where_sql}"
    );

    let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
    let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
    let total_count = total_count as usize;

    let sort_column = match filters.sort {
        "created" => "m.created_at",
        "iid" => "m.iid",
        _ => "m.updated_at",
    };
    let order = if filters.order == "asc" {
        "ASC"
    } else {
        "DESC"
    };

    let query_sql = format!(
        "SELECT
            m.iid,
            m.title,
            m.state,
            m.draft,
            m.author_username,
            m.source_branch,
            m.target_branch,
            m.created_at,
            m.updated_at,
            m.web_url,
            p.path_with_namespace,
            (SELECT GROUP_CONCAT(l.name, X'1F')
             FROM mr_labels ml
             JOIN labels l ON ml.label_id = l.id
             WHERE ml.merge_request_id = m.id) AS labels_csv,
            (SELECT GROUP_CONCAT(ma.username, X'1F')
             FROM mr_assignees ma
             WHERE ma.merge_request_id = m.id) AS assignees_csv,
            (SELECT GROUP_CONCAT(mr.username, X'1F')
             FROM mr_reviewers mr
             WHERE mr.merge_request_id = m.id) AS reviewers_csv,
            (SELECT COUNT(*) FROM discussions d
             WHERE d.merge_request_id = m.id) AS discussion_count,
            (SELECT COUNT(*) FROM discussions d
             WHERE d.merge_request_id = m.id AND d.resolvable = 1 AND d.resolved = 0) AS unresolved_count
         FROM merge_requests m
         JOIN projects p ON m.project_id = p.id
         {where_sql}
         ORDER BY {sort_column} {order}
         LIMIT ?"
    );

    params.push(Box::new(filters.limit as i64));
    let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();

    let mut stmt = conn.prepare(&query_sql)?;
    let mrs: Vec<MrListRow> = stmt
        .query_map(param_refs.as_slice(), |row| {
            let labels_csv: Option<String> = row.get(11)?;
            let labels = labels_csv
                .map(|s| s.split('\x1F').map(String::from).collect())
                .unwrap_or_default();

            let assignees_csv: Option<String> = row.get(12)?;
            let assignees = assignees_csv
                .map(|s| s.split('\x1F').map(String::from).collect())
                .unwrap_or_default();

            let reviewers_csv: Option<String> = row.get(13)?;
            let reviewers = reviewers_csv
                .map(|s| s.split('\x1F').map(String::from).collect())
                .unwrap_or_default();

            let draft_int: i64 = row.get(3)?;

            Ok(MrListRow {
                iid: row.get(0)?,
                title: row.get(1)?,
                state: row.get(2)?,
                draft: draft_int == 1,
                author_username: row.get(4)?,
                source_branch: row.get(5)?,
                target_branch: row.get(6)?,
                created_at: row.get(7)?,
                updated_at: row.get(8)?,
                web_url: row.get(9)?,
                project_path: row.get(10)?,
                labels,
                assignees,
                reviewers,
                discussion_count: row.get(14)?,
                unresolved_count: row.get(15)?,
            })
        })?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    Ok(MrListResult { mrs, total_count })
}

fn format_assignees(assignees: &[String]) -> String {
    if assignees.is_empty() {
        return "-".to_string();
    }

    let max_shown = 2;
    let shown: Vec<String> = assignees
        .iter()
        .take(max_shown)
        .map(|s| format!("@{}", render::truncate(s, 10)))
        .collect();
    let overflow = assignees.len().saturating_sub(max_shown);

    if overflow > 0 {
        format!("{} +{}", shown.join(", "), overflow)
    } else {
        shown.join(", ")
    }
}

fn format_discussions(total: i64, unresolved: i64) -> String {
    if total == 0 {
        return String::new();
    }

    if unresolved > 0 {
        format!("{total}/{unresolved}!")
    } else {
        format!("{total}")
    }
}

fn format_branches(target: &str, source: &str, max_width: usize) -> String {
    let full = format!("{} <- {}", target, source);
    render::truncate(&full, max_width)
}

pub fn print_list_issues(result: &ListResult) {
    if result.issues.is_empty() {
        println!("No issues found.");
        return;
    }

    println!(
        "Issues (showing {} of {})\n",
        result.issues.len(),
        result.total_count
    );

    let has_any_status = result.issues.iter().any(|i| i.status_name.is_some());

    let mut headers = vec!["IID", "Title", "State"];
    if has_any_status {
        headers.push("Status");
    }
    headers.extend(["Assignee", "Labels", "Disc", "Updated"]);

    let mut table = LoreTable::new().headers(&headers).align(0, Align::Right);

    for issue in &result.issues {
        let title = render::truncate(&issue.title, 45);
        let relative_time = render::format_relative_time(issue.updated_at);
        let labels = render::format_labels(&issue.labels, 2);
        let assignee = format_assignees(&issue.assignees);
        let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);

        let state_cell = if issue.state == "opened" {
            StyledCell::styled(&issue.state, Theme::success())
        } else {
            StyledCell::styled(&issue.state, Theme::dim())
        };

        let mut row = vec![
            StyledCell::styled(format!("#{}", issue.iid), Theme::info()),
            StyledCell::plain(title),
            state_cell,
        ];
        if has_any_status {
            match &issue.status_name {
                Some(status) => {
                    row.push(StyledCell::plain(render::style_with_hex(
                        status,
                        issue.status_color.as_deref(),
                    )));
                }
                None => {
                    row.push(StyledCell::plain(""));
                }
            }
        }
        row.extend([
            StyledCell::styled(assignee, Theme::accent()),
            StyledCell::styled(labels, Theme::warning()),
            StyledCell::plain(discussions),
            StyledCell::styled(relative_time, Theme::dim()),
        ]);
        table.add_row(row);
    }

    println!("{}", table.render());
}

pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) {
    let json_result = ListResultJson::from(result);
    let output = serde_json::json!({
        "ok": true,
        "data": json_result,
        "meta": {
            "elapsed_ms": elapsed_ms,
            "available_statuses": result.available_statuses,
        },
    });
    let mut output = output;
    if let Some(f) = fields {
        let expanded = expand_fields_preset(f, "issues");
        filter_fields(&mut output, "issues", &expanded);
    }
    match serde_json::to_string(&output) {
        Ok(json) => println!("{json}"),
        Err(e) => eprintln!("Error serializing to JSON: {e}"),
    }
}

pub fn open_issue_in_browser(result: &ListResult) -> Option<String> {
    let first_issue = result.issues.first()?;
    let url = first_issue.web_url.as_ref()?;

    match open::that(url) {
        Ok(()) => {
            println!("Opened: {url}");
            Some(url.clone())
        }
        Err(e) => {
            eprintln!("Failed to open browser: {e}");
            None
        }
    }
}

pub fn print_list_mrs(result: &MrListResult) {
    if result.mrs.is_empty() {
        println!("No merge requests found.");
        return;
    }

    println!(
        "Merge Requests (showing {} of {})\n",
        result.mrs.len(),
        result.total_count
    );

    let mut table = LoreTable::new()
        .headers(&[
            "IID", "Title", "State", "Author", "Branches", "Disc", "Updated",
        ])
        .align(0, Align::Right);

    for mr in &result.mrs {
        let title = if mr.draft {
            format!("[DRAFT] {}", render::truncate(&mr.title, 38))
        } else {
            render::truncate(&mr.title, 45)
        };

        let relative_time = render::format_relative_time(mr.updated_at);
        let branches = format_branches(&mr.target_branch, &mr.source_branch, 25);
        let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);

        let state_cell = match mr.state.as_str() {
            "opened" => StyledCell::styled(&mr.state, Theme::success()),
            "merged" => StyledCell::styled(&mr.state, Theme::accent()),
            "closed" => StyledCell::styled(&mr.state, Theme::error()),
            "locked" => StyledCell::styled(&mr.state, Theme::warning()),
            _ => StyledCell::styled(&mr.state, Theme::dim()),
        };

        table.add_row(vec![
            StyledCell::styled(format!("!{}", mr.iid), Theme::info()),
            StyledCell::plain(title),
            state_cell,
            StyledCell::styled(
                format!("@{}", render::truncate(&mr.author_username, 12)),
                Theme::accent(),
            ),
            StyledCell::styled(branches, Theme::info()),
            StyledCell::plain(discussions),
            StyledCell::styled(relative_time, Theme::dim()),
        ]);
    }

    println!("{}", table.render());
}

pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) {
    let json_result = MrListResultJson::from(result);
    let meta = RobotMeta { elapsed_ms };
    let output = serde_json::json!({
        "ok": true,
        "data": json_result,
        "meta": meta,
    });
    let mut output = output;
    if let Some(f) = fields {
        let expanded = expand_fields_preset(f, "mrs");
        filter_fields(&mut output, "mrs", &expanded);
    }
    match serde_json::to_string(&output) {
        Ok(json) => println!("{json}"),
        Err(e) => eprintln!("Error serializing to JSON: {e}"),
    }
}

pub fn open_mr_in_browser(result: &MrListResult) -> Option<String> {
    let first_mr = result.mrs.first()?;
    let url = first_mr.web_url.as_ref()?;

    match open::that(url) {
        Ok(()) => {
            println!("Opened: {url}");
            Some(url.clone())
        }
        Err(e) => {
            eprintln!("Failed to open browser: {e}");
            None
        }
    }
}

// ---------------------------------------------------------------------------
// Note output formatting
// ---------------------------------------------------------------------------

fn truncate_body(body: &str, max_len: usize) -> String {
    if body.chars().count() <= max_len {
        body.to_string()
    } else {
        let truncated: String = body.chars().take(max_len).collect();
        format!("{truncated}...")
    }
}

fn format_note_type(note_type: Option<&str>) -> &str {
    match note_type {
        Some("DiffNote") => "Diff",
        Some("DiscussionNote") => "Disc",
        _ => "-",
    }
}

fn format_note_path(path: Option<&str>, line: Option<i64>) -> String {
    match (path, line) {
        (Some(p), Some(l)) => format!("{p}:{l}"),
        (Some(p), None) => p.to_string(),
        _ => "-".to_string(),
    }
}

fn format_note_parent(noteable_type: Option<&str>, parent_iid: Option<i64>) -> String {
    match (noteable_type, parent_iid) {
        (Some("Issue"), Some(iid)) => format!("Issue #{iid}"),
        (Some("MergeRequest"), Some(iid)) => format!("MR !{iid}"),
        _ => "-".to_string(),
    }
}

pub fn print_list_notes(result: &NoteListResult) {
    if result.notes.is_empty() {
        println!("No notes found.");
        return;
    }

    println!(
        "Notes (showing {} of {})\n",
        result.notes.len(),
        result.total_count
    );

    let mut table = LoreTable::new()
        .headers(&[
            "ID",
            "Author",
            "Type",
            "Body",
            "Path:Line",
            "Parent",
            "Created",
        ])
        .align(0, Align::Right);

    for note in &result.notes {
        let body = note
            .body
            .as_deref()
            .map(|b| truncate_body(b, 60))
            .unwrap_or_default();
        let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line);
        let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid);
        let relative_time = render::format_relative_time(note.created_at);
        let note_type = format_note_type(note.note_type.as_deref());

        table.add_row(vec![
            StyledCell::styled(note.gitlab_id.to_string(), Theme::info()),
            StyledCell::styled(
                format!("@{}", render::truncate(&note.author_username, 12)),
                Theme::accent(),
            ),
            StyledCell::plain(note_type),
            StyledCell::plain(body),
            StyledCell::plain(path),
            StyledCell::plain(parent),
            StyledCell::styled(relative_time, Theme::dim()),
        ]);
    }

    println!("{}", table.render());
}

pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) {
    let json_result = NoteListResultJson::from(result);
    let meta = RobotMeta { elapsed_ms };
    let output = serde_json::json!({
        "ok": true,
        "data": json_result,
        "meta": meta,
    });
    let mut output = output;
    if let Some(f) = fields {
        let expanded = expand_fields_preset(f, "notes");
        filter_fields(&mut output, "notes", &expanded);
    }
    match serde_json::to_string(&output) {
        Ok(json) => println!("{json}"),
        Err(e) => eprintln!("Error serializing to JSON: {e}"),
    }
}

pub fn print_list_notes_jsonl(result: &NoteListResult) {
    for note in &result.notes {
        let json_row = NoteListRowJson::from(note);
        match serde_json::to_string(&json_row) {
            Ok(json) => println!("{json}"),
            Err(e) => eprintln!("Error serializing to JSON: {e}"),
        }
    }
}

/// Escape a field for RFC 4180 CSV: quote fields containing commas, quotes, or newlines.
fn csv_escape(field: &str) -> String {
    if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
        let escaped = field.replace('"', "\"\"");
        format!("\"{escaped}\"")
    } else {
        field.to_string()
    }
}

pub fn print_list_notes_csv(result: &NoteListResult) {
    println!(
        "id,gitlab_id,author_username,body,note_type,is_system,created_at,updated_at,position_new_path,position_new_line,noteable_type,parent_iid,project_path"
    );
    for note in &result.notes {
        let body = note.body.as_deref().unwrap_or("");
        let note_type = note.note_type.as_deref().unwrap_or("");
        let path = note.position_new_path.as_deref().unwrap_or("");
        let line = note
            .position_new_line
            .map_or(String::new(), |l| l.to_string());
        let noteable = note.noteable_type.as_deref().unwrap_or("");
        let parent_iid = note.parent_iid.map_or(String::new(), |i| i.to_string());

        println!(
            "{},{},{},{},{},{},{},{},{},{},{},{},{}",
            note.id,
            note.gitlab_id,
            csv_escape(&note.author_username),
            csv_escape(body),
            csv_escape(note_type),
            note.is_system,
            note.created_at,
            note.updated_at,
            csv_escape(path),
            line,
            csv_escape(noteable),
            parent_iid,
            csv_escape(&note.project_path),
        );
    }
}

// ---------------------------------------------------------------------------
// Note query layer
// ---------------------------------------------------------------------------

#[derive(Debug, Serialize)]
pub struct NoteListRow {
    pub id: i64,
    pub gitlab_id: i64,
    pub author_username: String,
    pub body: Option<String>,
    pub note_type: Option<String>,
    pub is_system: bool,
    pub created_at: i64,
    pub updated_at: i64,
    pub position_new_path: Option<String>,
    pub position_new_line: Option<i64>,
    pub position_old_path: Option<String>,
    pub position_old_line: Option<i64>,
    pub resolvable: bool,
    pub resolved: bool,
    pub resolved_by: Option<String>,
    pub noteable_type: Option<String>,
    pub parent_iid: Option<i64>,
    pub parent_title: Option<String>,
    pub project_path: String,
}

#[derive(Serialize)]
pub struct NoteListRowJson {
    pub id: i64,
    pub gitlab_id: i64,
    pub author_username: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub body: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note_type: Option<String>,
    pub is_system: bool,
    pub created_at_iso: String,
    pub updated_at_iso: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub position_new_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub position_new_line: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub position_old_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub position_old_line: Option<i64>,
    pub resolvable: bool,
    pub resolved: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub resolved_by: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub noteable_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_iid: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_title: Option<String>,
    pub project_path: String,
}

impl From<&NoteListRow> for NoteListRowJson {
    fn from(row: &NoteListRow) -> Self {
        Self {
            id: row.id,
            gitlab_id: row.gitlab_id,
            author_username: row.author_username.clone(),
            body: row.body.clone(),
            note_type: row.note_type.clone(),
            is_system: row.is_system,
            created_at_iso: ms_to_iso(row.created_at),
            updated_at_iso: ms_to_iso(row.updated_at),
            position_new_path: row.position_new_path.clone(),
            position_new_line: row.position_new_line,
            position_old_path: row.position_old_path.clone(),
            position_old_line: row.position_old_line,
            resolvable: row.resolvable,
            resolved: row.resolved,
            resolved_by: row.resolved_by.clone(),
            noteable_type: row.noteable_type.clone(),
            parent_iid: row.parent_iid,
            parent_title: row.parent_title.clone(),
            project_path: row.project_path.clone(),
        }
    }
}

#[derive(Debug)]
pub struct NoteListResult {
    pub notes: Vec<NoteListRow>,
    pub total_count: i64,
}

#[derive(Serialize)]
pub struct NoteListResultJson {
    pub notes: Vec<NoteListRowJson>,
    pub total_count: i64,
    pub showing: usize,
}

impl From<&NoteListResult> for NoteListResultJson {
    fn from(result: &NoteListResult) -> Self {
        Self {
            notes: result.notes.iter().map(NoteListRowJson::from).collect(),
            total_count: result.total_count,
            showing: result.notes.len(),
        }
    }
}

pub struct NoteListFilters {
    pub limit: usize,
    pub project: Option<String>,
    pub author: Option<String>,
    pub note_type: Option<String>,
    pub include_system: bool,
    pub for_issue_iid: Option<i64>,
    pub for_mr_iid: Option<i64>,
    pub note_id: Option<i64>,
    pub gitlab_note_id: Option<i64>,
    pub discussion_id: Option<String>,
    pub since: Option<String>,
    pub until: Option<String>,
    pub path: Option<String>,
    pub contains: Option<String>,
    pub resolution: Option<String>,
    pub sort: String,
    pub order: String,
}

pub fn query_notes(
    conn: &Connection,
    filters: &NoteListFilters,
    config: &Config,
) -> Result<NoteListResult> {
    let mut where_clauses: Vec<String> = Vec::new();
    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();

    // Project filter
    if let Some(ref project) = filters.project {
        let project_id = resolve_project(conn, project)?;
        where_clauses.push("n.project_id = ?".to_string());
        params.push(Box::new(project_id));
    }

    // Author filter (case-insensitive, strip leading @)
    if let Some(ref author) = filters.author {
        let username = author.strip_prefix('@').unwrap_or(author);
        where_clauses.push("n.author_username = ? COLLATE NOCASE".to_string());
        params.push(Box::new(username.to_string()));
    }

    // Note type filter
    if let Some(ref note_type) = filters.note_type {
        where_clauses.push("n.note_type = ?".to_string());
        params.push(Box::new(note_type.clone()));
    }

    // System note filter (default: exclude system notes)
    if !filters.include_system {
        where_clauses.push("n.is_system = 0".to_string());
    }

    // Since filter
    let since_ms = if let Some(ref since_str) = filters.since {
        let ms = parse_since(since_str).ok_or_else(|| {
            LoreError::Other(format!(
                "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
                since_str
            ))
        })?;
        where_clauses.push("n.created_at >= ?".to_string());
        params.push(Box::new(ms));
        Some(ms)
    } else {
        None
    };

    // Until filter (end of day for date-only input)
    if let Some(ref until_str) = filters.until {
        let until_ms = if until_str.len() == 10
            && until_str.chars().filter(|&c| c == '-').count() == 2
        {
            // Date-only: use end of day 23:59:59.999
            let iso_full = format!("{until_str}T23:59:59.999Z");
            crate::core::time::iso_to_ms(&iso_full).ok_or_else(|| {
                LoreError::Other(format!(
                    "Invalid --until value '{}'. Use YYYY-MM-DD or relative format.",
                    until_str
                ))
            })?
        } else {
            parse_since(until_str).ok_or_else(|| {
                LoreError::Other(format!(
                    "Invalid --until value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
                    until_str
                ))
            })?
        };

        // Validate since <= until
        if let Some(s) = since_ms
            && s > until_ms
        {
            return Err(LoreError::Other(
                "Invalid time window: --since is after --until.".to_string(),
            ));
        }

        where_clauses.push("n.created_at <= ?".to_string());
        params.push(Box::new(until_ms));
    }

    // Path filter (trailing / = prefix match, else exact)
    if let Some(ref path) = filters.path {
        if let Some(prefix) = path.strip_suffix('/') {
            let escaped = note_escape_like(prefix);
            where_clauses.push("n.position_new_path LIKE ? ESCAPE '\\'".to_string());
            params.push(Box::new(format!("{escaped}%")));
        } else {
            where_clauses.push("n.position_new_path = ?".to_string());
            params.push(Box::new(path.clone()));
        }
    }

    // Contains filter (LIKE %term% on body, case-insensitive)
    if let Some(ref contains) = filters.contains {
        let escaped = note_escape_like(contains);
        where_clauses.push("n.body LIKE ? ESCAPE '\\' COLLATE NOCASE".to_string());
        params.push(Box::new(format!("%{escaped}%")));
    }

    // Resolution filter
    if let Some(ref resolution) = filters.resolution {
        match resolution.as_str() {
            "unresolved" => {
                where_clauses.push("n.resolvable = 1 AND n.resolved = 0".to_string());
            }
            "resolved" => {
                where_clauses.push("n.resolvable = 1 AND n.resolved = 1".to_string());
            }
            other => {
                return Err(LoreError::Other(format!(
                    "Invalid --resolution value '{}'. Use 'resolved' or 'unresolved'.",
                    other
                )));
            }
        }
    }

    // For-issue-iid filter (requires project context)
    if let Some(iid) = filters.for_issue_iid {
        let project_str = filters.project.as_deref().or(config.default_project.as_deref()).ok_or_else(|| {
            LoreError::Other(
                "Cannot filter by issue IID without a project context. Use --project or set defaultProject in config."
                    .to_string(),
            )
        })?;
        let project_id = resolve_project(conn, project_str)?;
        where_clauses.push(
            "d.issue_id = (SELECT id FROM issues WHERE project_id = ? AND iid = ?)".to_string(),
        );
        params.push(Box::new(project_id));
        params.push(Box::new(iid));
    }

    // For-mr-iid filter (requires project context)
    if let Some(iid) = filters.for_mr_iid {
        let project_str = filters.project.as_deref().or(config.default_project.as_deref()).ok_or_else(|| {
            LoreError::Other(
                "Cannot filter by MR IID without a project context. Use --project or set defaultProject in config."
                    .to_string(),
            )
        })?;
        let project_id = resolve_project(conn, project_str)?;
        where_clauses.push(
            "d.merge_request_id = (SELECT id FROM merge_requests WHERE project_id = ? AND iid = ?)"
                .to_string(),
        );
        params.push(Box::new(project_id));
        params.push(Box::new(iid));
    }

    // Note ID filter
    if let Some(id) = filters.note_id {
        where_clauses.push("n.id = ?".to_string());
        params.push(Box::new(id));
    }

    // GitLab note ID filter
    if let Some(gitlab_id) = filters.gitlab_note_id {
        where_clauses.push("n.gitlab_id = ?".to_string());
        params.push(Box::new(gitlab_id));
    }

    // Discussion ID filter
    if let Some(ref disc_id) = filters.discussion_id {
        where_clauses.push("d.gitlab_discussion_id = ?".to_string());
        params.push(Box::new(disc_id.clone()));
    }

    let where_sql = if where_clauses.is_empty() {
        String::new()
    } else {
        format!("WHERE {}", where_clauses.join(" AND "))
    };

    // Count query
    let count_sql = format!(
        "SELECT COUNT(*) FROM notes n
         JOIN discussions d ON n.discussion_id = d.id
         JOIN projects p ON n.project_id = p.id
         LEFT JOIN issues i ON d.issue_id = i.id
         LEFT JOIN merge_requests m ON d.merge_request_id = m.id
         {where_sql}"
    );

    let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
    let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;

    // Sort + order
    let sort_column = match filters.sort.as_str() {
        "updated" => "n.updated_at",
        _ => "n.created_at",
    };
    let order = if filters.order == "asc" {
        "ASC"
    } else {
        "DESC"
    };

    let query_sql = format!(
        "SELECT
            n.id,
            n.gitlab_id,
            n.author_username,
            n.body,
            n.note_type,
            n.is_system,
            n.created_at,
            n.updated_at,
            n.position_new_path,
            n.position_new_line,
            n.position_old_path,
            n.position_old_line,
            n.resolvable,
            n.resolved,
            n.resolved_by,
            d.noteable_type,
            COALESCE(i.iid, m.iid) AS parent_iid,
            COALESCE(i.title, m.title) AS parent_title,
            p.path_with_namespace AS project_path
         FROM notes n
         JOIN discussions d ON n.discussion_id = d.id
         JOIN projects p ON n.project_id = p.id
         LEFT JOIN issues i ON d.issue_id = i.id
         LEFT JOIN merge_requests m ON d.merge_request_id = m.id
         {where_sql}
         ORDER BY {sort_column} {order}, n.id {order}
         LIMIT ?"
    );

    params.push(Box::new(filters.limit as i64));
    let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();

    let mut stmt = conn.prepare(&query_sql)?;
    let notes: Vec<NoteListRow> = stmt
        .query_map(param_refs.as_slice(), |row| {
            let is_system_int: i64 = row.get(5)?;
            let resolvable_int: i64 = row.get(12)?;
            let resolved_int: i64 = row.get(13)?;

            Ok(NoteListRow {
                id: row.get(0)?,
                gitlab_id: row.get(1)?,
                author_username: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
                body: row.get(3)?,
                note_type: row.get(4)?,
                is_system: is_system_int == 1,
                created_at: row.get(6)?,
                updated_at: row.get(7)?,
                position_new_path: row.get(8)?,
                position_new_line: row.get(9)?,
                position_old_path: row.get(10)?,
                position_old_line: row.get(11)?,
                resolvable: resolvable_int == 1,
                resolved: resolved_int == 1,
                resolved_by: row.get(14)?,
                noteable_type: row.get(15)?,
                parent_iid: row.get(16)?,
                parent_title: row.get(17)?,
                project_path: row.get(18)?,
            })
        })?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    Ok(NoteListResult { notes, total_count })
}

#[cfg(test)]
#[path = "list_tests.rs"]
mod tests;
