#![deny(warnings)]

use std::convert::Infallible;
use std::fs;
use core::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
use std::sync::Arc;
use std::process::{Command, Stdio};

use hyper::service::{make_service_fn, service_fn};
use hyper::{body, Body, Method, Request, Response, Server, StatusCode};
use clap::{Arg, ArgMatches, App};

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

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 favicon() -> Response<Body> {
    let file = "favicon.ico";
    match fs::read(file) {
        Ok(bytes) => Response::builder()
            .status(StatusCode::OK)
            .header("etag", etag(&bytes))
            .body(Body::from(bytes))
            .unwrap(),
        Err(_) => Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(Body::empty())
            .unwrap()
    }
}

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

fn head(opts: &Opts) -> Response<Body> {
    let mut result = get(opts);
    *result.body_mut() = Body::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<Body>) -> Response<Body> {
    let (head, body) = req.into_parts();
    let client_etag = head.headers.get("if-match").unwrap().to_str().unwrap();
    let our_etag = etag(&fs::read(&opts.file).unwrap());
    if client_etag != our_etag {
        Response::builder()
            .status(StatusCode::PRECONDITION_FAILED)
            .body(Body::empty())
            .unwrap()
    } else {
        let bytes = body::to_bytes(body).await.expect("put body");
        save(opts, &bytes);
        Response::builder()
            .status(StatusCode::NO_CONTENT)
            .body(Body::empty())
            .unwrap()
    }
}

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

fn options() -> Response<Body> {
    Response::builder()
        .status(StatusCode::OK)
        .header("dav", "1")
        .body(Body::empty())
        .unwrap()
}

async fn handle(opts: Arc<Opts>, req: Request<Body>) -> Result<Response<Body>, Infallible> {
    Ok(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 = App::new("dav")
        .arg(Arg::with_name("port")
            .help("TCP port to listen on")
            .short("p")
            .default_value("53502"))
        .arg(Arg::with_name("git")
            .help("Periodically commit changes to git")
            .takes_value(false)
            .short("g"))
        .arg(Arg::with_name("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 }
}

#[tokio::main]
pub async fn main() {
    let opts = Arc::new(opts());

    let addr = ([127, 0, 0, 1], opts.port).into();

    let make_svc = make_service_fn(|_conn| {
        let opts = opts.clone();
        async {
            Ok::<_, Infallible>(service_fn(move |req| {
                handle(opts.clone(), req)
            }))
        }
    });

    let server = Server::bind(&addr).serve(make_svc);

    println!("Listening on http://{}", addr);

    if let Err(e) = server.await {
        eprintln!("server error: {}", e);
    }
}