const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { spawn } = require("child_process");

const DEFAULT_EXCLUDED_DIRECTORIES = new Set([
  ".git", ".vs", ".vscode", "bin", "obj", "node_modules", "data", "logs",
  "dist", "build", "coverage", "output", "publish", "package", "tmp", "temp",
  "tests", "docs"
]);
const DEFAULT_EXCLUDED_FILES = new Set([
  ".ds_store", "thumbs.db", "readme.md", "changelog.md"
]);

class DevelopmentUpdateService {
  constructor(options = {}) {
    this.repoRoot = path.resolve(options.repoRoot || path.join(__dirname, "..", ".."));
    this.cacheRoot = path.resolve(options.cacheRoot || path.join(this.repoRoot, "data", "development-updates"));
    this.runtime = options.runtime || global.lumiRuntime || null;
    this.spawn = options.spawn || spawn;
    this.platform = options.platform || process.platform;
    this.activeBuilds = new Map();
  }

  enabled() {
    return Boolean(this.runtime?.isDevelopment);
  }

  inventory() {
    const companionRoot = path.join(this.repoRoot, "companion");
    const components = [];
    const relatedServerPluginIds = new Set();
    const corePaths = [
      path.join(companionRoot, "src"),
      path.join(companionRoot, "legal"),
      path.join(companionRoot, "Lumi.Companion.sln"),
      path.join(companionRoot, "scripts", "publish-dev-update.ps1")
    ].filter((target) => fs.existsSync(target));
    if (corePaths.length) {
      components.push(componentRecord("companion:core", "companion-core", corePaths, this.repoRoot));
    }

    const companionPluginsRoot = path.join(companionRoot, "plugins");
    for (const name of listDirectories(companionPluginsRoot)) {
      const pluginRoot = path.join(companionPluginsRoot, name);
      components.push(componentRecord(`companion-plugin:${name}`, "companion-plugin", [pluginRoot], this.repoRoot));
      const declaredId = readPluginId(path.join(pluginRoot, "plugin.json"));
      if (declaredId) relatedServerPluginIds.add(declaredId);
    }

    const serverPluginsRoot = path.join(this.repoRoot, "plugins");
    for (const name of listDirectories(serverPluginsRoot)) {
      const pluginRoot = path.join(serverPluginsRoot, name);
      const related = relatedServerPluginIds.has(name) || fs.existsSync(path.join(pluginRoot, "companion_manifest.json"));
      components.push({
        ...componentRecord(`lumi-plugin:${name}`, "lumi-plugin", [pluginRoot], this.repoRoot),
        companion_related: related
      });
    }

    components.sort((a, b) => a.id.localeCompare(b.id));
    const packageComponents = components.filter((entry) => entry.kind === "companion-core" || entry.kind === "companion-plugin");
    const synchronizedServerComponents = components.filter((entry) => entry.kind === "lumi-plugin" && entry.companion_related);
    const trackedComponents = [...packageComponents, ...synchronizedServerComponents].sort((a, b) => a.id.localeCompare(b.id));
    const aggregateChecksum = aggregateComponents(trackedComponents);
    return {
      generated_at: new Date().toISOString(),
      aggregate_checksum: aggregateChecksum,
      components,
      package_components: packageComponents,
      synchronized_server_components: synchronizedServerComponents,
      tracked_components: trackedComponents,
      updatable_components: trackedComponents
    };
  }

  compare(current = {}) {
    const inventory = this.inventory();
    const currentAggregate = normalizeChecksum(current.aggregate_checksum || current.build_checksum);
    const currentComponents = normalizeComponentMap(current.components || current.component_checksums);
    const changed = inventory.updatable_components.filter((entry) => currentComponents[entry.id] !== entry.checksum);
    return {
      ...inventory,
      current_aggregate_checksum: currentAggregate || null,
      update_available: currentAggregate !== inventory.aggregate_checksum,
      changed_components: changed.map((entry) => ({ id: entry.id, kind: entry.kind, checksum: entry.checksum })),
      server_components: inventory.synchronized_server_components.map((entry) => ({ id: entry.id, checksum: entry.checksum }))
    };
  }

