//! Putting a question to whoever is at the machine, during a mount.
//!
//! Two facts about the boot shape the whole module. At boot stdin *is* a
//! terminal - /dev/console - but plymouth owns the screen, so a question
//! written there is never seen; systemd's password agents include one that
//! draws on the splash. And the agents are boot-time units, so on a running
//! system /run/systemd/ask-password exists with nothing listening, and a
//! question posted to it times out having shown the user nothing (measured:
//! all three agents inactive, `--no-tty` returns "Timer expired").
//!
//! Hence [`agent_plausibly_listening`]: there is no direct test, so we want
//! evidence before handing a question over.
//!
//! key.rs keeps its own path - a passphrase needs termios echo-off with the
//! ICRNL/ICANON repair for an unconfigured initramfs console, zeroizing, and
//! keyring caching.

use std::io::{stdin, IsTerminal, Read};
use std::os::fd::{AsFd, BorrowedFd, RawFd};
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use anyhow::Result;
use rustix::event::{poll, PollFd, PollFlags, Timespec};
use bcachefs_kernel::c::bch_sb_handle;
use log::debug;

const ASK_PASSWORD_DIR: &str = "/run/systemd/ask-password";

pub const PROMPT_TIMEOUT: Duration = Duration::from_secs(60);

/// Its label, or its UUID - several filesystems come up at once at boot, and
/// someone facing two questions needs to see whether they are the same disk.
pub fn fs_name(sb: &bch_sb_handle) -> String {
    let label = String::from_utf8_lossy(sb.sb().label());

    if label.is_empty() {
        sb.sb().uuid().hyphenated().to_string()
    } else {
        label.into_owned()
    }
}

/// The only place an answer is written down: both rendered forms and the parse
/// derive from this, so a question cannot offer a letter it will not accept.
pub struct Choice<A> {
    /// Matched case-insensitively, as are the aliases.
    pub key: char,
    pub aliases: &'static [&'static str],
    /// For the bracketed summary; "" shows the bare key.
    pub short: &'static str,
    /// For a terminal, which has room for a sentence.
    pub blurb: &'static str,
    pub answer: A,
}

/// Answers live per question rather than in one shared parser, so that two
/// questions cannot cross-parse each other's vocabulary.
pub struct Question<'a, A> {
    /// One line: the agent protocol's `Message=` is one line, and at boot
    /// behind a splash that line is the whole of what anyone sees. So it says
    /// everything the answer turns on - there is no second place to put it.
    pub prompt: &'a str,
    pub choices: &'a [Choice<A>],
    /// Also what a bare Enter and a timed-out boot prompt mean. Shown
    /// capitalised in the summary.
    pub silence: A,
    /// This question is about losing data, and the prompt says so. Drawn in
    /// red where there is a terminal to draw it on; everywhere else the words
    /// have to carry it alone, which is why they are in `prompt` and not in a
    /// decoration of their own.
    pub alarm: bool,
    pub uuid: &'a str,
    /// `None` waits indefinitely - what `--timeout=0` means to
    /// systemd-ask-password.
    pub timeout: Option<Duration>,
}

impl<A: Copy + PartialEq> Question<'_, A> {
    pub fn lines(&self) -> Vec<String> {
        self.choices.iter()
            .map(|c| format!("  {}  {}", c.key, c.blurb))
            .collect()
    }

    /// The answer silence resolves to is the one shown capitalised.
    pub fn brief(&self) -> String {
        let parts = self.choices.iter().map(|c| {
            if c.answer == self.silence {
                c.key.to_uppercase().to_string()
            } else if c.short.is_empty() {
                c.key.to_string()
            } else {
                format!("{}={}", c.key, c.short)
            }
        });

        format!("[{}]", parts.collect::<Vec<_>>().join(" / "))
    }

    pub fn parse(&self, reply: &str) -> A {
        let reply = reply.trim();

        self.choices.iter()
            .find(|c| {
                reply.eq_ignore_ascii_case(&c.key.to_string())
                    || c.aliases.iter().any(|a| reply.eq_ignore_ascii_case(a))
            })
            .map_or(self.silence, |c| c.answer)
    }
}

/// The rendered question, in both the forms its destinations need.
struct Ask<'a> {
    prompt: &'a str,
    choices: Vec<String>,
    brief: String,
    alarm: bool,
    id: String,
    timeout: Option<Duration>,
}

/// What a [`Watch`] waking the poll turned out to mean.
pub enum Stirred {
    /// Not the thing we were watching for - any block device arriving wakes a
    /// device watch, and most of them are somebody else's.
    Nothing,
    /// The question no longer applies.
    Moot,
    /// Somebody answered it by this route rather than by typing, and the watch
    /// is holding what they said.
    Answered,
}

/// Something that can settle a question by happening.
///
/// Someone asked whether to mount without a disk, who responds by plugging the
/// disk in, has answered - and so has someone who supplies the passphrase over
/// a socket while the terminal prompt is up. The first makes the question moot,
/// the second answers it; [`Stirred`] is the difference.
pub trait Watch {
    fn raw_fd(&self) -> RawFd;
    /// Readiness alone is not an answer, so this decides whether *this* wakeup
    /// settled anything.
    fn stirred(&mut self) -> Stirred;
}

