#![deny(warnings)]

use std::convert::Infallible;
use std::fs;
use std::net::SocketAddr;
use std::pin::Pin;
use std::process::{Command, Stdio};
use std::sync::Arc;

use bytes::Bytes;
use clap::{Arg, ArgMatches, Command as ClapCommand};
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming as IncomingBody;
use hyper::http::response::Builder;
use hyper::server::conn::http1;
use hyper::service::Service;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use tokio::task;

use core::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
use std::future::Future;

struct Opts {
    port: u16,
    file: String,
    commit: bool,
}

fn empty() -> Full<Bytes> {
    Full::new(Bytes::new())
}

fn from<T: Into<Bytes>>(data: T) -> Full<Bytes> {
    Full::new(data.into())
}

fn no_changes(file: &str) -> bool {
    let output = Command::new("git")
        .args(&["status", "--porcelain", file])
        .output()
        .expect("Failed to execute git");
    output.stdout.is_empty()
}

fn commit(file: &str) {
    if no_changes(file) {
        return;
    }
    let commit = Command::new("git")
        .args(&[
            "commit", file,
            "-m", "Commit after PUT",
            "--author", "DAV Daemon <dav@example.org>",
        ])
        .stdout(Stdio::null())
        .status()
        .expect("Failed to execute git");
    if !commit.success() {
        panic!("Failed to commit: {}", file)
    }
}

fn check_if_tracked(file: &str) {
    let check = Command::new("git")
        .args(&["ls-files", "--error-unmatch", file])
        .stdout(Stdio::null())
        .status()
        .expect("Failed to execute git");
    if !check.success() {
        panic!("File not tracked in git: {}", file)
    }
}

fn etag(bytes: &[u8]) -> String {
    let mut hasher = DefaultHasher::new();
    bytes.hash(&mut hasher);
    hasher.finish().to_string()
}

fn respond(status: StatusCode) -> Builder {
    Response::builder().status(status)
}

fn favicon() -> Response<Full<Bytes>> {
    let file = "favicon.ico";
    match fs::read(file) {
        Ok(bytes) => respond(StatusCode::OK)
            .header("etag", etag(&bytes))
            .body(from(bytes))
            .unwrap(),
        Err(_) => respond(StatusCode::NOT_FOUND)
            .body(empty())
            .unwrap(),
    }
}

fn get(opts: &Opts) -> Response<Full<Bytes>> {
    let bytes = fs::read(&opts.file).expect("the file is gone");
    respond(StatusCode::OK)
        .header("content-type", "text/html")
        .header("etag", etag(&bytes))
        .body(from(bytes))
        .unwrap()
}

fn head(opts: &Opts) -> Response<Full<Bytes>> {
    let mut result = get(opts);
    *result.body_mut() = empty();
    *result.status_mut() = StatusCode::NO_CONTENT;
    result
}

fn save(opts: &Opts, bytes: &[u8]) {
    fs::write(&opts.file, bytes).expect("cannot write to the file");
    if opts.commit {
        commit(&opts.file)
    }
}

async fn put(opts: &Opts, req: Request<IncomingBody>) -> Response<Full<Bytes>> {
    let client_etag = req.headers().get("if-match")
        .map_or("", |etag| etag.to_str().unwrap());
    let our_etag = etag(&fs::read(&opts.file).unwrap());
    if client_etag != our_etag {
        respond(StatusCode::PRECONDITION_FAILED)
            .body(empty())
            .unwrap()
    } else {
        let bytes = req.collect().await.expect("put body").to_bytes();
        save(opts, &bytes);
        respond(StatusCode::NO_CONTENT)
            .body(empty())
            .unwrap()
    }
}

fn bad_request() -> Response<Full<Bytes>> {
    respond(StatusCode::BAD_REQUEST)
        .header("content-type", "text/plain")
        .body(from("bad request\n"))
        .unwrap()
}

fn options() -> Response<Full<Bytes>> {
    respond(StatusCode::OK)
        .header("dav", "1")
        .body(empty())
        .unwrap()
}

async fn handle(opts: Arc<Opts>, req: Request<IncomingBody>) -> Response<Full<Bytes>> {
    match (req.method(), req.uri().path()) {
        (&Method::GET, "/") => {
            get(&opts)
        }
        (&Method::GET, "/favicon.ico") => {
            favicon()
        }
        (&Method::HEAD, "/") => {
            head(&opts)
        }
        (&Method::OPTIONS, "/") => {
            options()
        }
        (&Method::PUT, "/") => {
            put(&opts, req).await
        }
        _ => {
            bad_request()
        }
    }
}

fn commit_opt(file: &str, matches: &ArgMatches) -> bool {
    if matches.is_present("git") {
        check_if_tracked(file);
        true
    } else {
        false
    }
}

fn opts() -> Opts {
    let matches = ClapCommand::new("dav")
        .arg(
            Arg::new("port")
                .help("TCP port to listen on")
                .short('p')
                .default_value("53502"),
        )
        .arg(
            Arg::new("git")
                .help("Periodically commit changes to git")
                .takes_value(false)
                .short('g'),
        )
        .arg(
            Arg::new("file")
                .help("File to serve")
                .short('f')
                .default_value("tiddlywiki.html"),
        )
        .get_matches();

    let port = matches.value_of("port").unwrap().parse().unwrap();
    let file = matches.value_of("file").unwrap().to_owned();

    if fs::metadata(&file).is_err() {
        panic!("File doesn't exist: {}", &file);
    }

    let commit = commit_opt(&file, &matches);

    Opts { file, port, commit }
}

#[derive(Clone)]
struct MyService {
    opts: Arc<Opts>,
}

impl Service<Request<IncomingBody>> for MyService {
    type Response = Response<Full<Bytes>>;
    type Error = Infallible;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

    fn call(&self, req: Request<IncomingBody>) -> Self::Future {
        let opts = self.opts.clone();
        Box::pin(async move {
            Ok(handle(opts, req).await)
        })
    }
}

#[tokio::main]
pub async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let opts = Arc::new(opts());

    let addr = SocketAddr::from(([127, 0, 0, 1], opts.port));
    let listener = TcpListener::bind(addr).await?;
    println!("Listening on http://{}", addr);

    loop {
        let (stream, _) = listener.accept().await?;
        let io = TokioIo::new(stream);
        let service = MyService { opts: opts.clone() };

        task::spawn(async move {
            if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
                eprintln!("Error serving connection: {:?}", err);
            }
        });
    }
}