use std::{error::Error, io::Read};

use gotcha_gitea::{
    Client, CreateIssue, EditIssue, IssueQuery,
    models::{
        ChangedFile, Comment, Commit, CreateIssueOption, CreateMilestoneOption,
        CreatePullRequestOption, EditIssueOption, EditMilestoneOption, EditPullRequestOption,
        Issue, MergePullRequestOption, Milestone, PullRequest, PullReview,
    },
    pull_state,
};
use serde::{Deserialize, de::DeserializeOwned};

use crate::{
    config::{RepositoryScope, Selection},
    print_table,
};

const ISSUE_HELP: &str = "\
Usage: gotcha issue SUBCOMMAND

Subcommands:
  list [OWNER/REPOSITORY] [OPTIONS]
                                  List and filter issues
  show INDEX [OWNER/REPOSITORY]    Show an issue
  create [OWNER/REPOSITORY]        Create from CreateIssueOption YAML on stdin
  edit INDEX... [OWNER/REPOSITORY] Edit issues from EditIssueOption YAML on stdin
  close INDEX... [OWNER/REPOSITORY]
                                  Close one or more issues
  reopen INDEX... [OWNER/REPOSITORY]
                                  Reopen one or more issues
  delete INDEX [OWNER/REPOSITORY]  Delete an issue
  comments INDEX [OWNER/REPOSITORY]
                                  List comments
  comment INDEX [OWNER/REPOSITORY] Add a comment read as text from stdin";

const MILESTONE_HELP: &str = "\
Usage: gotcha milestone SUBCOMMAND

Subcommands:
  list [OWNER/REPOSITORY]       List milestones
  show ID [OWNER/REPOSITORY]    Show a milestone
  create [OWNER/REPOSITORY]     Create from CreateMilestoneOption YAML on stdin
  edit ID [OWNER/REPOSITORY]    Edit from EditMilestoneOption YAML on stdin
  delete ID [OWNER/REPOSITORY]  Delete a milestone";

const PULL_HELP: &str = "\
Usage: gotcha pull SUBCOMMAND

Subcommands:
  list [OWNER/REPOSITORY]          List pull requests
  show INDEX [OWNER/REPOSITORY]    Show a pull request
  create [OWNER/REPOSITORY]        Create from CreatePullRequestOption YAML on stdin
  edit INDEX [OWNER/REPOSITORY]    Edit from EditPullRequestOption YAML on stdin
  merge INDEX [OWNER/REPOSITORY]   Merge from MergePullRequestOption YAML on stdin
  commits INDEX [OWNER/REPOSITORY] List commits
  files INDEX [OWNER/REPOSITORY]   List changed files
  reviews INDEX [OWNER/REPOSITORY] List reviews";

pub fn domain_help(domain: &str) -> Option<&'static str> {
    match domain {
        "issue" => Some(ISSUE_HELP),
        "milestone" => Some(MILESTONE_HELP),
        "pull" => Some(PULL_HELP),
        _ => None,
    }
}

pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> {
    match (domain, command) {
        ("issue", "list") => Some(
            "Usage: gotcha issue list [OWNER/REPOSITORY] [OPTIONS]\n\nOptions:\n  --state open|closed|all\n  -K, --kind issues|pulls|all\n  -k, --keyword TEXT\n  -L, --labels NAMES\n  -m, --milestones NAMES\n  -A, --author USER\n  -a, --assignee USER\n  -M, --mentions USER\n  -F, --from TIMESTAMP\n  -u, --until TIMESTAMP\n  -p, --page NUMBER\n      --limit NUMBER",
        ),
        ("issue", "show") => Some("Usage: gotcha issue show INDEX [OWNER/REPOSITORY]"),
        ("issue", "create") => Some(
            "Usage: gotcha issue create [OWNER/REPOSITORY] < issue.yaml\n\nReads CreateIssueOption YAML from stdin. label_names and milestone_name accept display names, for example:\n  title: Fix the bug\n  body: Reproduction steps\n  label_names: [bug, critical]\n  milestone_name: Version 1.0",
        ),
        ("issue", "edit") => Some(
            "Usage: gotcha issue edit INDEX... [OWNER/REPOSITORY] < issue.yaml\n\nReads EditIssueOption YAML from stdin and applies it to every index. milestone_name resolves a display name; add_labels and remove_labels accept label names; add_assignees adds users without replacing existing assignees.",
        ),
        ("issue", "close") => Some("Usage: gotcha issue close INDEX... [OWNER/REPOSITORY]"),
        ("issue", "reopen") => Some("Usage: gotcha issue reopen INDEX... [OWNER/REPOSITORY]"),
        ("issue", "delete") => Some("Usage: gotcha issue delete INDEX [OWNER/REPOSITORY]"),
        ("issue", "comments") => Some("Usage: gotcha issue comments INDEX [OWNER/REPOSITORY]"),
        ("issue", "comment") => Some(
            "Usage: gotcha issue comment INDEX [OWNER/REPOSITORY] < comment.txt\n\nReads the comment body as text from stdin.",
        ),
        ("milestone", "list") => Some("Usage: gotcha milestone list [OWNER/REPOSITORY]"),
        ("milestone", "show") => Some("Usage: gotcha milestone show ID [OWNER/REPOSITORY]"),
        ("milestone", "create") => Some(
            "Usage: gotcha milestone create [OWNER/REPOSITORY] < milestone.yaml\n\nReads CreateMilestoneOption YAML from stdin, for example:\n  title: Version 1.0\n  due_on: 2026-09-01T00:00:00Z",
        ),
        ("milestone", "edit") => Some(
            "Usage: gotcha milestone edit ID [OWNER/REPOSITORY] < milestone.yaml\n\nReads EditMilestoneOption YAML from stdin.",
        ),
        ("milestone", "delete") => Some("Usage: gotcha milestone delete ID [OWNER/REPOSITORY]"),
        ("pull", "list") => Some("Usage: gotcha pull list [OWNER/REPOSITORY]"),
        ("pull", "show") => Some("Usage: gotcha pull show INDEX [OWNER/REPOSITORY]"),
        ("pull", "create") => Some(
            "Usage: gotcha pull create [OWNER/REPOSITORY] < pull.yaml\n\nReads CreatePullRequestOption YAML from stdin, for example:\n  title: Add feature\n  head: feature\n  base: main",
        ),
        ("pull", "edit") => Some(
            "Usage: gotcha pull edit INDEX [OWNER/REPOSITORY] < pull.yaml\n\nReads EditPullRequestOption YAML from stdin.",
        ),
        ("pull", "merge") => Some(
            "Usage: gotcha pull merge INDEX [OWNER/REPOSITORY] < merge.yaml\n\nReads MergePullRequestOption YAML from stdin, for example:\n  Do: squash\n  delete_branch_after_merge: true",
        ),
        ("pull", "commits") => Some("Usage: gotcha pull commits INDEX [OWNER/REPOSITORY]"),
        ("pull", "files") => Some("Usage: gotcha pull files INDEX [OWNER/REPOSITORY]"),
        ("pull", "reviews") => Some("Usage: gotcha pull reviews INDEX [OWNER/REPOSITORY]"),
        _ => None,
    }
}

#[derive(Debug, PartialEq)]
struct IssueListOptions {
    repository: Option<String>,
    state: String,
    kind: String,
    keyword: Option<String>,
    labels: Option<String>,
    milestones: Option<String>,
    author: Option<String>,
    assignee: Option<String>,
    mentions: Option<String>,
    from: Option<String>,
    until: Option<String>,
    page: i32,
    limit: i32,
}

#[derive(Deserialize)]
struct CreateIssueInput {
    #[serde(flatten)]
    issue: CreateIssueOption,
    #[serde(default)]
    label_names: Vec<String>,
    milestone_name: Option<String>,
}

#[derive(Clone, Deserialize)]
struct EditIssueInput {
    #[serde(flatten)]
    issue: EditIssueOption,
    #[serde(default)]
    add_labels: Vec<String>,
    #[serde(default)]
    remove_labels: Vec<String>,
    #[serde(default)]
    add_assignees: Vec<String>,
    milestone_name: Option<String>,
}

