// SPDX-FileCopyrightText: 2024 blinry <mail@blinry.org>
// SPDX-FileCopyrightText: 2024 zormit <nt4u@kpvn.de>
// SPDX-FileCopyrightText: 2026 Caleb Maclennan <caleb@alerque.com>
//
// SPDX-License-Identifier: AGPL-3.0-or-later

//! This module is all about daemon to editor communication.

use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::{env, fs};

use anyhow::bail;
use anyhow::{Context, Result};
use futures::StreamExt;
use tokio::{
    io::WriteHalf,
    net::{UnixListener, UnixStream},
};
use tokio_util::{
    bytes::BytesMut,
    codec::{Decoder, Encoder, FramedRead, FramedWrite, LinesCodec},
};
use tracing::{debug, error, info};

use crate::daemon::{DocMessage, DocumentActorHandle};
use crate::editor_protocol::{
    EditorProtocolMessageError, IncomingMessage, JSONRPCResponse, OutgoingMessage,
};
use crate::sandbox;

pub type EditorId = usize;

pub type EditorWriter = FramedWrite<WriteHalf<UnixStream>, OutgoingProtocolCodec>;

#[derive(Debug)]
pub struct OutgoingProtocolCodec;

impl Encoder<OutgoingMessage> for OutgoingProtocolCodec {
    type Error = anyhow::Error;

    fn encode(&mut self, item: OutgoingMessage, dst: &mut BytesMut) -> Result<(), Self::Error> {
        let payload = item.to_jsonrpc()?;
        dst.extend_from_slice(format!("{payload}\n").as_bytes());
        Ok(())
    }
}

#[derive(Debug)]
pub struct IncomingProtocolCodec;

impl Decoder for IncomingProtocolCodec {
    type Error = anyhow::Error;
    type Item = IncomingMessage;

    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
        LinesCodec::new()
            .decode(src)?
            .map(|line| IncomingMessage::from_jsonrpc(&line))
            .transpose()
    }
}

fn is_user_readable_only(socket_path: &Path) -> Result<()> {
    let parent_dir = socket_path
        .parent()
        .context("The socket path should not be the root directory")?;
    let current_permissions = fs::metadata(parent_dir)
        .with_context(|| {
            format!(
                "Expected to have access to metadata of the socket's parent directory: {}",
                parent_dir.display()
            )
        })?
        .permissions()
        .mode();
    // Group and others should not have any permissions.
    let allowed_permissions = 0o77700u32;
    if current_permissions | allowed_permissions != allowed_permissions {
        bail!(
            "For security reasons, the parent directory of the socket must only be accessible by the current user. Please run `chmod go-rwx {}`",
            parent_dir.display()
        );
    }
    Ok(())
}

pub(crate) fn strip_current_dir(path: &Path) -> PathBuf {
    let Ok(cwd) = env::current_dir() else {
        return path.to_path_buf();
    };
    path.strip_prefix(&cwd)
        .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
}

/// # Panics
///
/// Will panic if we fail to listen on the socket, or if we fail to accept an incoming connection.
pub fn spawn_socket_listener(
    socket_path: &Path,
    document_handle: DocumentActorHandle,
    bool_prompter: &dyn Fn(&str) -> Result<bool>,
) -> Result<()> {
    // Make sure the parent directory of the socket is only accessible by the current user.
    if let Err(description) = is_user_readable_only(socket_path) {
        bail!("{description}");
    }

    // Using the sandbox method here is technically unnecessary,
    // but we want to really run all path operations through the sandbox module.
    // TODO: Use correct directory as guard.
    if sandbox::exists(Path::new("/"), Path::new(&socket_path))
        .expect("Failed to check existence of path")
    {
        let socket_path_display = socket_path.display();
        let remove_socket = bool_prompter(&format!(
            "Detected an existing socket '{socket_path_display}'. There might be a daemon running already for this directory, or the previous one crashed. Do you want to continue?"
        ));
        if remove_socket? {
            sandbox::remove_file(Path::new("/"), socket_path).expect("Could not remove socket");
        } else {
            bail!("Not continuing, make sure to stop all other daemons on this directory");
        }
    }

    // The std library function used to create sockets requires a path shorter than SUN_LEN, but the
    // length that matters is only the segment it is asked to handle. If passed an absolute path
    // here we can potentially be run in a path that exceeds the maximum (~100 chars). Passing it a
    // relative path effectively sidesteps this limitation. Stripping the leading path segments will
    // result in a relative path that won't have a long cumbersome prefix that fails safety checks.
    // The extra song and dance to change into the parent directory first is not needed by our CLI
    // (which already changes to that location) but it will make this API usable when linked as a
    // library without changing the parent thread's location for keeps.
    let previous_cwd = env::current_dir()?;
    env::set_current_dir(
        socket_path
            .parent()
            .context("Invalid socket creation location")?,
    )?;
    let listener = UnixListener::bind(strip_current_dir(socket_path))?;
    env::set_current_dir(previous_cwd)?;
    debug!("Listening on UNIX socket: {}", socket_path.display());

    tokio::spawn(async move {
        loop {
            match listener.accept().await {
                Ok((stream, _addr)) => {
                    let id = document_handle.clone().next_editor_id();
                    let document_handle_clone = document_handle.clone();
                    tokio::spawn(async move {
                        handle_editor_connection(stream, document_handle_clone.clone(), id).await;
                    })
                }
                Err(err) => {
                    panic!("Error while accepting socket connection: {err}");
                }
            };
        }
    });

    Ok(())
}

async fn handle_editor_connection(
    stream: UnixStream,
    document_handle: DocumentActorHandle,
    editor_id: EditorId,
) {
    let (stream_read, stream_write) = tokio::io::split(stream);
    let mut reader = FramedRead::new(stream_read, IncomingProtocolCodec);
    let writer = FramedWrite::new(stream_write, OutgoingProtocolCodec);

    document_handle
        .send_message(DocMessage::NewEditorConnection(editor_id, writer))
        .await;
    info!("Editor #{editor_id} connected.");

    while let Some(message) = reader.next().await {
        match message {
            Ok(message) => {
                document_handle
                    .send_message(DocMessage::FromEditor(editor_id, message))
                    .await;
            }
            Err(e) => {
                let response = JSONRPCResponse::RequestError {
                    id: None,
                    error: EditorProtocolMessageError {
                        code: -32700,
                        message: format!("Invalid request: {e}"),
                        data: None,
                    },
                };
                error!("Error for JSON-RPC request: {:?}", response);
                let message = OutgoingMessage::Response(response);
                document_handle
                    .send_message(DocMessage::ToEditor(editor_id, message))
                    .await;
            }
        }
    }
    // Err(e) => {
    // }

    document_handle
        .send_message(DocMessage::CloseEditorConnection(editor_id))
        .await;
    info!("Editor #{editor_id} disconnected.");
}
