//! Shared chat rendering for DM + Group panes — extracted from the legacy
//! `ui/room.rs`. Message list, input box, day separators, scroll indicator,
//! verified marker, typing indicator. The DM and Group panes wrap this
//! with their own header and (for Group) member margin.

use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap};
use ratatui::Frame;

use crate::app::{OpenRoom, TuiApp};
use crate::ui::file_card;
use crate::ui::short_fp;
use crate::ui::theme::Theme;

const MSG_LABEL_WIDTH: usize = 12;
// 2 lead + 8 (HH:MM:SS) + 2 + label + 2 trailing. `pub(crate)` so `file_card`
// reserves the SAME left column (cards sit under message bodies) from one
// source of truth — they previously drifted apart.
pub(crate) const MSG_PREFIX_WIDTH: usize = 2 + 8 + 2 + MSG_LABEL_WIDTH + 2; // 26

/// Compute the desired height for the input box, accounting for the
/// number of lines the user has typed (including soft-wrapped lines).
pub fn input_height(r: &OpenRoom, screen_width: u16) -> u16 {
    let inner_w = screen_width.saturating_sub(4) as usize;
    let prompt_w = 2usize;
    let body_w = inner_w.saturating_sub(prompt_w).max(1);
    let mut lines: usize = 0;
    if r.input.is_empty() {
        lines = 1;
    } else {
        for raw_line in r.input.split('\n') {
            let chars = raw_line.chars().count();
            let n = ((chars + body_w) / body_w).max(1);
            lines += n;
        }
    }
    let clamped = lines.clamp(1, 8) as u16;
    clamped + 2 // borders
}