impl Default for IssueListOptions {
    fn default() -> Self {
        Self {
            repository: None,
            state: "open".into(),
            kind: "issues".into(),
            keyword: None,
            labels: None,
            milestones: None,
            author: None,
            assignee: None,
            mentions: None,
            from: None,
            until: None,
            page: 1,
            limit: 30,
        }
    }
}

fn parse_issue_list(arguments: &[String]) -> Result<IssueListOptions, Box<dyn Error>> {
    let mut options = IssueListOptions::default();
    let mut index = 0;
    while index < arguments.len() {
        let argument = arguments[index].as_str();
        let value = |index: &mut usize| -> Result<String, Box<dyn Error>> {
            *index += 1;
            arguments
                .get(*index)
                .cloned()
                .ok_or_else(|| format!("{argument} requires a value").into())
        };
        match argument {
            "--state" => options.state = value(&mut index)?,
            "-K" | "--kind" => options.kind = value(&mut index)?,
            "-k" | "--keyword" => options.keyword = Some(value(&mut index)?),
            "-L" | "--labels" => options.labels = Some(value(&mut index)?),
            "-m" | "--milestones" => options.milestones = Some(value(&mut index)?),
            "-A" | "--author" => options.author = Some(value(&mut index)?),
            "-a" | "--assignee" => options.assignee = Some(value(&mut index)?),
            "-M" | "--mentions" => options.mentions = Some(value(&mut index)?),
            "-F" | "--from" => options.from = Some(value(&mut index)?),
            "-u" | "--until" => options.until = Some(value(&mut index)?),
            "-p" | "--page" => options.page = positive_i32(&value(&mut index)?, "page")?,
            "--limit" | "--lm" => options.limit = positive_i32(&value(&mut index)?, "limit")?,
            unknown if unknown.starts_with('-') => {
                return Err(format!("unknown issue list option {unknown:?}").into());
            }
            repository if options.repository.is_none() => {
                RepositoryScope::parse(repository)?;
                options.repository = Some(repository.into());
            }
            value => return Err(format!("unexpected issue list argument {value:?}").into()),
        }
        index += 1;
    }
    if !matches!(options.state.as_str(), "open" | "closed" | "all") {
        return Err("--state must be open, closed, or all".into());
    }
    if !matches!(options.kind.as_str(), "issues" | "pulls" | "all") {
        return Err("--kind must be issues, pulls, or all".into());
    }
    Ok(options)
}

fn positive_i32(value: &str, name: &str) -> Result<i32, Box<dyn Error>> {
    let value: i32 = value.parse()?;
    if value < 1 {
        return Err(format!("{name} must be a positive integer").into());
    }
    Ok(value)
}

fn issue_targets(arguments: &[String]) -> Result<(Vec<i64>, Option<String>), Box<dyn Error>> {
    let mut indexes = Vec::new();
    let mut repository = None;
    for argument in arguments {
        if argument.contains('/') {
            if repository.is_some() {
                return Err("only one OWNER/REPOSITORY may be provided".into());
            }
            RepositoryScope::parse(argument)?;
            repository = Some(argument.clone());
        } else {
            indexes.push(number(argument)?);
        }
    }
    if indexes.is_empty() {
        return Err("at least one issue index is required".into());
    }
    Ok((indexes, repository))
}