  async buildCompanionArtifact(state = this.inventory()) {
    if (!this.enabled()) throw new Error("Local development updates are disabled in production mode.");
    const buildId = normalizeChecksum(state.aggregate_checksum);
    if (!buildId) throw new Error("The local Companion source checksum is invalid.");
    const cached = this.readArtifact(buildId);
    if (cached) return cached;
    if (this.activeBuilds.has(buildId)) return this.activeBuilds.get(buildId);
    const build = this.buildCompanionArtifactInternal(state).finally(() => this.activeBuilds.delete(buildId));
    this.activeBuilds.set(buildId, build);
    return build;
  }

  readArtifact(buildId) {
    const safeId = normalizeChecksum(buildId);
    if (!safeId) return null;
    const archivePath = path.join(this.cacheRoot, "companion", safeId, "Lumi.Companion-win-x64.zip");
    const metadataPath = path.join(this.cacheRoot, "companion", safeId, "artifact.json");
    if (!fs.existsSync(archivePath) || !fs.existsSync(metadataPath)) return null;
    try {
      const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
      const stat = fs.statSync(archivePath);
      const sha256 = sha256File(archivePath);
      if (stat.size !== metadata.bytes || sha256 !== metadata.sha256) return null;
      return { ...metadata, path: archivePath };
    } catch {
      return null;
    }
  }

  async buildCompanionArtifactInternal(state) {
    const buildId = state.aggregate_checksum;
    const buildRoot = path.join(this.cacheRoot, "companion", buildId);
    const archivePath = path.join(buildRoot, "Lumi.Companion-win-x64.zip");
    const manifestPath = path.join(buildRoot, ".lumi-dev-build.json");
    fs.mkdirSync(buildRoot, { recursive: true });
    const manifest = {
      schema_version: 1,
      mode: "localhost-development",
      build_checksum: buildId,
      generated_at: new Date().toISOString(),
      components: Object.fromEntries(state.updatable_components.map((entry) => [entry.id, entry.checksum]))
    };
    fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");

    const script = path.join(this.repoRoot, "companion", "scripts", "publish-dev-update.ps1");
    if (!fs.existsSync(script)) throw new Error("The local Companion development publish script is missing.");
    const shell = findPowerShell(this.platform);
    await runProcess(this.spawn, shell, [
      "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
      "-File", script,
      "-OutputArchive", archivePath,
      "-ManifestPath", manifestPath
    ], { cwd: this.repoRoot, timeoutMs: 15 * 60 * 1000 });
    if (!fs.existsSync(archivePath)) throw new Error("The local Companion build completed without producing an update archive.");
    const afterBuild = this.inventory();
    if (afterBuild.aggregate_checksum !== buildId) {
      try { fs.rmSync(buildRoot, { recursive: true, force: true }); } catch { }
      return this.buildCompanionArtifact(afterBuild);
    }
    const metadata = {
      build_id: buildId,
      sha256: sha256File(archivePath),
      bytes: fs.statSync(archivePath).size,
      entrypoint: "Lumi.Companion.App.exe",
      generated_at: new Date().toISOString(),
      changed_components: state.updatable_components.map((entry) => entry.id)
    };
    fs.writeFileSync(path.join(buildRoot, "artifact.json"), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
    this.cleanupArtifacts(buildId);
    return { ...metadata, path: archivePath };
  }

  cleanupArtifacts(keepBuildId, maximum = 5) {
    const root = path.join(this.cacheRoot, "companion");
    if (!fs.existsSync(root)) return;
    const entries = fs.readdirSync(root, { withFileTypes: true })
      .filter((entry) => entry.isDirectory() && entry.name !== keepBuildId)
      .map((entry) => ({ name: entry.name, path: path.join(root, entry.name), mtime: fs.statSync(path.join(root, entry.name)).mtimeMs }))
      .sort((a, b) => b.mtime - a.mtime);
    for (const entry of entries.slice(Math.max(0, maximum - 1))) {
      try { fs.rmSync(entry.path, { recursive: true, force: true }); } catch { }
    }
  }
}

function componentRecord(id, kind, targets, root) {
  const checksum = hashTargets(targets, root);
  return {
    id,
    kind,
    checksum,
    paths: targets.map((target) => path.relative(root, target).replace(/\\/g, "/"))
  };
}

function hashTargets(targets, root) {
  const files = [];
  for (const target of targets) collectFiles(target, files);
  files.sort((a, b) => a.localeCompare(b));
  const hash = crypto.createHash("sha256");
  for (const file of files) {
    const relative = path.relative(root, file).replace(/\\/g, "/");
    const stat = fs.statSync(file);
    hash.update(relative, "utf8");
    hash.update("\0");
    hash.update(String(stat.size), "utf8");
    hash.update("\0");
    const fd = fs.openSync(file, "r");
    const buffer = Buffer.alloc(1024 * 1024);
    try {
      let read;
      while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read));
    } finally { fs.closeSync(fd); }
    hash.update("\0");
  }
  return hash.digest("hex");
}