pub fn render_messages(f: &mut Frame, area: Rect, app: &TuiApp, theme: &Theme, room_id: &str) {
    let r = match app.open_room(room_id) {
        Some(r) => r,
        None => return,
    };
    let me = app.handle.fingerprint().to_string();
    let verified: std::collections::HashSet<String> = app
        .handle
        .verified_fingerprints(room_id)
        .into_iter()
        .collect();

    let inner_w = area.width.saturating_sub(4) as usize;
    let body_w = inner_w.saturating_sub(MSG_PREFIX_WIDTH).max(8);

    // huddle 2.0.0 (F10): grouped reaction badges (target id → [(emoji, count)]),
    // and the `client_msg_id` of the currently-selected message (for the
    // react/reply/edit/delete cursor highlight).
    let reactions = reaction_counts(app, room_id);
    let selected_id = selected_msg_client_id(r);

    enum Row<'a> {
        Text(&'a huddle_core::storage::repo::StoredRoomMessage),
        Card(&'a huddle_core::storage::repo::StoredAttachment, bool),
    }
    let mut timeline: Vec<(i64, Row)> = Vec::new();
    for m in &r.messages {
        timeline.push((m.sent_at, Row::Text(m)));
    }
    for (i, a) in r.attachments.iter().enumerate() {
        let focused = r.card_focus && i == r.focused_card_idx;
        timeline.push((a.created_at, Row::Card(a, focused)));
    }
    timeline.sort_by_key(|(ts, _)| *ts);

    let mut lines: Vec<Line> = Vec::new();
    let mut prev_day: Option<i64> = None;
    let mut prev_ts: Option<i64> = None;
    for (ts, row) in timeline {
        let day = ts.div_euclid(86_400);
        if prev_day.map(|p| p != day).unwrap_or(true) {
            // New calendar day → a dated separator.
            lines.push(separator_line(ts, inner_w, theme));
            prev_day = Some(day);
        } else if prev_ts
            .map(|p| ts - p >= huddle_core::app::MESSAGE_GROUP_GAP_SECS)
            .unwrap_or(false)
        {
            // huddle 1.2.3: same day but a quiet gap — a time separator so the
            // pause is visible instead of the messages running together.
            lines.push(time_separator_line(ts, inner_w, theme));
        }
        prev_ts = Some(ts);
        match row {
            Row::Text(m) => {
                let is_me = m.sender_fingerprint == me || m.direction == "out";
                let label = if is_me {
                    app.handle
                        .display_name()
                        .unwrap_or_else(|| "you".to_string())
                } else {
                    app.handle
                        .lookup_username(&m.sender_fingerprint)
                        .unwrap_or_else(|| "[anonymous]".to_string())
                };
                let label: String = label.chars().take(MSG_LABEL_WIDTH).collect();
                let label_style = if is_me {
                    Style::default().fg(theme.warn).add_modifier(Modifier::BOLD)
                } else {
                    Style::default()
                        .fg(theme.accent)
                        .add_modifier(Modifier::BOLD)
                };
                let is_verified = !is_me && verified.contains(&m.sender_fingerprint);
                let time = format_time(m.sent_at);
                // huddle 2.0.0 (F10): is this the message the affordance
                // keybindings currently target?
                let is_selected = match (&selected_id, &m.client_msg_id) {
                    (Some(sel), Some(id)) => sel == id,
                    _ => false,
                };
                let deleted = m.deleted_at.is_some();
                let edited = m.edited_at.is_some() && !deleted;

                // huddle 2.0.0 (F10): reply context — an indented quote of the
                // message this one replies to, rendered above it.
                if let Some(reply_to) = &m.reply_to {
                    if let Some(preview) = reply_preview(r, app, reply_to, &me) {
                        lines.push(Line::from(vec![
                            Span::styled(" ".repeat(MSG_PREFIX_WIDTH), theme.dim()),
                            Span::styled(format!("┊ {}", preview), theme.dim()),
                        ]));
                    }
                }

                // Deleted messages render a tombstone in place of the body;
                // otherwise wrap the (possibly edited) plaintext.
                let body_style = if deleted {
                    theme.dim().add_modifier(Modifier::ITALIC)
                } else {
                    theme.text_style()
                };
                let chunks = if deleted {
                    vec!["[deleted]".to_string()]
                } else {
                    wrap_body(&m.body, body_w)
                };
                let last = chunks.len().saturating_sub(1);
                for (i, chunk) in chunks.iter().enumerate() {
                    if i == 0 {
                        // huddle 2.0.0 (F10): the 2-col lead doubles as the
                        // selection cursor (▸) without shifting alignment.
                        let lead = if is_selected { "▸ " } else { "  " };
                        let lead_style = if is_selected {
                            theme.warn_style()
                        } else {
                            theme.dim()
                        };
                        let mut spans = vec![
                            Span::styled(lead, lead_style),
                            Span::styled(format!("{}  ", time), theme.dim()),
                            Span::styled(
                                format!("{:<width$}", label, width = MSG_LABEL_WIDTH),
                                label_style,
                            ),
                            Span::raw("  "),
                        ];
                        if is_verified {
                            spans.push(Span::styled("✓ ", theme.ok()));
                        }
                        spans.push(Span::styled(chunk.clone(), body_style));
                        if edited && i == last {
                            spans.push(Span::styled("  [edited]", theme.dim()));
                        }
                        lines.push(Line::from(spans));
                    } else {
                        let mut spans = vec![
                            Span::styled(" ".repeat(MSG_PREFIX_WIDTH), theme.dim()),
                            Span::styled(chunk.clone(), body_style),
                        ];
                        if edited && i == last {
                            spans.push(Span::styled("  [edited]", theme.dim()));
                        }
                        lines.push(Line::from(spans));
                    }
                }

                // huddle 2.0.0 (F10): reaction badges under the message.
                if !deleted {
                    if let Some(id) = &m.client_msg_id {
                        if let Some(badges) = reactions.get(id) {
                            if !badges.is_empty() {
                                let mut spans: Vec<Span> =
                                    vec![Span::styled(" ".repeat(MSG_PREFIX_WIDTH), theme.dim())];
                                for (emoji, count) in badges {
                                    spans.push(Span::styled(
                                        format!("{} {}  ", emoji, count),
                                        theme.warn_style(),
                                    ));
                                }
                                lines.push(Line::from(spans));
                            }
                        }
                    }
                }
            }
            Row::Card(a, focused) => {
                let card = file_card::render_card_lines(a, inner_w, focused);
                lines.extend(card);
            }
        }
    }

    if lines.is_empty() {
        lines.push(Line::from(Span::styled(
            "  no messages yet — say hi! press / to type.",
            theme.dim(),
        )));
    }

    let visible_h = area.height.saturating_sub(2);
    let total = lines.len() as u16;
    let max_scroll = total.saturating_sub(visible_h);
    r.last_max_scroll.set(max_scroll);
    let scroll_y = if r.follow_mode {
        max_scroll
    } else {
        r.scroll.min(max_scroll)
    };

    let title = if max_scroll == 0 {
        " ".to_string()
    } else if r.follow_mode {
        format!(" {}/{}  · live ", total.saturating_sub(1), total)
    } else {
        let current_line = scroll_y + visible_h.min(total);
        format!(
            " {}/{}  · ↑ {} above  · g/G top/bottom ",
            current_line.min(total),
            total,
            scroll_y
        )
    };

    let widget = Paragraph::new(lines)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(theme.border_style())
                .padding(Padding::horizontal(1))
                .title_bottom(Line::from(Span::styled(title, theme.dim()))),
        )
        .scroll((scroll_y, 0));
    f.render_widget(widget, area);
}