pub async fn run(
    command: &[String],
    selection: &Selection,
    client: &Client,
) -> Result<bool, Box<dyn Error>> {
    match command {
        [domain, action, arguments @ ..] if domain == "issue" && action == "list" => {
            let options = parse_issue_list(arguments)?;
            let scope = scope(selection, options.repository.as_deref())?;
            let issues = client
                .issues(
                    &scope,
                    &IssueQuery {
                        state: options.state,
                        labels: options.labels,
                        keyword: options.keyword,
                        kind: options.kind,
                        milestones: options.milestones,
                        from: options.from,
                        until: options.until,
                        author: options.author,
                        assignee: options.assignee,
                        mentions: options.mentions,
                        page: options.page,
                        limit: options.limit,
                    },
                )
                .await?;
            print_issues(&issues.items);
        }
        [domain, action, index] if domain == "issue" && action == "show" => {
            let scope = scope(selection, None)?;
            print_issue(&client.issue(&scope, number(index)?).await?);
        }
        [domain, action, index, repository] if domain == "issue" && action == "show" => {
            let scope = scope(selection, Some(repository))?;
            print_issue(&client.issue(&scope, number(index)?).await?);
        }
        [domain, action] if domain == "issue" && action == "create" => {
            create_issue(client, &scope(selection, None)?).await?;
        }
        [domain, action, repository] if domain == "issue" && action == "create" => {
            create_issue(client, &scope(selection, Some(repository))?).await?;
        }
        [domain, action, arguments @ ..] if domain == "issue" && action == "edit" => {
            let (indexes, repository) = issue_targets(arguments)?;
            edit_issues(client, &scope(selection, repository.as_deref())?, &indexes).await?;
        }
        [domain, action, arguments @ ..]
            if domain == "issue" && matches!(action.as_str(), "close" | "reopen") =>
        {
            let (indexes, repository) = issue_targets(arguments)?;
            let scope = scope(selection, repository.as_deref())?;
            set_issue_state(
                client,
                &scope,
                &indexes,
                if action == "close" { "closed" } else { "open" },
            )
            .await?;
        }
        [domain, action, index] if domain == "issue" && action == "delete" => {
            delete_issue(client, &scope(selection, None)?, number(index)?).await?;
        }
        [domain, action, index, repository] if domain == "issue" && action == "delete" => {
            delete_issue(client, &scope(selection, Some(repository))?, number(index)?).await?;
        }
        [domain, action, index] if domain == "issue" && action == "comments" => {
            list_comments(client, &scope(selection, None)?, number(index)?).await?;
        }
        [domain, action, index, repository] if domain == "issue" && action == "comments" => {
            list_comments(client, &scope(selection, Some(repository))?, number(index)?).await?;
        }
        [domain, action, index] if domain == "issue" && action == "comment" => {
            create_comment(client, &scope(selection, None)?, number(index)?).await?;
        }
        [domain, action, index, repository] if domain == "issue" && action == "comment" => {
            create_comment(client, &scope(selection, Some(repository))?, number(index)?).await?;
        }
        [domain, action] if domain == "milestone" && action == "list" => {
            list_milestones(client, &scope(selection, None)?).await?;
        }
        [domain, action, repository] if domain == "milestone" && action == "list" => {
            list_milestones(client, &scope(selection, Some(repository))?).await?;
        }
        [domain, action, id] if domain == "milestone" && action == "show" => {
            show_milestone(client, &scope(selection, None)?, id).await?;
        }
        [domain, action, id, repository] if domain == "milestone" && action == "show" => {
            show_milestone(client, &scope(selection, Some(repository))?, id).await?;
        }
        [domain, action] if domain == "milestone" && action == "create" => {
            create_milestone(client, &scope(selection, None)?).await?;
        }
        [domain, action, repository] if domain == "milestone" && action == "create" => {
            create_milestone(client, &scope(selection, Some(repository))?).await?;
        }
        [domain, action, id] if domain == "milestone" && action == "edit" => {
            edit_milestone(client, &scope(selection, None)?, id).await?;
        }
        [domain, action, id, repository] if domain == "milestone" && action == "edit" => {
            edit_milestone(client, &scope(selection, Some(repository))?, id).await?;
        }
        [domain, action, id] if domain == "milestone" && action == "delete" => {
            delete_milestone(client, &scope(selection, None)?, id).await?;
        }
        [domain, action, id, repository] if domain == "milestone" && action == "delete" => {
            delete_milestone(client, &scope(selection, Some(repository))?, id).await?;
        }
        [domain, action] if domain == "pull" && action == "list" => {
            list_pulls(client, &scope(selection, None)?).await?;
        }
        [domain, action, repository] if domain == "pull" && action == "list" => {
            list_pulls(client, &scope(selection, Some(repository))?).await?;
        }
        [domain, action, index] if domain == "pull" && action == "show" => {
            show_pull(client, &scope(selection, None)?, number(index)?).await?;
        }
        [domain, action, index, repository] if domain == "pull" && action == "show" => {
            show_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
        }
        [domain, action] if domain == "pull" && action == "create" => {
            create_pull(client, &scope(selection, None)?).await?;
        }
        [domain, action, repository] if domain == "pull" && action == "create" => {
            create_pull(client, &scope(selection, Some(repository))?).await?;
        }
        [domain, action, index] if domain == "pull" && action == "edit" => {
            edit_pull(client, &scope(selection, None)?, number(index)?).await?;
        }
        [domain, action, index, repository] if domain == "pull" && action == "edit" => {
            edit_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
        }
        [domain, action, index] if domain == "pull" && action == "merge" => {
            merge_pull(client, &scope(selection, None)?, number(index)?).await?;
        }
        [domain, action, index, repository] if domain == "pull" && action == "merge" => {
            merge_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
        }
        [domain, action, index]
            if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
        {
            pull_details(client, &scope(selection, None)?, number(index)?, action).await?;
        }
        [domain, action, index, repository]
            if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
        {
            pull_details(
                client,
                &scope(selection, Some(repository))?,
                number(index)?,
                action,
            )
            .await?;
        }
        [domain, ..] if matches!(domain.as_str(), "issue" | "milestone" | "pull") => {
            return Ok(false);
        }
        _ => return Ok(false),
    }
    Ok(true)
}