function collectFiles(target, output) {
  if (!fs.existsSync(target)) return;
  const stat = fs.lstatSync(target);
  if (stat.isSymbolicLink()) return;
  if (stat.isFile()) {
    if (!DEFAULT_EXCLUDED_FILES.has(path.basename(target).toLowerCase())) output.push(target);
    return;
  }
  if (!stat.isDirectory()) return;
  for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
    if (entry.isDirectory() && DEFAULT_EXCLUDED_DIRECTORIES.has(entry.name)) continue;
    if (entry.isFile() && DEFAULT_EXCLUDED_FILES.has(entry.name.toLowerCase())) continue;
    collectFiles(path.join(target, entry.name), output);
  }
}

function aggregateComponents(components) {
  const hash = crypto.createHash("sha256");
  for (const entry of components.slice().sort((a, b) => a.id.localeCompare(b.id))) {
    hash.update(entry.id, "utf8");
    hash.update("\0");
    hash.update(entry.checksum, "utf8");
    hash.update("\0");
  }
  return hash.digest("hex");
}

function normalizeChecksum(value) {
  const normalized = String(value || "").trim().toLowerCase();
  return /^[a-f0-9]{64}$/.test(normalized) ? normalized : "";
}

function normalizeComponentMap(value) {
  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
  const output = {};
  for (const [key, checksum] of Object.entries(value)) {
    const safe = normalizeChecksum(checksum);
    if (safe && /^[-a-z0-9_.:]{1,160}$/i.test(key)) output[key] = safe;
  }
  return output;
}

function readPluginId(manifestPath) {
  if (!fs.existsSync(manifestPath)) return "";
  try {
    const value = JSON.parse(fs.readFileSync(manifestPath, "utf8"))?.id;
    return /^[-a-z0-9_.]{1,100}$/i.test(String(value || "")) ? String(value) : "";
  } catch { return ""; }
}

function listDirectories(root) {
  if (!fs.existsSync(root)) return [];
  return fs.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
}

function sha256File(target) {
  const hash = crypto.createHash("sha256");
  const fd = fs.openSync(target, "r");
  const buffer = Buffer.alloc(1024 * 1024);
  try {
    let read;
    while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read));
  } finally { fs.closeSync(fd); }
  return hash.digest("hex");
}

function findPowerShell(platform = process.platform) {
  if (platform !== "win32") return "pwsh";
  return "powershell.exe";
}

function runProcess(spawnImpl, command, args, options = {}) {
  return new Promise((resolve, reject) => {
    const child = spawnImpl(command, args, {
      cwd: options.cwd,
      env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: "1" },
      windowsHide: true,
      stdio: ["ignore", "pipe", "pipe"]
    });
    let stdout = "";
    let stderr = "";
    const limit = 256 * 1024;
    child.stdout?.on("data", (chunk) => { stdout = `${stdout}${chunk}`.slice(-limit); });
    child.stderr?.on("data", (chunk) => { stderr = `${stderr}${chunk}`.slice(-limit); });
    const timer = setTimeout(() => {
      child.kill();
      reject(new Error("The local Companion build timed out."));
    }, options.timeoutMs || 15 * 60 * 1000);
    child.once("error", (error) => { clearTimeout(timer); reject(error); });
    child.once("exit", (code) => {
      clearTimeout(timer);
      if (code === 0) return resolve({ stdout, stderr });
      const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
      reject(new Error(`The local Companion build failed with exit code ${code}. ${detail}`.trim()));
    });
  });
}

let singleton = null;
function getDevelopmentUpdateService(options = {}) {
  if (!singleton || options.reset) singleton = new DevelopmentUpdateService(options);
  return singleton;
}

module.exports = {
  DevelopmentUpdateService,
  aggregateComponents,
  getDevelopmentUpdateService,
  hashTargets,
  normalizeComponentMap,
  normalizeChecksum,
  sha256File
};