/// What ended a [`wait`].
pub enum Waited {
    /// The descriptor we were polling has something for us.
    Readable,
    /// A watch answered; it is holding the answer.
    Answered,
    /// A watch says the question stopped applying.
    Moot,
    /// The deadline passed with none of the above.
    Timeout,
}

enum Answer {
    Said(String),
    /// Timed out, declined, or nobody was there.
    Silence,
    Moot,
}

/// Copy so one detection serves a later question too - see degraded.rs's Retry.
#[derive(Clone, Copy)]
pub enum Prompt {
    Agent,
    Terminal,
}

/// Plymouth answering a ping is an agent itself; stdin on /dev/null is what a
/// unit started by init gets, which puts us in the boot where the agents live.
/// A pipe or a file is a script redirecting us, with nobody behind it.
fn agent_plausibly_listening(tty: bool) -> bool {
    (tty || stdin_is_dev_null()) && ask_password_installed()
}

/// Why [`Prompt::detect`] came back `None`, for the caller to put in front of
/// its own refusal.
///
/// There is one such reason because there is one such return: reaching it
/// means stdin is not a terminal - the last branch tests that - and that no
/// agent was plausibly listening either. Somebody reading this in a boot log
/// needs the concrete condition, not "nobody was there": the fix is a console
/// or an installed systemd-ask-password, and which one is what they have to
/// work out.
pub const NO_ONE_TO_ASK: &str = "no terminal to ask on, and no systemd password agent";

impl Prompt {
    /// `None` when nobody can be reached; callers take their safe answer
    /// without composing a question, and say [`NO_ONE_TO_ASK`] when they do.
    pub fn detect() -> Option<Prompt> {
        let tty = stdin().is_terminal();

        if tty && !crate::plymouth::active() {
            debug!("asking on the terminal");
            return Some(Prompt::Terminal);
        }

        if agent_plausibly_listening(tty) {
            debug!("asking via systemd's password agents");
            return Some(Prompt::Agent);
        }

        // Under the splash with no agent to draw on it: printing here is poor,
        // but better than refusing without asking.
        if tty {
            debug!("asking on the terminal (plymouth is up but has no agent)");
            return Some(Prompt::Terminal);
        }

        debug!("{NO_ONE_TO_ASK}");
        None
    }

    /// Put the question and interpret the reply.
    ///
    /// `None` means the question stopped applying while it was up - see
    /// [`Watch`]. Anything else resolves to one of the question's own answers,
    /// including silence, so a caller never has to decide what an unrecognised
    /// reply meant.
    pub fn put<A: Copy + PartialEq>(
        &self,
        q: &Question<'_, A>,
        watch: Option<&mut dyn Watch>,
    ) -> Result<Option<A>> {
        let ask = Ask {
            prompt:  q.prompt,
            choices: q.lines(),
            brief:   q.brief(),
            alarm:   q.alarm,
            id:      format!("bcachefs:UUID={}", q.uuid),
            timeout: q.timeout,
        };

        Ok(match match self {
            Prompt::Agent    => ask_via_agent(&ask, watch),
            Prompt::Terminal => ask_on_terminal(&ask, watch),
        }? {
            Answer::Said(reply) => Some(q.parse(&reply)),
            Answer::Silence     => Some(q.silence),
            Answer::Moot        => None,
        })
    }
}

/// Installed, note - not listening. detect() supplies the evidence for that.
fn ask_password_installed() -> bool {
    Path::new(ASK_PASSWORD_DIR).is_dir()
        && std::env::var_os("PATH")
            .map(|path| {
                std::env::split_paths(&path)
                    .any(|dir| dir.join("systemd-ask-password").is_file())
            })
            .unwrap_or(false)
}

/// Stdin being /dev/null is the signature of a process started by init: a
/// terminal means a person, a pipe or a file means a script, /dev/null means
/// neither, so the question has to go wherever init's own prompts go.
pub fn stdin_is_dev_null() -> bool {
    let Ok(stat) = rustix::fs::fstat(stdin().as_fd()) else {
        return false;
    };

    rustix::fs::FileType::from_raw_mode(stat.st_mode) == rustix::fs::FileType::CharacterDevice
        && rustix::fs::major(stat.st_rdev) == 1
        && rustix::fs::minor(stat.st_rdev) == 3
}