fn scope(selection: &Selection, value: Option<&str>) -> Result<RepositoryScope, Box<dyn Error>> {
    match value {
        Some(value) => Ok(RepositoryScope::parse(value)?),
        None => selection.repository.clone().ok_or_else(|| {
            "cannot infer a repository; run inside one or pass OWNER/REPOSITORY".into()
        }),
    }
}

fn number(value: &str) -> Result<i64, Box<dyn Error>> {
    let number: i64 = value.parse()?;
    if number < 1 {
        return Err("index must be a positive integer".into());
    }
    Ok(number)
}

fn read_stdin() -> Result<String, Box<dyn Error>> {
    let mut input = String::new();
    std::io::stdin().read_to_string(&mut input)?;
    if input.trim().is_empty() {
        return Err("stdin must not be empty".into());
    }
    Ok(input)
}

fn read_yaml<T: DeserializeOwned>() -> Result<T, Box<dyn Error>> {
    parse_yaml(&read_stdin()?)
}

fn parse_yaml<T: DeserializeOwned>(input: &str) -> Result<T, Box<dyn Error>> {
    Ok(serde_yaml::from_str(input)?)
}

async fn create_issue(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
    let input: CreateIssueInput = read_yaml()?;
    print_issue(
        &client
            .create_issue(
                scope,
                CreateIssue {
                    option: input.issue,
                    label_names: input.label_names,
                    milestone_name: input.milestone_name,
                },
            )
            .await?,
    );
    Ok(())
}

async fn edit_issues(
    client: &Client,
    scope: &RepositoryScope,
    indexes: &[i64],
) -> Result<(), Box<dyn Error>> {
    let input: EditIssueInput = read_yaml()?;
    for issue in client
        .edit_issues(
            scope,
            indexes,
            EditIssue {
                option: input.issue,
                replace_labels: None,
                add_labels: input.add_labels,
                remove_labels: input.remove_labels,
                add_assignees: input.add_assignees,
                milestone_name: input.milestone_name,
            },
        )
        .await?
    {
        print_issue(&issue);
    }
    Ok(())
}

async fn set_issue_state(
    client: &Client,
    scope: &RepositoryScope,
    indexes: &[i64],
    state: &str,
) -> Result<(), Box<dyn Error>> {
    for issue in client.set_issue_state(scope, indexes, state).await? {
        print_issue(&issue);
    }
    Ok(())
}

async fn delete_issue(
    client: &Client,
    scope: &RepositoryScope,
    index: i64,
) -> Result<(), Box<dyn Error>> {
    client.delete_issue(scope, index).await?;
    println!("Deleted issue #{index}.");
    Ok(())
}

async fn list_comments(
    client: &Client,
    scope: &RepositoryScope,
    index: i64,
) -> Result<(), Box<dyn Error>> {
    let comments = client.issue_comments(scope, index).await?;
    print_comments(&comments);
    Ok(())
}

