use std::{
    env,
    ffi::OsStr,
    fs,
    io::{ErrorKind, Read, Write},
    net::{Shutdown, TcpListener, TcpStream},
    path::{Path, PathBuf},
    process::{Child, Command, Output, Stdio},
    sync::atomic::{AtomicUsize, Ordering},
    thread,
    time::{Duration, Instant},
};

static NEXT_FIXTURE_ID: AtomicUsize = AtomicUsize::new(0);

#[test]
fn standard_net_lowers_to_private_runtime_helpers() {
    let fixture = write_fixture(
        "lowering",
        r#"
(module main)

(fn main () -> i32
  (match (std.net.tcp_connect_loopback_result 1)
    ((ok client)
      (std.net.tcp_write_text_result client "ping")
      (std.net.tcp_read_all_result client)
      (std.net.tcp_close_result client)
      0)
    ((err code)
      (match (std.net.tcp_listen_loopback_result 0)
        ((ok listener)
          (std.net.tcp_bound_port_result listener)
          (std.net.tcp_accept_result listener)
          (std.net.tcp_close_result listener)
          0)
        ((err listen_code)
          listen_code)))))
"#,
    );
    let output = run_glagol([fixture.as_os_str()]);
    assert_success("compile standard net lowering", &output);
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert!(
        stdout.contains("declare i64 @__glagol_net_tcp_connect_loopback_result(i32)")
            && stdout.contains("declare i64 @__glagol_net_tcp_listen_loopback_result(i32)")
            && stdout.contains("declare i64 @__glagol_net_tcp_bound_port_result(i32)")
            && stdout.contains("declare i64 @__glagol_net_tcp_accept_result(i32)")
            && stdout.contains("declare ptr @__glagol_net_tcp_read_all_result(i32)")
            && stdout.contains("declare i32 @__glagol_net_tcp_write_text_result(i32, ptr)")
            && stdout.contains("declare i32 @__glagol_net_tcp_close_result(i32)")
            && stdout.contains("call i64 @__glagol_net_tcp_connect_loopback_result(i32 1)")
            && stdout.contains("call i32 @__glagol_net_tcp_write_text_result(")
            && stdout.contains("call ptr @__glagol_net_tcp_read_all_result(")
            && stdout.contains("call i64 @__glagol_net_tcp_listen_loopback_result(i32 0)")
            && stdout.contains("call i64 @__glagol_net_tcp_bound_port_result(")
            && stdout.contains("call i64 @__glagol_net_tcp_accept_result(")
            && stdout.contains("call i32 @__glagol_net_tcp_close_result(")
            && !stdout.contains("@std.net."),
        "standard net LLVM shape drifted\nstdout:\n{}",
        stdout
    );
}

#[test]
fn test_runner_reports_deterministic_net_result_errors() {
    let fixture = write_fixture(
        "test-runner",
        r#"
(module main)

(test "connect invalid port returns err"
  (= (unwrap_err (std.net.tcp_connect_loopback_result 0)) 1))

(test "listen invalid port returns err"
  (= (unwrap_err (std.net.tcp_listen_loopback_result -1)) 1))

(test "bound port invalid handle returns err"
  (= (unwrap_err (std.net.tcp_bound_port_result -1)) 1))

(test "accept invalid listener returns err"
  (= (unwrap_err (std.net.tcp_accept_result -1)) 1))

(test "read invalid handle returns err"
  (= (unwrap_err (std.net.tcp_read_all_result -1)) 1))

(test "write invalid handle returns err"
  (= (unwrap_err (std.net.tcp_write_text_result -1 "ping")) 1))

(test "close invalid handle returns err"
  (= (unwrap_err (std.net.tcp_close_result -1)) 1))
"#,
    );
    let output = run_glagol([OsStr::new("test"), fixture.as_os_str()]);
    assert_success("run standard net tests", &output);
    assert_eq!(
        String::from_utf8_lossy(&output.stdout),
        concat!(
            "test \"connect invalid port returns err\" ... ok\n",
            "test \"listen invalid port returns err\" ... ok\n",
            "test \"bound port invalid handle returns err\" ... ok\n",
            "test \"accept invalid listener returns err\" ... ok\n",
            "test \"read invalid handle returns err\" ... ok\n",
            "test \"write invalid handle returns err\" ... ok\n",
            "test \"close invalid handle returns err\" ... ok\n",
            "7 test(s) passed\n",
        ),
        "standard net test runner stdout drifted"
    );
}

