use std::{
    env, fs,
    path::{Path, PathBuf},
    process::Command,
};

fn main() {
    println!("cargo:rerun-if-env-changed=CARGO_FEATURE_GRAPHIQL");
    if env::var_os("CARGO_FEATURE_GRAPHIQL").is_some() {
        return;
    }

    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR"));
    let output_file = out_dir.join("laboratory.html");
    let product_logo = manifest_dir.join("static/product_logo.svg");
    let node_modules_dist = out_dir.join("node_modules/@graphql-hive/laboratory/dist");

    fs::copy(
        manifest_dir.join("package.json"),
        out_dir.join("package.json"),
    )
    .expect("Failed to copy package.json");
    fs::copy(
        manifest_dir.join("package-lock.json"),
        out_dir.join("package-lock.json"),
    )
    .expect("Failed to copy package-lock.json");

    println!("cargo:rerun-if-changed={}", product_logo.display());
    println!(
        "cargo:rerun-if-changed={}",
        manifest_dir.join("package.json").display()
    );
    println!(
        "cargo:rerun-if-changed={}",
        out_dir.join("node_modules").display()
    );
    println!(
        "cargo:rerun-if-changed={}",
        manifest_dir.join("package-lock.json").display()
    );

    if !node_modules_dist.exists() {
        let status = Command::new("npm")
            .args([
                "install",
                "--include=dev", // NODE_ENV=production will skip dev deps - make sure they're in
            ])
            .current_dir(out_dir)
            .status()
            .expect("Failed to execute npm install");

        if !status.success() {
            panic!("npm install failed");
        }
    }

    let html = build_inline_laboratory_html(&node_modules_dist, &product_logo);

    fs::write(output_file, html).expect("failed to write generated laboratory.html");
}

fn build_inline_laboratory_html(dist_dir: &Path, product_logo: &Path) -> String {
    let js_contents = fs::read_to_string(dist_dir.join("hive-laboratory.umd.js"))
        .expect("failed to read hive-laboratory.umd.js");
    let editor_worker =
        fs::read_to_string(dist_dir.join("monacoeditorwork/editor.worker.bundle.js"))
            .expect("failed to read editor worker");
    let graphql_worker =
        fs::read_to_string(dist_dir.join("monacoeditorwork/graphql.worker.bundle.js"))
            .expect("failed to read graphql worker");
    let json_worker = fs::read_to_string(dist_dir.join("monacoeditorwork/json.worker.bundle.js"))
        .expect("failed to read json worker");
    let typescript_worker =
        fs::read_to_string(dist_dir.join("monacoeditorwork/ts.worker.bundle.js"))
            .expect("failed to read typescript worker");
    let product_logo_data_url = format!(
        "data:image/svg+xml;base64,{}",
        base64_encode(&fs::read(product_logo).expect("failed to read product logo"))
    );

    format!(
        r##"<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Hive Router Laboratory</title>
    <link rel="icon" type="image/svg+xml" href="{product_logo_data_url}" />
    <style>
      html,
      body,
      #root {{
        height: 100%;
      }}

      body {{
        margin: 0;
      }}
    </style>
  </head>
  <body id="body" class="no-focus-outline">
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>

    <script>
      function prepareBlob(workerContent) {{
        const blob = new Blob([workerContent], {{ type: "application/javascript" }});
        return URL.createObjectURL(blob);
      }}
      const workers = {{
        editorWorkerService: prepareBlob({editor_worker}),
        typescript: prepareBlob({typescript_worker}),
        json: prepareBlob({json_worker}),
        graphql: prepareBlob({graphql_worker}),
      }};
      self["MonacoEnvironment"] = {{
        globalAPI: false,
        getWorkerUrl: function (_moduleId, label) {{
          return workers[label];
        }},
      }};

      // Global headers: attach configured headers to every request the Laboratory makes to this
      // router, without showing them in the UI. Installed before the bundle so the bundle, which
      // captures `globalThis.fetch` at init, uses this wrapper. The router replaces the placeholder
      // with a header map (or {{}} when none are configured).
      (function () {{
        var globalHeaders = {{}};
        try {{
          globalHeaders = JSON.parse("__LABORATORY_GLOBAL_HEADERS__");
        }} catch (error) {{
          console.warn("Failed to read the Laboratory global headers", error);
        }}
        if (Object.keys(globalHeaders).length === 0) {{
          return;
        }}

        function isSameOrigin(url) {{
          try {{
            return new URL(url, window.location.href).origin === window.location.origin;
          }} catch (error) {{
            // Fail closed: if the URL can't be parsed, don't add the headers.
            return false;
          }}
        }}

        try {{
          // Built once; an invalid header name throws here (at install) rather than on every
          // request, and the catch keeps a bad value from breaking the rest of the page.
          var base = new Headers(globalHeaders);
          var nativeFetch = window.fetch.bind(window);
          window.fetch = function (input, init) {{
            init = init || {{}};
            var url = typeof input === "string" ? input : input && input.url;
            // Only the router's own endpoint (same-origin) gets the global headers, so they never
            // leak to a third-party endpoint a user repoints the Laboratory at.
            if (isSameOrigin(url)) {{
              var merged = new Headers(base);
              // A per-operation header of the same name wins.
              new Headers(init.headers || {{}}).forEach(function (value, key) {{
                merged.set(key, value);
              }});
              init = Object.assign({{}}, init, {{ headers: merged }});
            }}
            return nativeFetch(input, init);
          }};
        }} catch (error) {{
          console.warn("Failed to install Laboratory global headers", error);
        }}
      }})();