async fn create_comment(
    client: &Client,
    scope: &RepositoryScope,
    index: i64,
) -> Result<(), Box<dyn Error>> {
    let body = read_stdin()?.trim_end().to_owned();
    let comment = client.create_issue_comment(scope, index, body).await?;
    print_comments(&[comment]);
    Ok(())
}

async fn list_milestones(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
    let milestones = client.milestones(scope).await?;
    print_milestones(&milestones);
    Ok(())
}

async fn show_milestone(
    client: &Client,
    scope: &RepositoryScope,
    id: &str,
) -> Result<(), Box<dyn Error>> {
    let milestone = client.milestone(scope, number(id)?).await?;
    print_milestone(&milestone);
    Ok(())
}

async fn create_milestone(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
    let body: CreateMilestoneOption = read_yaml()?;
    let milestone = client.create_milestone(scope, body).await?;
    print_milestone(&milestone);
    Ok(())
}

async fn edit_milestone(
    client: &Client,
    scope: &RepositoryScope,
    id: &str,
) -> Result<(), Box<dyn Error>> {
    let milestone = client
        .edit_milestone(scope, id, read_yaml::<EditMilestoneOption>()?)
        .await?;
    print_milestone(&milestone);
    Ok(())
}

async fn delete_milestone(
    client: &Client,
    scope: &RepositoryScope,
    id: &str,
) -> Result<(), Box<dyn Error>> {
    client.delete_milestone(scope, id).await?;
    println!("Deleted milestone {id}.");
    Ok(())
}

async fn list_pulls(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
    print_pulls(&client.repository_pulls(scope, "open", 1, 30).await?.items);
    Ok(())
}

async fn show_pull(
    client: &Client,
    scope: &RepositoryScope,
    index: i64,
) -> Result<(), Box<dyn Error>> {
    let pull = client.pull(scope, index).await?;
    print_pull(&pull);
    Ok(())
}

async fn create_pull(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
    let body: CreatePullRequestOption = read_yaml()?;
    let pull = client.create_pull(scope, body).await?;
    print_pull(&pull);
    Ok(())
}

async fn edit_pull(
    client: &Client,
    scope: &RepositoryScope,
    index: i64,
) -> Result<(), Box<dyn Error>> {
    let pull = client
        .edit_pull(scope, index, read_yaml::<EditPullRequestOption>()?)
        .await?;
    print_pull(&pull);
    Ok(())
}

async fn merge_pull(
    client: &Client,
    scope: &RepositoryScope,
    index: i64,
) -> Result<(), Box<dyn Error>> {
    client
        .merge_pull(scope, index, read_yaml::<MergePullRequestOption>()?)
        .await?;
    println!("Merged pull request #{index}.");
    Ok(())
}

async fn pull_details(
    client: &Client,
    scope: &RepositoryScope,
    index: i64,
    action: &str,
) -> Result<(), Box<dyn Error>> {
    match action {
        "commits" => print_commits(&client.pull_commits(scope, index).await?),
        "files" => print_files(&client.pull_files(scope, index).await?),
        "reviews" => print_reviews(&client.pull_reviews(scope, index).await?),
        _ => unreachable!(),
    }
    Ok(())
}

fn print_issues(issues: &[Issue]) {
    print_table(
        &[
            ("INDEX", 8),
            ("STATE", 10),
            ("TITLE", 60),
            ("MILESTONE", 30),
            ("LABELS", 30),
        ],
        issues
            .iter()
            .map(|issue| {
                vec![
                    issue.number.unwrap_or_default().to_string(),
                    issue.state.as_deref().unwrap_or("unknown").into(),
                    one_line(issue.title.as_deref().unwrap_or("")).into(),
                    issue
                        .milestone
                        .as_deref()
                        .and_then(|milestone| milestone.title.as_deref())
                        .unwrap_or("-")
                        .into(),
                    issue_labels(issue),
                ]
            })
            .collect(),
    );
}

