use gotcha_gitea::{
    PullFileSource, activity, comment_can_edit, diff, models, pull_file_source, pull_state,
};

use crate::domain::{
    HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
    PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
};

#[derive(Clone, uniffi::Record)]
pub struct ServerRow {
    pub name: String,
    pub url: String,
}

#[derive(Clone, uniffi::Record)]
pub struct RepositoryRow {
    pub name: String,
    pub owner: String,
    pub description: String,
    pub meta: String,
    pub favorite: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct RepositoryListPage {
    pub rows: Vec<RepositoryRow>,
    pub has_more: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct LabelRow {
    pub name: String,
    pub color: String,
    pub light: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct IssueRow {
    pub number: i64,
    pub state: String,
    pub title: String,
    pub summary: String,
    pub meta: String,
    pub milestone: String,
    pub labels: Vec<LabelRow>,
}

#[derive(Clone, uniffi::Record)]
pub struct IssueListPage {
    pub rows: Vec<IssueRow>,
    pub has_more: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct PullRow {
    pub number: i64,
    pub owner: String,
    pub repository: String,
    pub title: String,
    pub summary: String,
    pub meta: String,
}

#[derive(Clone, uniffi::Record)]
pub struct CommentRow {
    pub id: i64,
    pub author: String,
    pub body: String,
    pub meta: String,
    pub can_edit: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct IssuePage {
    pub title: String,
    pub state: String,
    pub meta: String,
    pub milestone: String,
    pub body: String,
    pub comments: Vec<CommentRow>,
    pub has_more: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct IssueEditorLabel {
    pub id: i64,
    pub name: String,
    pub selected: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct IssueEditorMilestone {
    pub id: i64,
    pub title: String,
    pub selected: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct IssueEditorPage {
    pub title: String,
    pub body: String,
    pub due_date: Option<i64>,
    pub labels: Vec<IssueEditorLabel>,
    pub milestones: Vec<IssueEditorMilestone>,
}

#[derive(Clone, uniffi::Record)]
pub struct IssueFilterOptions {
    pub milestones: Vec<String>,
    pub labels: Vec<String>,
    pub unavailable_labels: Vec<String>,
    pub selected_milestone: String,
    pub selected_labels: Vec<String>,
}

#[derive(Clone, uniffi::Record)]
pub struct MilestoneRow {
    pub id: i64,
    pub title: String,
    pub description: String,
    pub meta: String,
    pub has_issues: bool,
    pub progress: f64,
    pub progress_accessibility: String,
}

#[derive(Clone, uniffi::Record)]
pub struct MilestonePage {
    pub milestone: MilestoneRow,
    pub issues: Vec<IssueRow>,
    pub pulls: Vec<PullRow>,
    pub has_more: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct MilestoneListPage {
    pub rows: Vec<MilestoneRow>,
    pub has_more: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct MilestoneEditorPage {
    pub title: String,
    pub description: String,
    pub due_date: Option<i64>,
}

#[derive(Clone, uniffi::Record)]
pub struct PullFilterOptions {
    pub milestones: Vec<String>,
    pub selected_milestone: String,
}

#[derive(Clone, uniffi::Record)]
pub struct PullPage {
    pub title: String,
    pub meta: String,
    pub body: String,
    pub files_ref: String,
    pub files: Vec<FileRow>,
    pub comments: Vec<CommentRow>,
    pub has_more: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct PullListPage {
    pub rows: Vec<PullRow>,
    pub has_more: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct CommitRow {
    pub sha: String,
    pub title: String,
    pub detail: String,
    pub top_lanes: Vec<u32>,
    pub bottom_lanes: Vec<u32>,
    pub node_lane: Option<u32>,
    pub top_connections: Vec<u32>,
    pub bottom_connections: Vec<u32>,
}

#[derive(Clone, uniffi::Record)]
pub struct CommitPage {
    pub branches: Vec<String>,
    pub commits: Vec<CommitRow>,
    pub lane_count: u32,
    pub has_more: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct FileRow {
    pub path: String,
    pub status: String,
}

#[derive(Clone, uniffi::Record)]
pub struct FileListPage {
    pub rows: Vec<FileRow>,
    pub has_more: bool,
}

#[derive(Clone, uniffi::Record)]
pub struct RepositoryContentRow {
    pub name: String,
    pub path: String,
    pub kind: String,
    pub size: i64,
}

#[derive(Clone, uniffi::Record)]
pub struct RepositoryFilePage {
    pub name: String,
    pub kind: String,
    pub language: String,
    pub text: String,
    pub data: Vec<u8>,
}

#[derive(Clone, uniffi::Record)]
pub struct DiffLine {
    pub old_number: String,
    pub new_number: String,
    pub text: String,
    pub kind: String,
}

#[derive(Clone, uniffi::Record)]
pub struct DiffPage {
    pub title: String,
    pub columns: u32,
    pub lines: Vec<DiffLine>,
}

#[derive(Clone, uniffi::Record)]
pub struct ActivityRow {
    pub icon: String,
    pub title: String,
    pub detail: String,
    pub meta: String,
    pub target: String,
    pub owner: String,
    pub repository: String,
    pub number: i64,
    pub sha: String,
}

#[derive(Clone, Copy, Debug, uniffi::Enum)]
pub enum HomeActivityFilter {
    All,
    Issues,
    PullRequests,
}

impl From<HomeActivityFilter> for gotcha_gitea::ActivityFilter {
    fn from(filter: HomeActivityFilter) -> Self {
        match filter {
            HomeActivityFilter::All => Self::All,
            HomeActivityFilter::Issues => Self::Issues,
            HomeActivityFilter::PullRequests => Self::PullRequests,
        }
    }
}

#[derive(Clone, uniffi::Record)]
pub struct HeatCell {
    pub level: u32,
    pub timestamp: i64,
}

#[derive(Clone, uniffi::Record)]
pub struct HomePage {
    pub server_name: String,
    pub activities: Vec<ActivityRow>,
    pub heat_cells: Vec<HeatCell>,
    pub contribution_count: i64,
    pub next_page: Option<u32>,
}

pub fn repository_rows(
    repositories: &[RepositoryData],
    is_favorite: impl Fn(&RepositoryData) -> bool,
) -> Vec<RepositoryRow> {
    let mut repositories = repositories.to_vec();
    repositories
        .sort_by_key(|repository| (!is_favorite(repository), repository.name.to_lowercase()));
    repositories
        .into_iter()
        .map(|repository| RepositoryRow {
            favorite: is_favorite(&repository),
            meta: format!(
                "{}  ·  {} open  ·  {}",
                repository.language, repository.open_issues, repository.updated
            ),
            name: repository.name,
            owner: repository.owner,
            description: repository.description,
        })
        .collect()
}

pub fn issue_rows(issues: &[models::Issue]) -> Vec<IssueRow> {
    issues
        .iter()
        .map(|issue| IssueRow {
            number: issue.number.unwrap_or_default(),
            state: issue.state.clone().unwrap_or_else(|| "unknown".into()),
            title: issue
                .title
                .clone()
                .unwrap_or_else(|| "Untitled issue".into()),
            summary: issue
                .body
                .as_deref()
                .map(summary)
                .unwrap_or_else(|| "No description".into()),
            meta: issue_meta(issue),
            milestone: issue_milestone(issue),
            labels: issue
                .labels
                .as_deref()
                .unwrap_or_default()
                .iter()
                .filter_map(label_row)
                .collect(),
        })
        .collect()
}

pub fn issue_filter_options(
    labels: &[models::Label],
    milestones: &[models::Milestone],
    filter: &IssueFilter,
) -> IssueFilterOptions {
    let mut labels: Vec<_> = labels
        .iter()
        .filter_map(|label| label.name.clone())
        .collect();
    let unavailable_labels = filter
        .labels
        .iter()
        .filter(|label| !labels.contains(label))
        .cloned()
        .collect();
    labels.extend(filter.labels.iter().cloned());
    labels.sort_by_key(|label| label.to_lowercase());
    labels.dedup();
    let mut milestones: Vec<_> = milestones
        .iter()
        .filter_map(|milestone| milestone.title.clone())
        .collect();
    if !filter.milestone.is_empty() {
        milestones.push(filter.milestone.clone());
    }
    milestones.sort_by_key(|milestone| milestone.to_lowercase());
    milestones.dedup();
    IssueFilterOptions {
        milestones,
        labels,
        unavailable_labels,
        selected_milestone: filter.milestone.clone(),
        selected_labels: filter.labels.iter().cloned().collect(),
    }
}

pub fn milestone_rows(milestones: &[models::Milestone]) -> Vec<MilestoneRow> {
    let mut milestones = milestones.to_vec();
    milestones.sort_by_key(|milestone| {
        (
            milestone.state.as_deref() == Some("closed"),
            milestone
                .title
                .as_deref()
                .unwrap_or_default()
                .to_lowercase(),
        )
    });
    milestones.iter().map(milestone_row).collect()
}

pub fn milestone_page(details: MilestoneDetails) -> MilestonePage {
    MilestonePage {
        milestone: milestone_row(&details.milestone),
        issues: issue_rows(&details.issues),
        pulls: pull_rows(&details.pulls),
        has_more: details.has_more,
    }
}

pub fn milestone_editor_page(milestone: Option<models::Milestone>) -> MilestoneEditorPage {
    MilestoneEditorPage {
        title: milestone
            .as_ref()
            .and_then(|milestone| milestone.title.clone())
            .unwrap_or_default(),
        description: milestone
            .as_ref()
            .and_then(|milestone| milestone.description.clone())
            .unwrap_or_default(),
        due_date: milestone
            .as_ref()
            .and_then(|milestone| milestone.due_on.as_deref())
            .and_then(parse_api_date),
    }
}

pub fn pull_filter_options(milestones: &[String], filter: &PullFilter) -> PullFilterOptions {
    let mut milestones = milestones.to_vec();
    if !filter.milestone.is_empty() {
        milestones.push(filter.milestone.clone());
    }
    milestones.sort_by_key(|milestone| milestone.to_lowercase());
    milestones.dedup();
    PullFilterOptions {
        milestones,
        selected_milestone: filter.milestone.clone(),
    }
}

pub fn pull_rows(pulls: &[models::Issue]) -> Vec<PullRow> {
    pulls
        .iter()
        .filter_map(|pull| {
            let repository = pull.repository.as_ref()?;
            let author = pull
                .user
                .as_ref()
                .and_then(|user| user.login.as_deref())
                .unwrap_or("unknown");
            let kind = if pull
                .pull_request
                .as_ref()
                .and_then(|pull| pull.draft)
                .unwrap_or(false)
            {
                "Draft"
            } else {
                pull.state.as_deref().unwrap_or("unknown")
            };
            Some(PullRow {
                number: pull.number.unwrap_or_default(),
                owner: repository.owner.clone()?,
                repository: repository.name.clone()?,
                title: format!(
                    "{}  #{}\n{}",
                    repository.name.as_deref()?,
                    pull.number.unwrap_or_default(),
                    pull.title.as_deref().unwrap_or("Untitled pull request")
                ),
                summary: pull
                    .body
                    .as_deref()
                    .map(summary)
                    .filter(|body| !body.is_empty())
                    .unwrap_or_else(|| "No description".into()),
                meta: format!(
                    "{kind}  ·  {author}  ·  {}  ·  {} comments",
                    compact_date(pull.updated_at.as_deref()),
                    pull.comments.unwrap_or_default()
                ),
            })
        })
        .collect()
}

pub fn commit_page(
    branches: Vec<String>,
    commits: &[HistoryCommit],
    show_graph: bool,
    has_more: bool,
) -> CommitPage {
    let lane_count = if show_graph {
        commits
            .iter()
            .flat_map(|commit| {
                commit
                    .top_lanes
                    .iter()
                    .chain(commit.bottom_lanes.iter())
                    .chain(commit.top_connections.iter())
                    .chain(commit.bottom_connections.iter())
                    .chain(commit.node_lane.iter())
            })
            .max()
            .map_or(0, |lane| lane + 1)
    } else {
        0
    };
    let commits = commits
        .iter()
        .filter_map(|row| {
            let commit = &row.commit;
            let sha = commit.sha.as_deref()?;
            let details = commit.commit.as_ref();
            let author = details
                .and_then(|commit| commit.author.as_ref())
                .and_then(|author| author.name.as_deref())
                .or_else(|| {
                    commit
                        .author
                        .as_ref()
                        .and_then(|author| author.login.as_deref())
                })
                .unwrap_or("unknown");
            let date = details
                .and_then(|commit| commit.author.as_ref())
                .and_then(|author| author.date.as_deref())
                .or(commit.created.as_deref());
            Some(CommitRow {
                sha: sha.into(),
                title: details
                    .and_then(|commit| commit.message.as_deref())
                    .map(|message| message.lines().next().unwrap_or(message))
                    .unwrap_or("Commit")
                    .into(),
                detail: if row.refs.is_empty() {
                    format!("{author}  ·  {}\n{}", compact_date(date), short_sha(sha))
                } else {
                    format!(
                        "{}\n{author}  ·  {}  ·  {}",
                        row.refs.join("  ·  "),
                        compact_date(date),
                        short_sha(sha)
                    )
                },
                top_lanes: indices(&row.top_lanes),
                bottom_lanes: indices(&row.bottom_lanes),
                node_lane: row.node_lane.map(|lane| lane as u32),
                top_connections: indices(&row.top_connections),
                bottom_connections: indices(&row.bottom_connections),
            })
        })
        .collect();
    CommitPage {
        branches,
        commits,
        lane_count: lane_count as u32,
        has_more,
    }
}

pub fn issue_page(details: IssueDetails) -> IssuePage {
    IssuePage {
        title: details
            .issue
            .title
            .clone()
            .unwrap_or_else(|| "Untitled issue".into()),
        state: details
            .issue
            .state
            .clone()
            .unwrap_or_else(|| "unknown".into()),
        meta: issue_meta(&details.issue),
        milestone: issue_milestone(&details.issue),
        body: details
            .issue
            .body
            .filter(|body| !body.is_empty())
            .unwrap_or_else(|| "No description provided.".into()),
        comments: details
            .comments
            .iter()
            .map(|comment| comment_row(comment, details.viewer_id))
            .collect(),
        has_more: details.has_more,
    }
}

pub fn issue_editor_page(data: IssueEditorData) -> IssueEditorPage {
    let selected_labels: std::collections::BTreeSet<_> = data
        .issue
        .as_ref()
        .and_then(|issue| issue.labels.as_deref())
        .unwrap_or_default()
        .iter()
        .filter_map(|label| label.id)
        .collect();
    let selected_milestone = data
        .issue
        .as_ref()
        .and_then(|issue| issue.milestone.as_ref())
        .and_then(|milestone| milestone.id);
    let mut labels: Vec<_> = data
        .labels
        .into_iter()
        .filter_map(|label| {
            let id = label.id?;
            Some(IssueEditorLabel {
                id,
                name: label.name?,
                selected: selected_labels.contains(&id),
            })
        })
        .collect();
    labels.sort_by_key(|label| label.name.to_lowercase());
    let mut milestones: Vec<_> = data
        .milestones
        .into_iter()
        .filter(|milestone| {
            milestone.state.as_deref() != Some("closed") || milestone.id == selected_milestone
        })
        .filter_map(|milestone| {
            let id = milestone.id?;
            Some(IssueEditorMilestone {
                id,
                title: milestone.title?,
                selected: Some(id) == selected_milestone,
            })
        })
        .collect();
    milestones.sort_by_key(|milestone| milestone.title.to_lowercase());
    IssueEditorPage {
        title: data
            .issue
            .as_ref()
            .and_then(|issue| issue.title.clone())
            .unwrap_or_default(),
        body: data
            .issue
            .as_ref()
            .and_then(|issue| issue.body.clone())
            .unwrap_or_default(),
        due_date: data
            .issue
            .as_ref()
            .and_then(|issue| issue.due_date.as_deref())
            .and_then(parse_api_date),
        labels,
        milestones,
    }
}

pub fn pull_page(details: PullDetails) -> PullPage {
    let pull = &details.pull;
    let author = pull
        .user
        .as_ref()
        .and_then(|user| user.login.as_deref())
        .unwrap_or("unknown");
    let head = pull
        .head
        .as_ref()
        .and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
        .unwrap_or("head");
    let base = pull
        .base
        .as_ref()
        .and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
        .unwrap_or("base");
    let state = pull_state(pull);
    PullPage {
        title: pull
            .title
            .clone()
            .unwrap_or_else(|| "Untitled pull request".into()),
        meta: format!(
            "#{}  ·  {state}  ·  {author}  ·  {head} → {base}\n{} files  ·  +{} −{}  ·  {} comments",
            pull.number.unwrap_or_default(),
            pull.changed_files.unwrap_or_default(),
            pull.additions.unwrap_or_default(),
            pull.deletions.unwrap_or_default(),
            pull.comments.unwrap_or_default(),
        ),
        body: pull
            .body
            .clone()
            .filter(|body| !body.is_empty())
            .unwrap_or_else(|| "No description provided.".into()),
        files_ref: pull_files_ref(pull),
        files: details.files.iter().filter_map(file_row).collect(),
        comments: details
            .comments
            .iter()
            .map(|comment| comment_row(comment, None))
            .collect(),
        has_more: details.has_more,
    }
}

pub fn file_rows(files: Vec<models::ChangedFile>) -> Vec<FileRow> {
    files.iter().filter_map(file_row).collect()
}

pub fn commit_file_rows(files: Vec<models::CommitAffectedFiles>) -> Vec<FileRow> {
    files
        .into_iter()
        .filter_map(|file| {
            Some(FileRow {
                path: file.filename?,
                status: file.status.unwrap_or_else(|| "modified".into()),
            })
        })
        .collect()
}

pub fn repository_content_rows(
    contents: Vec<models::ContentsResponse>,
) -> Vec<RepositoryContentRow> {
    let mut rows: Vec<_> = contents
        .into_iter()
        .filter_map(|content| {
            Some(RepositoryContentRow {
                name: content.name?,
                path: content.path?,
                kind: content.r#type.unwrap_or_else(|| "file".into()),
                size: content.size.unwrap_or_default(),
            })
        })
        .collect();
    rows.sort_by_key(|row| (row.kind != "dir", row.name.to_lowercase()));
    rows
}

pub fn repository_file_page(path: &str, data: Vec<u8>) -> RepositoryFilePage {
    let name = path.rsplit('/').next().unwrap_or("Preview").to_string();
    let extension = path.rsplit('.').next().unwrap_or_default().to_lowercase();
    let language = source_language(path).unwrap_or_default().to_string();
    let markdown = matches!(extension.as_str(), "md" | "markdown" | "mdown" | "mkd");
    let media = matches!(
        extension.as_str(),
        "apng"
            | "avi"
            | "bmp"
            | "flac"
            | "gif"
            | "heic"
            | "heif"
            | "jpeg"
            | "jpg"
            | "m4a"
            | "m4v"
            | "mkv"
            | "mov"
            | "mp3"
            | "mp4"
            | "mpeg"
            | "mpg"
            | "ogg"
            | "pdf"
            | "png"
            | "tif"
            | "tiff"
            | "wav"
            | "webm"
            | "webp"
    );
    if media {
        return RepositoryFilePage {
            name,
            kind: "preview".into(),
            language,
            text: String::new(),
            data,
        };
    }
    match String::from_utf8(data) {
        Ok(text) => RepositoryFilePage {
            name,
            kind: if markdown { "markdown" } else { "source" }.into(),
            language,
            text,
            data: Vec::new(),
        },
        Err(error) => RepositoryFilePage {
            name,
            kind: "preview".into(),
            language,
            text: String::new(),
            data: error.into_bytes(),
        },
    }
}

pub fn diff_page(path: &str, diff: diff::Parsed) -> DiffPage {
    DiffPage {
        title: path.rsplit('/').next().unwrap_or(path).into(),
        columns: diff.columns.min(u32::MAX as usize) as u32,
        lines: diff
            .lines
            .into_iter()
            .map(|line| DiffLine {
                old_number: line.old_number,
                new_number: line.new_number,
                text: line.text,
                kind: line.kind.into(),
            })
            .collect(),
    }
}

pub fn home_page(server_name: String, home: HomeData) -> HomePage {
    let (heat_cells, contribution_count) = heat_cells(&home.heatmap);
    HomePage {
        server_name,
        activities: home.activities.iter().map(activity_row).collect(),
        heat_cells,
        contribution_count,
        next_page: home.next_page.map(|page| page as u32),
    }
}

fn activity_row(activity: &models::Activity) -> ActivityRow {
    use models::activity::OpType;

    let repository = activity
        .repo
        .as_ref()
        .and_then(|repo| repo.full_name.as_deref())
        .unwrap_or("repository");
    let branch = activity
        .ref_name
        .as_deref()
        .and_then(|name| name.strip_prefix("refs/heads/").or(Some(name)))
        .unwrap_or("default branch");
    let (icon, title) = match activity.op_type {
        Some(OpType::CommitRepo) if activity.content.as_deref().unwrap_or("").is_empty() => (
            "branch-create",
            format!("Created branch {branch} in {repository}"),
        ),
        Some(OpType::CommitRepo | OpType::MirrorSyncPush) => {
            ("push", format!("Pushed to {branch} in {repository}"))
        }
        Some(OpType::CreateRepo) => ("repo-create", format!("Created {repository}")),
        Some(OpType::RenameRepo) => ("repo-rename", format!("Renamed {repository}")),
        Some(OpType::StarRepo) => ("repo-star", format!("Starred {repository}")),
        Some(OpType::WatchRepo) => ("repo-watch", format!("Started watching {repository}")),
        Some(OpType::CreateIssue) => ("issue-open", format!("Opened an issue in {repository}")),
        Some(OpType::CloseIssue) => ("issue-close", format!("Closed an issue in {repository}")),
        Some(OpType::ReopenIssue) => ("issue-reopen", format!("Reopened an issue in {repository}")),
        Some(OpType::CommentIssue) => (
            "issue-comment",
            format!("Commented on an issue in {repository}"),
        ),
        Some(OpType::CreatePullRequest) => (
            "pull-open",
            format!("Opened a pull request in {repository}"),
        ),
        Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => (
            "pull-merge",
            format!("Merged a pull request in {repository}"),
        ),
        Some(OpType::ClosePullRequest) => (
            "pull-close",
            format!("Closed a pull request in {repository}"),
        ),
        Some(OpType::ReopenPullRequest) => (
            "pull-reopen",
            format!("Reopened a pull request in {repository}"),
        ),
        Some(OpType::CommentPull) => (
            "pull-comment",
            format!("Commented on a pull request in {repository}"),
        ),
        Some(OpType::ApprovePullRequest) => (
            "pull-approve",
            format!("Approved a pull request in {repository}"),
        ),
        Some(OpType::RejectPullRequest) => {
            ("pull-reject", format!("Requested changes in {repository}"))
        }
        Some(OpType::PushTag) => ("tag-push", format!("Pushed tag {branch} in {repository}")),
        Some(OpType::DeleteTag) => (
            "tag-delete",
            format!("Deleted tag {branch} in {repository}"),
        ),
        Some(OpType::DeleteBranch) => (
            "branch-delete",
            format!("Deleted branch {branch} in {repository}"),
        ),
        Some(OpType::PublishRelease) => ("release", format!("Published a release in {repository}")),
        Some(_) | None => ("update", format!("Updated {repository}")),
    };
    let target = activity::target(activity);
    let (target, owner, repository, number, sha) = match target {
        Some(activity::Target::Repository { owner, repository }) => {
            ("repository", owner, repository, 0, String::new())
        }
        Some(activity::Target::Issue {
            owner,
            repository,
            number,
        }) => ("issue", owner, repository, number, String::new()),
        Some(activity::Target::Pull {
            owner,
            repository,
            number,
        }) => ("pull", owner, repository, number, String::new()),
        Some(activity::Target::Commit {
            owner,
            repository,
            sha,
        }) => ("commit", owner, repository, 0, sha),
        None => ("", String::new(), String::new(), 0, String::new()),
    };
    ActivityRow {
        icon: icon.into(),
        title,
        detail: activity_detail(activity),
        meta: compact_date(activity.created.as_deref()),
        target: target.into(),
        owner,
        repository,
        number,
        sha,
    }
}

fn activity_detail(activity: &models::Activity) -> String {
    let text = activity
        .comment
        .as_ref()
        .and_then(|comment| comment.body.as_deref())
        .or(activity.content.as_deref())
        .unwrap_or("");
    if let Ok(payload) = serde_json::from_str::<serde_json::Value>(text) {
        let commits = payload.get("Len").and_then(|value| value.as_i64());
        let message = payload
            .get("HeadCommit")
            .and_then(|commit| commit.get("Message"))
            .and_then(|message| message.as_str())
            .map(summary)
            .unwrap_or_default();
        if !message.is_empty() {
            return match commits {
                Some(1) => format!("1 commit · {message}"),
                Some(count) => format!("{count} commits · {message}"),
                None => message,
            };
        }
    }
    let text = summary(text);
    if text.is_empty() {
        "Server activity".into()
    } else {
        text
    }
}

fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
    let latest = data
        .iter()
        .filter_map(|entry| entry.timestamp)
        .max()
        .unwrap_or(0)
        / 86_400;
    let (year, month, _) = civil_from_days(latest);
    let first_month = year * 12 + month - 1 - 8;
    let start = days_from_civil(
        first_month.div_euclid(12),
        first_month.rem_euclid(12) + 1,
        1,
    );
    let mut counts = vec![0_i64; (latest - start + 1) as usize];
    for entry in data {
        let day = entry.timestamp.unwrap_or_default() / 86_400;
        if (start..=latest).contains(&day) {
            counts[(day - start) as usize] += entry.contributions.unwrap_or_default();
        }
    }
    let maximum = counts.iter().copied().max().unwrap_or_default();
    let contribution_count = counts.iter().sum();
    let cells = counts
        .into_iter()
        .enumerate()
        .map(|(index, count)| HeatCell {
            level: if count == 0 || maximum == 0 {
                0
            } else {
                ((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32
            },
            timestamp: (start + index as i64) * 86_400,
        })
        .collect();
    (cells, contribution_count)
}

fn label_row(label: &models::Label) -> Option<LabelRow> {
    let name = label.name.as_deref()?.trim();
    let color = label.color.as_deref()?.trim().trim_start_matches('#');
    if name.is_empty() || color.len() != 6 {
        return None;
    }
    let value = u32::from_str_radix(color, 16).ok()?;
    let red = (value >> 16) & 0xff;
    let green = (value >> 8) & 0xff;
    let blue = value & 0xff;
    Some(LabelRow {
        name: name.into(),
        color: format!("#{color}"),
        light: red * 299 + green * 587 + blue * 114 > 150_000,
    })
}

fn comment_row(comment: &models::Comment, viewer_id: Option<i64>) -> CommentRow {
    CommentRow {
        id: comment.id.unwrap_or_default(),
        author: comment
            .user
            .as_ref()
            .and_then(|user| user.login.as_deref())
            .or(comment.original_author.as_deref())
            .unwrap_or("unknown")
            .into(),
        body: comment
            .body
            .clone()
            .filter(|body| !body.is_empty())
            .unwrap_or_else(|| "No comment text.".into()),
        meta: compact_date(
            comment
                .updated_at
                .as_deref()
                .or(comment.created_at.as_deref()),
        ),
        can_edit: comment_can_edit(comment, viewer_id),
    }
}

fn file_row(file: &models::ChangedFile) -> Option<FileRow> {
    Some(FileRow {
        path: file.filename.clone()?,
        status: file.status.clone().unwrap_or_else(|| "modified".into()),
    })
}

fn pull_files_ref(pull: &models::PullRequest) -> String {
    match pull_file_source(pull) {
        PullFileSource::Branch(branch) => format!("Files on {branch}"),
        PullFileSource::Commit(sha) => format!("Files at merge commit {sha}"),
        PullFileSource::Request => "Files from pull request".into(),
    }
}

fn issue_meta(issue: &models::Issue) -> String {
    let author = issue
        .user
        .as_ref()
        .and_then(|user| user.login.as_deref())
        .unwrap_or("unknown");
    format!(
        "#{}  ·  {}  ·  updated {}  ·  {} comments",
        issue.number.unwrap_or_default(),
        author,
        compact_date(issue.updated_at.as_deref()),
        issue.comments.unwrap_or_default()
    )
}

fn issue_milestone(issue: &models::Issue) -> String {
    issue
        .milestone
        .as_ref()
        .and_then(|milestone| milestone.title.as_deref())
        .unwrap_or_default()
        .into()
}

fn milestone_row(milestone: &models::Milestone) -> MilestoneRow {
    let open = milestone.open_issues.unwrap_or_default();
    let closed = milestone.closed_issues.unwrap_or_default();
    let total = open + closed;
    let due = milestone
        .due_on
        .as_deref()
        .map(|date| format!("due {}", compact_date(Some(date))))
        .unwrap_or_else(|| "no due date".into());
    MilestoneRow {
        id: milestone.id.unwrap_or_default(),
        title: milestone
            .title
            .clone()
            .unwrap_or_else(|| "Untitled milestone".into()),
        description: milestone
            .description
            .clone()
            .filter(|description| !description.is_empty())
            .unwrap_or_else(|| "No description".into()),
        meta: format!(
            "{}  ·  {closed} of {} closed  ·  {due}",
            milestone.state.as_deref().unwrap_or("unknown"),
            open + closed
        ),
        has_issues: total > 0,
        progress: if total == 0 {
            0.0
        } else {
            closed as f64 / total as f64
        },
        progress_accessibility: format!("{closed} closed, {open} open"),
    }
}

fn short_sha(sha: &str) -> &str {
    &sha[..sha.len().min(8)]
}

fn source_language(path: &str) -> Option<&'static str> {
    let filename = path.rsplit('/').next()?.to_lowercase();
    match filename.as_str() {
        "cmakelists.txt" => return Some("cmake"),
        "dockerfile" => return Some("dockerfile"),
        "gemfile" | "podfile" => return Some("ruby"),
        "makefile" => return Some("makefile"),
        _ => {}
    }
    match filename.rsplit('.').next()? {
        "asm" => Some("x86asm"),
        "c" => Some("c"),
        "cc" | "cpp" | "h" | "hpp" => Some("cpp"),
        "clj" => Some("clojure"),
        "cs" => Some("csharp"),
        "css" => Some("css"),
        "dart" => Some("dart"),
        "ex" | "exs" => Some("elixir"),
        "fs" => Some("fsharp"),
        "go" => Some("go"),
        "groovy" => Some("groovy"),
        "hs" => Some("haskell"),
        "html" | "plist" | "xml" => Some("xml"),
        "java" => Some("java"),
        "jl" => Some("julia"),
        "js" | "jsx" => Some("javascript"),
        "json" => Some("json"),
        "kt" | "kts" => Some("kotlin"),
        "lua" => Some("lua"),
        "m" | "mm" => Some("objectivec"),
        "md" | "markdown" | "mdown" | "mkd" => Some("markdown"),
        "php" => Some("php"),
        "pl" => Some("perl"),
        "ps1" => Some("powershell"),
        "py" => Some("python"),
        "r" => Some("r"),
        "rb" => Some("ruby"),
        "rs" => Some("rust"),
        "scala" => Some("scala"),
        "scss" => Some("scss"),
        "sh" => Some("bash"),
        "sql" => Some("sql"),
        "swift" => Some("swift"),
        "toml" => Some("ini"),
        "ts" | "tsx" => Some("typescript"),
        "txt" => Some("plaintext"),
        "yaml" | "yml" => Some("yaml"),
        _ => None,
    }
}

fn summary(body: &str) -> String {
    body.split_whitespace()
        .take(24)
        .collect::<Vec<_>>()
        .join(" ")
}

fn indices(values: &[usize]) -> Vec<u32> {
    values.iter().map(|value| *value as u32).collect()
}

pub fn compact_date(date: Option<&str>) -> String {
    date.and_then(|date| date.get(..10))
        .unwrap_or("unknown")
        .to_string()
}

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

    #[test]
    fn repository_directories_sort_before_files() {
        let contents = [
            ("z.swift", "file"),
            ("Sources", "dir"),
            ("README.md", "file"),
        ]
        .into_iter()
        .map(|(name, kind)| models::ContentsResponse {
            name: Some(name.into()),
            path: Some(name.into()),
            r#type: Some(kind.into()),
            ..Default::default()
        })
        .collect();

        assert_eq!(
            repository_content_rows(contents)
                .into_iter()
                .map(|row| row.name)
                .collect::<Vec<_>>(),
            ["Sources", "README.md", "z.swift"]
        );
    }

    #[test]
    fn maps_heat_levels_and_labels() {
        let heatmap = vec![
            models::UserHeatmapData {
                timestamp: Some(20_393 * 86_400),
                contributions: Some(1),
            },
            models::UserHeatmapData {
                timestamp: Some(20_665 * 86_400),
                contributions: Some(4),
            },
        ];
        let (cells, total) = heat_cells(&heatmap);
        assert_eq!((cells.len(), total), (273, 5));
        assert_eq!(
            (cells[0].level, cells[0].timestamp, cells[272].level,),
            (1, 20_393 * 86_400, 4)
        );

        let label = models::Label {
            name: Some("bug".into()),
            color: Some("d73a4a".into()),
            ..Default::default()
        };
        assert!(label_row(&label).is_some());
        assert!(label_row(&models::Label::default()).is_none());

        let milestone = models::Milestone {
            id: Some(1),
            title: Some("Version 1".into()),
            open_issues: Some(3),
            closed_issues: Some(2),
            state: Some("open".into()),
            ..Default::default()
        };
        let milestone_row = milestone_rows(std::slice::from_ref(&milestone)).remove(0);
        assert_eq!(milestone_row.progress, 0.4);
        assert_eq!(milestone_row.progress_accessibility, "2 closed, 3 open");
        let issue = models::Issue {
            state: Some("closed".into()),
            milestone: Some(Box::new(milestone)),
            ..Default::default()
        };
        let row = &issue_rows(&[issue])[0];
        assert_eq!(row.state, "closed");
        assert_eq!(row.milestone, "Version 1");
    }

    #[test]
    fn presents_filtered_home_activity_and_continuation() {
        use models::activity::OpType;

        let page = home_page(
            "Gitea".into(),
            HomeData {
                activities: [OpType::CreatePullRequest, OpType::ClosePullRequest]
                    .into_iter()
                    .map(|op_type| models::Activity {
                        op_type: Some(op_type),
                        ..Default::default()
                    })
                    .collect(),
                heatmap: Vec::new(),
                next_page: Some(4),
            },
        );

        assert_eq!(page.activities.len(), 2);
        assert_eq!(page.next_page, Some(4));
    }

    #[test]
    fn issue_page_exposes_state_and_owned_comment_editing() {
        let page = issue_page(IssueDetails {
            issue: models::Issue {
                state: Some("closed".into()),
                ..Default::default()
            },
            comments: vec![
                models::Comment {
                    id: Some(7),
                    user: Some(Box::new(models::User {
                        id: Some(3),
                        login: Some("viewer".into()),
                        ..Default::default()
                    })),
                    body: Some("Comment".into()),
                    ..Default::default()
                },
                models::Comment {
                    id: Some(8),
                    user: Some(Box::new(models::User {
                        id: Some(4),
                        login: Some("someone-else".into()),
                        ..Default::default()
                    })),
                    ..Default::default()
                },
            ],
            viewer_id: Some(3),
            has_more: false,
        });

        assert_eq!(page.state, "closed");
        assert_eq!(page.comments[0].id, 7);
        assert!(page.comments[0].can_edit);
        assert!(!page.comments[1].can_edit);
    }

    #[test]
    fn issue_editor_exposes_selected_metadata_and_due_date() {
        let selected_label = models::Label {
            id: Some(2),
            name: Some("bug".into()),
            ..Default::default()
        };
        let page = issue_editor_page(IssueEditorData {
            issue: Some(models::Issue {
                title: Some("Title".into()),
                body: Some("Body".into()),
                due_date: Some("2024-02-29T18:00:00Z".into()),
                labels: Some(vec![selected_label.clone()]),
                milestone: Some(Box::new(models::Milestone {
                    id: Some(4),
                    title: Some("Current".into()),
                    state: Some("closed".into()),
                    ..Default::default()
                })),
                ..Default::default()
            }),
            labels: vec![selected_label],
            milestones: vec![models::Milestone {
                id: Some(4),
                title: Some("Current".into()),
                state: Some("closed".into()),
                ..Default::default()
            }],
        });

        assert_eq!((page.title.as_str(), page.body.as_str()), ("Title", "Body"));
        assert_eq!(page.due_date, Some(1_709_164_800));
        assert!(page.labels[0].selected);
        assert!(page.milestones[0].selected);
    }

    #[test]
    fn builds_sorted_issue_filter_options() {
        let filter = IssueFilter {
            milestone: "v3".into(),
            labels: ["bug".into(), "retired".into()].into(),
        };
        let options = issue_filter_options(
            &[
                models::Label {
                    name: Some("critical".into()),
                    ..Default::default()
                },
                models::Label {
                    name: Some("bug".into()),
                    ..Default::default()
                },
            ],
            &[
                models::Milestone {
                    title: Some("v2".into()),
                    ..Default::default()
                },
                models::Milestone {
                    title: Some("v1".into()),
                    ..Default::default()
                },
            ],
            &filter,
        );

        assert_eq!(options.labels, ["bug", "critical", "retired"]);
        assert_eq!(options.unavailable_labels, ["retired"]);
        assert_eq!(options.milestones, ["v1", "v2", "v3"]);
        assert_eq!(options.selected_labels, ["bug", "retired"]);
        assert_eq!(options.selected_milestone, "v3");

        let pull_options = pull_filter_options(
            &["v2".into(), "v1".into()],
            &PullFilter {
                milestone: "v3".into(),
            },
        );
        assert_eq!(pull_options.milestones, ["v1", "v2", "v3"]);
        assert_eq!(pull_options.selected_milestone, "v3");
    }

    #[test]
    fn milestone_page_includes_linked_pull_requests() {
        let page = milestone_page(MilestoneDetails {
            milestone: models::Milestone::default(),
            issues: Vec::new(),
            pulls: vec![models::Issue {
                number: Some(7),
                title: Some("Linked pull".into()),
                repository: Some(Box::new(models::RepositoryMeta {
                    name: Some("demo".into()),
                    owner: Some("octo".into()),
                    ..Default::default()
                })),
                ..Default::default()
            }],
            has_more: false,
        });

        assert!(page.issues.is_empty());
        assert_eq!(page.pulls.len(), 1);
        assert_eq!(page.pulls[0].number, 7);
    }

    #[test]
    fn milestone_editor_preserves_description_and_optional_due_date() {
        let page = milestone_editor_page(Some(models::Milestone {
            title: Some("Version 1".into()),
            description: Some("Ship it".into()),
            due_on: Some("2024-02-29T12:34:56Z".into()),
            ..Default::default()
        }));
        assert_eq!(page.title, "Version 1");
        assert_eq!(page.description, "Ship it");
        assert_eq!(page.due_date, Some(1_709_164_800));

        assert_eq!(milestone_editor_page(None).due_date, None);
    }

    #[test]
    fn classifies_repository_files_in_rust() {
        let markdown = repository_file_page("docs/README.md", b"# Hello".to_vec());
        assert_eq!(
            (
                markdown.name.as_str(),
                markdown.kind.as_str(),
                markdown.language.as_str()
            ),
            ("README.md", "markdown", "markdown")
        );
        assert_eq!(markdown.text, "# Hello");

        let source = repository_file_page("Sources/App.swift", b"import UIKit".to_vec());
        assert_eq!(
            (source.kind.as_str(), source.language.as_str()),
            ("source", "swift")
        );

        let preview = repository_file_page("image.png", vec![0, 159]);
        assert_eq!(preview.kind, "preview");
        assert_eq!(preview.data, [0, 159]);

        for path in ["manual.pdf", "sound.mp3", "movie.mp4"] {
            assert_eq!(repository_file_page(path, Vec::new()).kind, "preview");
        }
    }
}