#[test]
fn standard_net_diagnostics_cover_promoted_and_unpromoted_names() {
    let cases = [
        (
            "connect-arity",
            r#"
(module main)

(fn main () -> i32
  (std.net.tcp_connect_loopback_result))
"#,
            "ArityMismatch",
        ),
        (
            "write-type",
            r#"
(module main)

(fn main () -> i32
  (std.net.tcp_write_text_result "handle" "ping")
  0)
"#,
            "TypeMismatch",
        ),
        (
            "unknown-net",
            r#"
(module main)

(fn main () -> i32
  (std.net.udp_bind_loopback_result 0))
"#,
            "UnsupportedStandardLibraryCall",
        ),
        (
            "promoted-shadow",
            r#"
(module main)

(fn std.net.tcp_close_result ((handle i32)) -> (result i32 i32)
  (ok i32 i32 0))

(fn main () -> i32
  (unwrap_ok (std.net.tcp_close_result 1)))
"#,
            "DuplicateFunction",
        ),
    ];

    for (name, source, diagnostic) in cases {
        let fixture = write_fixture(name, source);
        let output = run_glagol([fixture.as_os_str()]);
        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);

        assert!(
            !output.status.success(),
            "compiler unexpectedly accepted `{}`\nstdout:\n{}\nstderr:\n{}",
            name,
            stdout,
            stderr
        );
        assert!(
            stdout.is_empty(),
            "rejected compile wrote stdout:\n{}",
            stdout
        );
        assert!(
            stderr.contains(diagnostic),
            "diagnostic `{}` was not reported for `{}`\nstderr:\n{}",
            diagnostic,
            name,
            stderr
        );
    }
}

#[test]
fn hosted_loopback_client_and_server_smoke_when_clang_is_available() {
    let Some(clang) = find_clang() else {
        eprintln!("skipping standard net runtime smoke: set GLAGOL_CLANG or install clang");
        return;
    };

    let Some(port) = try_reserve_loopback_port() else {
        eprintln!("skipping standard net server smoke: loopback bind is not permitted");
        return;
    };
    let server_source = format!(
        r#"
(module main)

(fn main () -> i32
  (match (std.net.tcp_listen_loopback_result {port})
    ((ok listener)
      (match (std.net.tcp_accept_result listener)
        ((ok stream)
          (match (std.net.tcp_read_all_result stream)
            ((ok text)
              (std.net.tcp_write_text_result stream "pong")
              (std.net.tcp_close_result stream)
              (std.net.tcp_close_result listener)
              (if (= text "ping") 0 2))
            ((err read_code)
              (std.net.tcp_close_result stream)
              (std.net.tcp_close_result listener)
              3)))
        ((err accept_code)
          (std.net.tcp_close_result listener)
          4)))
    ((err listen_code)
      5)))
"#
    );
    let server_fixture = write_fixture("runtime-server", &server_source);
    let compile = run_glagol([server_fixture.as_os_str()]);
    assert_success("compile standard net server smoke", &compile);
    let server_exe = compile_with_runtime(&clang, "standard-net-server", &compile.stdout);

    let mut child = Command::new(&server_exe)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap_or_else(|err| panic!("spawn `{}`: {}", server_exe.display(), err));

    let mut stream = connect_with_retry(port, &mut child);
    stream
        .write_all(b"ping")
        .unwrap_or_else(|err| panic!("write ping to loopback server: {}", err));
    stream
        .shutdown(Shutdown::Write)
        .expect("shutdown loopback client write half");

    let mut response = String::new();
    stream
        .read_to_string(&mut response)
        .unwrap_or_else(|err| panic!("read loopback server response: {}", err));
    assert_eq!(response, "pong", "loopback response drifted");

    let output = wait_child_with_timeout(child, Duration::from_secs(5));
    assert_success("run standard net server smoke", &output);
    assert!(
        output.stdout.is_empty(),
        "standard net server smoke wrote stdout:\n{}",
        String::from_utf8_lossy(&output.stdout)
    );
}