fn print_issue(issue: &Issue) {
    println!(
        "#{} [{}] {}",
        issue.number.unwrap_or_default(),
        issue.state.as_deref().unwrap_or("unknown"),
        issue.title.as_deref().unwrap_or("")
    );
    field("url", issue.html_url.as_deref());
    field(
        "author",
        issue.user.as_deref().and_then(|user| user.login.as_deref()),
    );
    let milestone = issue
        .milestone
        .as_deref()
        .and_then(|milestone| milestone.title.as_deref());
    field("milestone", milestone);
    let labels = issue_labels(issue);
    field("labels", (!labels.is_empty()).then_some(labels.as_str()));
    let assignees = issue
        .assignees
        .as_deref()
        .unwrap_or_default()
        .iter()
        .filter_map(|user| user.login.as_deref())
        .collect::<Vec<_>>()
        .join(",");
    field(
        "assignees",
        (!assignees.is_empty()).then_some(assignees.as_str()),
    );
    field("due", issue.due_date.as_deref());
    field("created", issue.created_at.as_deref());
    field("updated", issue.updated_at.as_deref());
    println!("comments: {}", issue.comments.unwrap_or_default());
    body(issue.body.as_deref());
}

fn issue_labels(issue: &Issue) -> String {
    issue
        .labels
        .as_deref()
        .unwrap_or_default()
        .iter()
        .filter_map(|label| label.name.as_deref())
        .collect::<Vec<_>>()
        .join(",")
}

fn print_comments(comments: &[Comment]) {
    for comment in comments {
        println!(
            "{} · {}",
            comment
                .user
                .as_deref()
                .and_then(|user| user.login.as_deref())
                .unwrap_or("unknown"),
            comment.created_at.as_deref().unwrap_or("-")
        );
        body(comment.body.as_deref());
    }
}

fn print_milestones(milestones: &[Milestone]) {
    print_table(
        &[
            ("ID", 8),
            ("STATE", 10),
            ("TITLE", 60),
            ("DUE", 25),
            ("OPEN/CLOSED", 12),
        ],
        milestones
            .iter()
            .map(|milestone| {
                vec![
                    milestone.id.unwrap_or_default().to_string(),
                    milestone.state.as_deref().unwrap_or("unknown").into(),
                    one_line(milestone.title.as_deref().unwrap_or("")).into(),
                    milestone.due_on.as_deref().unwrap_or("-").into(),
                    format!(
                        "{}/{}",
                        milestone.open_issues.unwrap_or_default(),
                        milestone.closed_issues.unwrap_or_default()
                    ),
                ]
            })
            .collect(),
    );
}

fn print_milestone(milestone: &Milestone) {
    println!(
        "{} [{}] {}",
        milestone.id.unwrap_or_default(),
        milestone.state.as_deref().unwrap_or("unknown"),
        milestone.title.as_deref().unwrap_or("")
    );
    field("due", milestone.due_on.as_deref());
    println!(
        "issues: {} open, {} closed",
        milestone.open_issues.unwrap_or_default(),
        milestone.closed_issues.unwrap_or_default()
    );
    body(milestone.description.as_deref());
}

fn print_pulls(pulls: &[PullRequest]) {
    print_table(
        &[("INDEX", 8), ("STATE", 10), ("TITLE", 70), ("UPDATED", 25)],
        pulls
            .iter()
            .map(|pull| {
                vec![
                    pull.number.unwrap_or_default().to_string(),
                    pull.state.as_deref().unwrap_or("unknown").into(),
                    one_line(pull.title.as_deref().unwrap_or("")).into(),
                    pull.updated_at.as_deref().unwrap_or("-").into(),
                ]
            })
            .collect(),
    );
}

fn print_pull(pull: &PullRequest) {
    println!(
        "#{} [{}] {}",
        pull.number.unwrap_or_default(),
        pull_state(pull),
        pull.title.as_deref().unwrap_or("")
    );
    field("url", pull.html_url.as_deref());
    field(
        "author",
        pull.user.as_deref().and_then(|user| user.login.as_deref()),
    );
    field("updated", pull.updated_at.as_deref());
    println!("mergeable: {}", pull.mergeable.unwrap_or(false));
    body(pull.body.as_deref());
}