/// Block until @fd is readable, the deadline passes, or @watch settles the
/// question.
///
/// A watch firing is not by itself an answer: any block device arriving wakes a
/// device watch, and a wrong passphrase wakes an unlock socket, so ask the watch
/// whether *this* wakeup settled anything and go back to waiting if it did not.
///
/// Callers that poll a terminal want it already in ICANON: then readable means a
/// whole line has been typed, and the read that follows will not block either.
pub fn wait(
    fd: BorrowedFd<'_>,
    timeout: Option<Duration>,
    mut watch: Option<&mut dyn Watch>,
) -> Result<Waited> {
    let start = Instant::now();

    loop {
        let deadline = timeout.map(|t| t.checked_sub(start.elapsed()).unwrap_or_default());

        // SAFETY: the watch outlives this call, so its descriptor does too.
        let watched = watch.as_ref()
            .map(|w| unsafe { BorrowedFd::borrow_raw(w.raw_fd()) });

        let mut fds = match watched.as_ref() {
            Some(w) => vec![PollFd::new(&fd, PollFlags::IN), PollFd::new(w, PollFlags::IN)],
            None => vec![PollFd::new(&fd, PollFlags::IN)],
        };

        let spec = deadline.map(|d| Timespec {
            tv_sec:  d.as_secs() as _,
            tv_nsec: d.subsec_nanos() as _,
        });

        if poll(&mut fds, spec.as_ref())? == 0 {
            debug!("nobody answered within the timeout");
            return Ok(Waited::Timeout);
        }

        // Read the readiness out before touching @watch again: the poll set
        // borrows its fd.
        let answered = fds[0].revents().intersects(PollFlags::IN | PollFlags::HUP);
        let stirred  = fds.len() > 1 && fds[1].revents().contains(PollFlags::IN);
        drop(fds);

        if answered {
            return Ok(Waited::Readable);
        }

        if stirred {
            match watch.as_mut().map(|w| w.stirred()) {
                Some(Stirred::Moot) => {
                    debug!("the question stopped applying while it was up");
                    return Ok(Waited::Moot);
                }
                Some(Stirred::Answered) => return Ok(Waited::Answered),
                Some(Stirred::Nothing) | None => (),
            }
        }
    }
}

/// [`wait`], in the terms a posted question is answered in.
fn wait_for_answer(
    fd: BorrowedFd<'_>,
    timeout: Option<Duration>,
    watch: Option<&mut dyn Watch>,
) -> Result<Answer> {
    Ok(match wait(fd, timeout, watch)? {
        Waited::Readable => Answer::Said(String::new()),
        Waited::Timeout  => Answer::Silence,
        // Nothing that answers a multiple-choice question this way exists yet;
        // the socket answers the passphrase prompt, which has its own path.
        Waited::Moot | Waited::Answered => Answer::Moot,
    })
}

/// --echo=yes because this isn't a password: the default is `masked`, an
/// asterisk per character plus a lock-and-key emoji.
///
/// Spawned rather than run to completion so it can be killed: with a watch,
/// the question can stop applying while the agent is still displaying it, and
/// leaving it on someone's screen after the fact is worse than not asking.
fn ask_via_agent(ask: &Ask<'_>, watch: Option<&mut dyn Watch>) -> Result<Answer> {
    let mut child = Command::new("systemd-ask-password")
        .arg("--no-tty")
        .arg("--echo=yes")
        .arg("--icon=drive-harddisk")
        .arg(format!("--id={}", ask.id))
        // 0 is systemd-ask-password's own spelling of "wait indefinitely".
        .arg(format!("--timeout={}", ask.timeout.map_or(0, |t| t.as_secs())))
        .arg("-n")
        .arg(format!("{} {}", ask.prompt, ask.brief))
        .stdin(Stdio::inherit())
        .stdout(Stdio::piped())
        .stderr(Stdio::inherit())
        .spawn()?;

    let mut stdout = child.stdout.take().expect("stdout is piped");

    // systemd-ask-password enforces its own --timeout, so let it: passing None
    // here means we wait on the child rather than racing it.
    let waited = wait_for_answer(stdout.as_fd(), None, watch);

    match waited? {
        Answer::Moot => {
            let _ = child.kill();
            let _ = child.wait();
            Ok(Answer::Moot)
        }
        _ => {
            let mut buf = String::new();
            stdout.read_to_string(&mut buf)?;
            if !child.wait()?.success() {
                debug!("systemd-ask-password declined or timed out");
                return Ok(Answer::Silence);
            }
            Ok(Answer::Said(buf))
        }
    }
}

fn ask_on_terminal(ask: &Ask<'_>, watch: Option<&mut dyn Watch>) -> Result<Answer> {
    use std::io::{stdout, Write};
    use owo_colors::OwoColorize;

    // stdout, not stdin: the question is read where it is drawn, and the two
    // can be different files.
    if ask.alarm && stdout().is_terminal() {
        println!("{}", ask.prompt.red().bold());
    } else {
        println!("{}", ask.prompt);
    }
    for choice in &ask.choices {
        println!("{choice}");
    }
    print!("{} ", ask.brief);
    stdout().flush()?;

    match wait_for_answer(stdin().as_fd(), ask.timeout, watch)? {
        Answer::Said(_) => {
            let mut answer = String::new();
            stdin().read_line(&mut answer)?;
            Ok(Answer::Said(answer))
        }
        // Leave the half-written prompt behind a newline rather than letting
        // whatever comes next start mid-line.
        other => {
            println!();
            Ok(other)
        }
    }
}