pub fn render_input(f: &mut Frame, area: Rect, app: &TuiApp, theme: &Theme, room_id: &str) {
    let r = match app.open_room(room_id) {
        Some(r) => r,
        None => return,
    };
    // huddle 1.2: surface real deliverability in the composer so the user
    // isn't typing into a window that can't send. When not Ready, the border
    // turns to the error style and the idle hint shows why.
    let readiness = app.handle.room_send_readiness(room_id);
    let not_ready = !readiness.can_send();
    let border_style = if not_ready {
        theme.err_style()
    } else if r.input_active {
        theme.warn_style()
    } else {
        theme.border_style()
    };

    let lines: Vec<Line> = if not_ready && !r.input_active {
        vec![Line::from(Span::styled(
            format!("○ {}", readiness.reason()),
            theme.err_style(),
        ))]
    } else if !r.input_active {
        vec![Line::from(Span::styled(
            "press / to type   ·   Alt+Enter or ^J for newline   ·   Ctrl+P for command palette",
            theme.dim(),
        ))]
    } else {
        let mut out: Vec<Line> = Vec::new();
        let raw_lines: Vec<&str> = if r.input.is_empty() {
            vec![""]
        } else {
            r.input.split('\n').collect()
        };
        let last = raw_lines.len().saturating_sub(1);
        for (i, line) in raw_lines.iter().enumerate() {
            let prompt = if i == 0 { "> " } else { "  " };
            let body = if i == last {
                format!("{}_", line)
            } else {
                (*line).to_string()
            };
            out.push(Line::from(vec![
                Span::styled(prompt, theme.dim()),
                Span::styled(body, theme.text_style()),
            ]));
        }
        out
    };

    let widget = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
        Block::default()
            .borders(Borders::ALL)
            .border_style(border_style)
            .padding(Padding::horizontal(1)),
    );
    f.render_widget(widget, area);
}

fn separator_line(unix_secs: i64, inner_w: usize, theme: &Theme) -> Line<'static> {
    let date = format_ymd(unix_secs);
    let label = format!(" {} ", date);
    let total = inner_w.saturating_sub(2);
    let side = total.saturating_sub(label.chars().count()) / 2;
    let dashes = "─".repeat(side.max(3));
    Line::from(vec![
        Span::raw("  "),
        Span::styled(dashes.clone(), theme.dim()),
        Span::styled(label, theme.dim()),
        Span::styled(dashes, theme.dim()),
    ])
}

/// huddle 1.2.3: a within-day separator marking a quiet gap, labelled with the
/// UTC time the conversation resumed. Same centered-rule style as the dated
/// separator, so a pause reads at a glance even though each line already shows
/// its own timestamp.
fn time_separator_line(unix_secs: i64, inner_w: usize, theme: &Theme) -> Line<'static> {
    let label = format!(" {} UTC ", format_time(unix_secs));
    let total = inner_w.saturating_sub(2);
    let side = total.saturating_sub(label.chars().count()) / 2;
    let dashes = "─".repeat(side.max(3));
    Line::from(vec![
        Span::raw("  "),
        Span::styled(dashes.clone(), theme.dim()),
        Span::styled(label, theme.dim()),
        Span::styled(dashes, theme.dim()),
    ])
}

fn wrap_body(body: &str, width: usize) -> Vec<String> {
    if width == 0 {
        return vec![body.to_string()];
    }
    let mut out = Vec::new();
    for line in body.split('\n') {
        if line.is_empty() {
            out.push(String::new());
            continue;
        }
        let chars: Vec<char> = line.chars().collect();
        let mut start = 0;
        while start < chars.len() {
            let end = (start + width).min(chars.len());
            out.push(chars[start..end].iter().collect());
            start = end;
        }
    }
    out
}

/// UTC `HH:MM:SS` for a unix timestamp. Seconds are shown so a pause of even a
/// minute or two between messages is visible (UTC, matching the logs).
fn format_time(unix_secs: i64) -> String {
    let secs_today = unix_secs.rem_euclid(86_400) as u32;
    let hh = (secs_today / 3600) % 24;
    let mm = (secs_today / 60) % 60;
    let ss = secs_today % 60;
    format!("{:02}:{:02}:{:02}", hh, mm, ss)
}