fn print_commits(commits: &[Commit]) {
    print_table(
        &[("SHA", 40), ("MESSAGE", 80)],
        commits
            .iter()
            .map(|commit| {
                vec![
                    commit.sha.as_deref().unwrap_or("unknown").into(),
                    one_line(
                        commit
                            .commit
                            .as_deref()
                            .and_then(|commit| commit.message.as_deref())
                            .unwrap_or(""),
                    )
                    .into(),
                ]
            })
            .collect(),
    );
}

fn print_files(files: &[ChangedFile]) {
    print_table(
        &[("STATUS", 12), ("CHANGES", 10), ("FILE", 90)],
        files
            .iter()
            .map(|file| {
                vec![
                    file.status.as_deref().unwrap_or("unknown").into(),
                    file.changes.unwrap_or_default().to_string(),
                    file.filename.as_deref().unwrap_or("").into(),
                ]
            })
            .collect(),
    );
}

fn print_reviews(reviews: &[PullReview]) {
    print_table(
        &[
            ("ID", 12),
            ("STATE", 20),
            ("REVIEWER", 25),
            ("SUBMITTED", 25),
        ],
        reviews
            .iter()
            .map(|review| {
                vec![
                    review.id.unwrap_or_default().to_string(),
                    review.state.as_deref().unwrap_or("unknown").into(),
                    review
                        .user
                        .as_deref()
                        .and_then(|user| user.login.as_deref())
                        .unwrap_or("unknown")
                        .into(),
                    review.submitted_at.as_deref().unwrap_or("-").into(),
                ]
            })
            .collect(),
    );
}

fn field(name: &str, value: Option<&str>) {
    if let Some(value) = value.filter(|value| !value.is_empty()) {
        println!("{name}: {value}");
    }
}

fn body(value: Option<&str>) {
    if let Some(value) = value.filter(|value| !value.is_empty()) {
        println!("\n{value}");
    }
}

fn one_line(value: &str) -> &str {
    value.lines().next().unwrap_or_default()
}

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

    #[test]
    fn parses_typed_yaml_and_rejects_bad_indexes() {
        let issue: CreateIssueInput = parse_yaml(
            "title: Fix it\nbody: Details\nlabel_names: [bug]\nmilestone_name: Version 1\n",
        )
        .unwrap();

        assert_eq!(issue.issue.title, "Fix it");
        assert_eq!(issue.issue.body.as_deref(), Some("Details"));
        assert_eq!(issue.label_names, ["bug"]);
        assert_eq!(issue.milestone_name.as_deref(), Some("Version 1"));
        let edit: EditIssueInput = parse_yaml(
            "title: Updated\nadd_labels: [critical]\nremove_labels: [bug]\nadd_assignees: [alice]\n",
        )
        .unwrap();
        assert_eq!(edit.issue.title.as_deref(), Some("Updated"));
        assert_eq!(edit.add_labels, ["critical"]);
        assert_eq!(edit.remove_labels, ["bug"]);
        assert_eq!(edit.add_assignees, ["alice"]);
        assert!(number("0").is_err());
        assert!(number("7").is_ok());
    }

    #[test]
    fn parses_issue_workflow_filters_and_bulk_state_targets() {
        let options = parse_issue_list(
            &[
                "hugo/Gotcha",
                "--state",
                "all",
                "--milestones",
                "first feature complete release",
                "-L",
                "bug,critical",
                "--page",
                "2",
                "--limit",
                "50",
            ]
            .map(str::to_owned),
        )
        .unwrap();
        assert_eq!(options.repository.as_deref(), Some("hugo/Gotcha"));
        assert_eq!(options.state, "all");
        assert_eq!(
            options.milestones.as_deref(),
            Some("first feature complete release")
        );
        assert_eq!(options.labels.as_deref(), Some("bug,critical"));
        assert_eq!((options.page, options.limit), (2, 50));

        let (indexes, repository) =
            issue_targets(&["4", "16", "hugo/Gotcha"].map(str::to_owned)).unwrap();
        assert_eq!(indexes, [4, 16]);
        assert_eq!(repository.as_deref(), Some("hugo/Gotcha"));
        assert!(parse_issue_list(&["--state".into(), "invalid".into()]).is_err());
        assert!(issue_targets(&[]).is_err());
    }
}