#[test]
fn hosted_loopback_client_smoke_when_clang_is_available() {
    let Some(clang) = find_clang() else {
        eprintln!("skipping standard net client smoke: set GLAGOL_CLANG or install clang");
        return;
    };

    let listener = match TcpListener::bind(("127.0.0.1", 0)) {
        Ok(listener) => listener,
        Err(err) if err.kind() == ErrorKind::PermissionDenied => {
            eprintln!("skipping standard net client smoke: loopback bind is not permitted");
            return;
        }
        Err(err) => panic!("bind loopback smoke listener: {}", err),
    };
    let port = listener
        .local_addr()
        .expect("loopback smoke listener addr")
        .port();
    let server = thread::spawn(move || {
        let (mut stream, _) = listener.accept().expect("accept loopback client");
        let mut request = [0u8; 4];
        stream
            .read_exact(&mut request)
            .expect("read loopback client request");
        assert_eq!(&request, b"ping");
        stream
            .write_all(b"pong")
            .expect("write loopback client response");
    });

    let client_source = format!(
        r#"
(module main)

(fn main () -> i32
  (match (std.net.tcp_connect_loopback_result {port})
    ((ok client)
      (std.net.tcp_write_text_result client "ping")
      (match (std.net.tcp_read_all_result client)
        ((ok text)
          (std.net.tcp_close_result client)
          (if (= text "pong") 0 2))
        ((err read_code)
          (std.net.tcp_close_result client)
          3)))
    ((err connect_code)
      4)))
"#
    );
    let client_fixture = write_fixture("runtime-client", &client_source);
    let compile = run_glagol([client_fixture.as_os_str()]);
    assert_success("compile standard net client smoke", &compile);
    let run = compile_and_run_with_runtime(&clang, "standard-net-client", &compile.stdout);
    assert_success("run standard net client smoke", &run);
    assert!(
        run.stdout.is_empty(),
        "standard net client smoke wrote stdout:\n{}",
        String::from_utf8_lossy(&run.stdout)
    );
    server.join().expect("loopback smoke server thread");
}

fn run_glagol<I, S>(args: I) -> Output
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    Command::new(env!("CARGO_BIN_EXE_glagol"))
        .args(args)
        .current_dir(Path::new(env!("CARGO_MANIFEST_DIR")))
        .output()
        .expect("run glagol")
}

fn write_fixture(name: &str, source: &str) -> PathBuf {
    let mut path = env::temp_dir();
    path.push(format!(
        "glagol-standard-net-{}-{}-{}.slo",
        name,
        std::process::id(),
        NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
    ));
    fs::write(&path, source).unwrap_or_else(|err| panic!("write `{}`: {}", path.display(), err));
    path
}

fn try_reserve_loopback_port() -> Option<u16> {
    let listener = match TcpListener::bind(("127.0.0.1", 0)) {
        Ok(listener) => listener,
        Err(err) if err.kind() == ErrorKind::PermissionDenied => return None,
        Err(err) => panic!("reserve loopback port: {}", err),
    };
    Some(
        listener
            .local_addr()
            .expect("reserved loopback addr")
            .port(),
    )
}