fn format_ymd(unix_secs: i64) -> String {
    let days = unix_secs.div_euclid(86_400);
    let jdn = days + 2440588;
    let f = jdn + 1401 + ((((4 * jdn) + 274_277) / 146_097) * 3) / 4 - 38;
    let e = 4 * f + 3;
    let g = (e.rem_euclid(1461)) / 4;
    let h = 5 * g + 2;
    let day = (h.rem_euclid(153)) / 5 + 1;
    let month = (h / 153 + 2).rem_euclid(12) + 1;
    let year = e.div_euclid(1461) - 4716 + (12 + 2 - month) / 12;
    format!("{:04}-{:02}-{:02}", year, month, day)
}

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

    #[test]
    fn format_time_shows_seconds_utc() {
        assert_eq!(format_time(0), "00:00:00");
        // 2021-01-01 03:25:07 UTC
        assert_eq!(
            format_time(1_609_459_200 + 3 * 3600 + 25 * 60 + 7),
            "03:25:07"
        );
        assert_eq!(format_time(86_399), "23:59:59");
    }
}

/// huddle 2.0.0 (F9): format a disappearing-messages TTL for the room header
/// indicator — whole days / hours / minutes when they divide evenly, else
/// seconds. Best-effort, human-readable ("1 hour", "7 days").
pub fn format_ttl(secs: u32) -> String {
    let s = secs as u64;
    if s >= 86_400 && s % 86_400 == 0 {
        let d = s / 86_400;
        format!("{} day{}", d, if d == 1 { "" } else { "s" })
    } else if s >= 3_600 && s % 3_600 == 0 {
        let h = s / 3_600;
        format!("{} hour{}", h, if h == 1 { "" } else { "s" })
    } else if s >= 60 && s % 60 == 0 {
        format!("{} min", s / 60)
    } else {
        format!("{}s", s)
    }
}

/// huddle 2.0.0 (F10): group a room's reactions by target message into
/// `(emoji, count)` badges, preserving first-seen emoji order.
fn reaction_counts(
    app: &TuiApp,
    room_id: &str,
) -> std::collections::HashMap<String, Vec<(String, usize)>> {
    let mut map: std::collections::HashMap<String, Vec<(String, usize)>> =
        std::collections::HashMap::new();
    for rx in app.handle.room_reactions(room_id) {
        let entry = map.entry(rx.target_client_msg_id.clone()).or_default();
        if let Some(slot) = entry.iter_mut().find(|(e, _)| *e == rx.emoji) {
            slot.1 += 1;
        } else {
            entry.push((rx.emoji.clone(), 1));
        }
    }
    map
}

/// huddle 2.0.0 (F10): the `client_msg_id` of the explicitly-selected message
/// in this room, if the user has moved the selection cursor (`[` / `]`). Returns
/// `None` when no explicit selection is active so the cursor marker stays hidden
/// during ordinary reading.
fn selected_msg_client_id(r: &OpenRoom) -> Option<String> {
    let idx = r.selected_msg?;
    let m = r.messages.get(idx)?;
    if m.client_msg_id.is_some() && m.deleted_at.is_none() {
        m.client_msg_id.clone()
    } else {
        None
    }
}

/// huddle 2.0.0 (F10): a one-line preview of the message `reply_to` points at,
/// shown as an indented quote above a reply. `None` when the target isn't in the
/// loaded history (pre-2.0, since-pruned, or never received).
fn reply_preview(r: &OpenRoom, app: &TuiApp, reply_to: &str, me: &str) -> Option<String> {
    let target = r
        .messages
        .iter()
        .find(|m| m.client_msg_id.as_deref() == Some(reply_to))?;
    let who = if target.sender_fingerprint.as_str() == me || target.direction == "out" {
        app.handle
            .display_name()
            .unwrap_or_else(|| "you".to_string())
    } else {
        app.handle
            .lookup_username(&target.sender_fingerprint)
            .unwrap_or_else(|| "[anonymous]".to_string())
    };
    let who: String = who.chars().take(MSG_LABEL_WIDTH).collect();
    let body = if target.deleted_at.is_some() {
        "[deleted]".to_string()
    } else {
        let flat = target.body.replace('\n', " ");
        flat.chars().take(48).collect()
    };
    Some(format!("{}: {}", who, body))
}

/// huddle 0.7: render the typing indicator (used by both DM and Group headers).
pub fn typing_line<'a>(app: &TuiApp, theme: &Theme, room_id: &str) -> Option<Line<'a>> {
    let typers = app.handle.typers_in_room(room_id);
    let me = app.handle.fingerprint().to_string();
    let names: Vec<String> = typers
        .iter()
        .filter(|fp| *fp != &me)
        .map(|fp| short_fp(fp))
        .collect();
    if names.is_empty() {
        return None;
    }
    let txt = if names.len() == 1 {
        format!("{} is typing…", names[0])
    } else {
        format!("{} are typing…", names.join(", "))
    };
    Some(Line::from(Span::styled(format!("  {}", txt), theme.dim())))
}