{js_contents}

      // The router always replaces this placeholder, with {{}} when nothing is configured.
      var hiveRouterSeed = {{}};
      try {{
        hiveRouterSeed = JSON.parse("__LABORATORY_PROPS__");
      }} catch (error) {{
        console.warn("Failed to read the Laboratory configuration", error);
      }}

      // Merges the seed with what the Laboratory has already persisted, so seeding does not
      // discard the user's own tabs and operations.
      function hiveRouterLaboratoryProps(seed) {{
        var STORAGE_NAMESPACE = "hive-laboratory";
        var SEEDED_TABS_KEY = "hive-router:seeded-tab-ids";
        var props = {{}};

        function readStored(key) {{
          try {{
            var raw = window.localStorage.getItem(STORAGE_NAMESPACE + ":" + key);
            return raw ? JSON.parse(raw) : null;
          }} catch (error) {{
            return null;
          }}
        }}

        // Seeded collections are refreshed from config wholesale; user-created ones are kept.
        var seededCollections = seed.collections || [];
        if (seededCollections.length > 0) {{
          var storedCollections = readStored("collections") || [];
          var seededCollectionIds = seededCollections.map(function (collection) {{
            return collection.id;
          }});
          props.defaultCollections = seededCollections.concat(
            storedCollections.filter(function (collection) {{
              return seededCollectionIds.indexOf(collection.id) === -1;
            }})
          );
        }}

        var seededOperations = seed.operations || [];
        if (seededOperations.length > 0) {{
          var storedOperations = readStored("operations") || [];
          var storedTabs = readStored("tabs") || [];

          var seededOperationIds = seededOperations.map(function (operation) {{
            return operation.id;
          }});

          // Seeded operations are refreshed from config; user-created ones are kept.
          props.defaultOperations = seededOperations.concat(
            storedOperations.filter(function (operation) {{
              return seededOperationIds.indexOf(operation.id) === -1;
            }})
          );

          // A seeded tab opens only the first time this browser sees it, so closing it makes it
          // stay closed while newly configured operations still show up.
          var alreadySeededTabIds = [];
          try {{
            alreadySeededTabIds =
              JSON.parse(window.localStorage.getItem(SEEDED_TABS_KEY)) || [];
          }} catch (error) {{
            alreadySeededTabIds = [];
          }}

          var openTabIds = storedTabs.map(function (tab) {{
            return tab.id;
          }});
          var newTabs = (seed.tabs || []).filter(function (tab) {{
            return (
              openTabIds.indexOf(tab.id) === -1 &&
              alreadySeededTabIds.indexOf(tab.id) === -1
            );
          }});

          props.defaultTabs = storedTabs.concat(newTabs);

          try {{
            var seenTabIds = alreadySeededTabIds.slice();
            (seed.tabs || []).forEach(function (tab) {{
              if (seenTabIds.indexOf(tab.id) === -1) {{
                seenTabIds.push(tab.id);
              }}
            }});
            window.localStorage.setItem(SEEDED_TABS_KEY, JSON.stringify(seenTabIds));
          }} catch (error) {{
            // Best-effort: unavailable storage only means a closed seeded tab may re-open later.
          }}

          // Keep the user where they left off, unless a brand new operation was just seeded.
          var storedActiveTabId = readStored("activeTabId");
          var storedTabIsStillOpen = props.defaultTabs.some(function (tab) {{
            return tab.id === storedActiveTabId;
          }});

          if (newTabs.length > 0) {{
            props.defaultActiveTabId = newTabs[0].id;
          }} else if (storedTabIsStillOpen) {{
            props.defaultActiveTabId = storedActiveTabId;
          }} else if (seed.activeTabId) {{
            props.defaultActiveTabId = seed.activeTabId;
          }}
        }}

        return props;
      }}

      HiveLaboratory.renderLaboratory(
        window.document.querySelector("#root"),
        hiveRouterLaboratoryProps(hiveRouterSeed)
      );
    </script>
  </body>
</html>
"##,
        product_logo_data_url = product_logo_data_url,
        editor_worker = js_string_literal(&editor_worker),
        typescript_worker = js_string_literal(&typescript_worker),
        json_worker = js_string_literal(&json_worker),
        graphql_worker = js_string_literal(&graphql_worker),
        js_contents = escape_inline_script(&js_contents),
    )
}

fn escape_inline_script(value: &str) -> String {
    value
        .replace("</script", "<\\/script")
        .replace("<!--", "<\\!--")
        .replace("<script", "<\\script")
}

fn js_string_literal(value: &str) -> String {
    let mut escaped = String::with_capacity(value.len() + 2);
    escaped.push('"');
    for ch in value.chars() {
        match ch {
            '\\' => escaped.push_str("\\\\"),
            '"' => escaped.push_str("\\\""),
            '\n' => escaped.push_str("\\n"),
            '\r' => escaped.push_str("\\r"),
            '\t' => escaped.push_str("\\t"),
            '\u{2028}' => escaped.push_str("\\u2028"),
            '\u{2029}' => escaped.push_str("\\u2029"),
            _ => escaped.push(ch),
        }
    }
    escaped.push('"');
    escape_inline_script(&escaped)
}

fn base64_encode(bytes: &[u8]) -> String {
    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);

    let mut chunks = bytes.chunks_exact(3);
    for chunk in &mut chunks {
        let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
        output.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
        output.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
        output.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
        output.push(TABLE[(n & 0x3f) as usize] as char);
    }

    let remainder = chunks.remainder();
    if !remainder.is_empty() {
        let first = remainder[0] as u32;
        let second = remainder.get(1).copied().unwrap_or_default() as u32;
        let n = (first << 16) | (second << 8);

        output.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
        output.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
        if remainder.len() == 2 {
            output.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
            output.push('=');
        } else {
            output.push('=');
            output.push('=');
        }
    }

    output
}