fn connect_with_retry(port: u16, child: &mut Child) -> TcpStream {
    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        match TcpStream::connect(("127.0.0.1", port)) {
            Ok(stream) => return stream,
            Err(connect_err) => {
                if let Some(status) = child.try_wait().expect("poll loopback server child") {
                    let mut stdout = String::new();
                    let mut stderr = String::new();
                    if let Some(mut out) = child.stdout.take() {
                        let _ = out.read_to_string(&mut stdout);
                    }
                    if let Some(mut err) = child.stderr.take() {
                        let _ = err.read_to_string(&mut stderr);
                    }
                    panic!(
                        "loopback server exited before connect: {}\nstdout:\n{}\nstderr:\n{}",
                        status, stdout, stderr
                    );
                }
                if Instant::now() >= deadline {
                    panic!("timed out connecting to loopback server: {}", connect_err);
                }
                thread::sleep(Duration::from_millis(20));
            }
        }
    }
}

fn wait_child_with_timeout(mut child: Child, timeout: Duration) -> Output {
    let deadline = Instant::now() + timeout;
    while Instant::now() < deadline {
        if child.try_wait().expect("poll child").is_some() {
            return child.wait_with_output().expect("collect child output");
        }
        thread::sleep(Duration::from_millis(20));
    }

    let _ = child.kill();
    let output = child
        .wait_with_output()
        .expect("collect killed child output");
    panic!(
        "child timed out\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

fn assert_success(context: &str, output: &Output) {
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        output.status.success(),
        "{} failed\nstdout:\n{}\nstderr:\n{}",
        context,
        stdout,
        stderr
    );
}

fn compile_and_run_with_runtime(clang: &Path, name: &str, ir: &[u8]) -> Output {
    let exe_path = compile_with_runtime(clang, name, ir);
    Command::new(&exe_path)
        .output()
        .unwrap_or_else(|err| panic!("run `{}`: {}", exe_path.display(), err))
}

fn compile_with_runtime(clang: &Path, name: &str, ir: &[u8]) -> PathBuf {
    let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
    let temp_dir = env::temp_dir().join(format!(
        "glagol-standard-net-{}-{}",
        std::process::id(),
        NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
    ));
    fs::create_dir_all(&temp_dir)
        .unwrap_or_else(|err| panic!("create `{}`: {}", temp_dir.display(), err));

    let ir_path = temp_dir.join(format!("{}.ll", name));
    let exe_path = temp_dir.join(name);
    fs::write(&ir_path, ir).unwrap_or_else(|err| panic!("write `{}`: {}", ir_path.display(), err));

    let runtime = manifest.join("../runtime/runtime.c");
    let mut clang_command = Command::new(clang);
    clang_command
        .arg(&runtime)
        .arg(&ir_path)
        .arg("-o")
        .arg(&exe_path)
        .current_dir(manifest);
    configure_clang_runtime_env(&mut clang_command, clang);
    let clang_output = clang_command
        .output()
        .unwrap_or_else(|err| panic!("run `{}`: {}", clang.display(), err));
    assert_success("clang standard net runtime smoke", &clang_output);
    exe_path
}

fn find_clang() -> Option<PathBuf> {
    if let Some(path) = env::var_os("GLAGOL_CLANG").filter(|value| !value.is_empty()) {
        return Some(PathBuf::from(path));
    }

    let hermetic_clang = PathBuf::from("/tmp/glagol-clang-root/usr/bin/clang");
    if hermetic_clang.is_file() {
        return Some(hermetic_clang);
    }

    find_on_path("clang")
}

fn find_on_path(program: &str) -> Option<PathBuf> {
    let path = env::var_os("PATH")?;
    env::split_paths(&path)
        .map(|dir| dir.join(program))
        .find(|candidate| candidate.is_file())
}

fn configure_clang_runtime_env(command: &mut Command, clang: &Path) {
    if !clang.starts_with("/tmp/glagol-clang-root") {
        return;
    }

    let root = Path::new("/tmp/glagol-clang-root");
    let lib64 = root.join("usr/lib64");
    let lib = root.join("usr/lib");
    let mut paths = vec![lib64, lib];

    if let Some(existing) = env::var_os("LD_LIBRARY_PATH") {
        paths.extend(env::split_paths(&existing));
    }

    let joined = env::join_paths(paths).expect("join LD_LIBRARY_PATH");
    command.env("LD_LIBRARY_PATH", joined);
}
