import { OPENCODE_PINNED_VERSION, OPENCODE_RELEASE_API_URL_TEMPLATE } from "@/constants";
import { logError, logInfo, logWarn } from "@/logger";
import type CopilotPlugin from "@/main";
import { getSettings, setSettings, type OpencodeBackendSettings } from "@/settings/model";
import { execFile, spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import * as fs from "node:fs";
import * as https from "node:https";
import { IncomingMessage } from "node:http";
import { FileSystemAdapter, requestUrl } from "obsidian";
import * as path from "node:path";
import { promisify } from "node:util";
import { renameWithRetry } from "@/agentMode/skills/renameWithRetry";
import { expectedBinaryName, resolveOpencodeTarget } from "./platformResolver";

const execFileAsync = promisify(execFile);
const DOWNLOAD_INACTIVITY_TIMEOUT_MS = 30_000;
// Generous: first-run on Windows (Defender real-time scan) and macOS
// (Gatekeeper translocation) can add a few seconds before the binary responds.
const VERIFY_BINARY_TIMEOUT_MS = 8_000;

export type ProgressEvent =
  | { phase: "resolve"; message: string }
  | { phase: "download"; received: number; total?: number; assetName: string }
  | { phase: "extract"; message: string }
  | { phase: "done"; version: string; path: string };

export interface InstallOptions {
  onProgress?: (e: ProgressEvent) => void;
  signal?: AbortSignal;
  /** Override pinned version. Defaults to OPENCODE_PINNED_VERSION. */
  version?: string;
}

export type InstallState =
  | { kind: "absent" }
  | { kind: "installed"; version: string; path: string; source: "managed" | "custom" };

interface GithubAsset {
  name: string;
  size: number;
  browser_download_url: string;
}

interface GithubRelease {
  tag_name: string;
  assets: GithubAsset[];
}

interface InstallManifest {
  version: string;
  assetName: string;
  installedAt: string;
}

export class AbortError extends Error {
  constructor() {
    super("Aborted");
    this.name = "AbortError";
  }
}

export function pickMatchingAsset(release: GithubRelease, candidates: string[]): GithubAsset {
  const ARCHIVE_EXTS = [".zip", ".tar.gz", ".tar.xz", ".tgz"];
  const stemOf = (name: string): string => {
    for (const ext of ARCHIVE_EXTS) {
      if (name.endsWith(ext)) return name.slice(0, -ext.length);
    }
    return name;
  };

  const byStem = new Map<string, GithubAsset>();
  for (const a of release.assets) {
    byStem.set(stemOf(a.name), a);
  }

  for (const stem of candidates) {
    const asset = byStem.get(stem);
    if (asset) return asset;
  }

  throw new Error(
    `No matching opencode release asset found. Tried: ${candidates.join(", ")}. ` +
      `Available: ${release.assets.map((a) => a.name).join(", ")}`
  );
}

/** Derive the install state from the persisted `agentMode.backends.opencode` slice. */
export function computeInstallState(opencode: OpencodeBackendSettings | undefined): InstallState {
  const s = opencode ?? {};
  if (s.binaryPath && s.binaryVersion) {
    return {
      kind: "installed",
      version: s.binaryVersion,
      path: s.binaryPath,
      source: s.binarySource ?? "managed",
    };
  }
  return { kind: "absent" };
}

/** Read the OpenCode-specific settings slice from current settings. */
export function readOpencodeSettings(): OpencodeBackendSettings {
  return getSettings().agentMode?.backends?.opencode ?? {};
}

function updateOpencodeFields(partial: Partial<OpencodeBackendSettings>): void {
  setSettings((cur) => ({
    agentMode: {
      ...cur.agentMode,
      backends: {
        ...cur.agentMode.backends,
        opencode: { ...(cur.agentMode.backends?.opencode ?? {}), ...partial },
      },
    },
  }));
}

function clearOpencodeBinary(): void {
  updateOpencodeFields({
    binaryVersion: undefined,
    binaryPath: undefined,
    binarySource: undefined,
  });
}

/**
 * Manages the lifecycle of the opencode binary on disk: platform-aware
 * download from GitHub releases, extraction into the plugin data dir, and
 * persistence of the install location into `settings.agentMode`. Desktop-only.
 */
export class OpencodeBinaryManager {
  constructor(private readonly plugin: CopilotPlugin) {}

  getInstallState(): InstallState {
    return computeInstallState(readOpencodeSettings());
  }

  /**
   * Reconcile persisted install state with what's actually on disk. If we
   * believe a managed install exists but the binary is gone (user deleted it,
   * restored a vault from backup, etc.), demote to `absent`. Skipped for
   * custom-source installs — re-checking on every plugin load would punish
   * users for transient filesystem hiccups (network mounts, etc.).
   */
  async refreshInstallState(): Promise<void> {
    const state = this.getInstallState();
    if (state.kind !== "installed") return;
    if (state.source !== "managed") return;
    if (await fileExists(state.path)) return;
    logWarn(`[AgentMode] persisted opencode binary missing at ${state.path}; clearing settings.`);
    clearOpencodeBinary();
  }

  /** Absolute path to `<vault>/.obsidian/plugins/<id>/data/opencode`. */
  getDataDir(): string {
    const adapter = this.plugin.app.vault.adapter;
    if (!(adapter instanceof FileSystemAdapter)) {
      throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter).");
    }
    const base = adapter.getBasePath();
    const id = this.plugin.manifest.id;
    return path.join(base, this.plugin.app.vault.configDir, "plugins", id, "data", "opencode");
  }

  getPinnedVersion(): string {
    return OPENCODE_PINNED_VERSION;
  }

  /**
   * Full install pipeline: resolve target → fetch release metadata →
   * download → extract → atomic rename → persist settings. Idempotent when
   * an existing install matches the pinned manifest.
   */
  async install(opts: InstallOptions = {}): Promise<{ version: string; path: string }> {
    const version = opts.version ?? OPENCODE_PINNED_VERSION;
    const dataDir = this.getDataDir();
    const versionDir = path.join(dataDir, version);

    opts.onProgress?.({ phase: "resolve", message: "Resolving platform asset…" });
    const { target, candidates } = await resolveOpencodeTarget();
    this.throwIfAborted(opts.signal);

    const release = await this.fetchReleaseMetadata(version);
    this.throwIfAborted(opts.signal);

    const asset = pickMatchingAsset(release, candidates);

    const binName = expectedBinaryName(target.platform);
    const finalBinPath = path.join(versionDir, "bin", binName);

    // Idempotency: if the existing manifest matches and the binary is in place, no-op.
    const existing = await readManifest(path.join(versionDir, "install-manifest.json"));
    if (existing && existing.assetName === asset.name && (await fileExists(finalBinPath))) {
      logInfo(`[AgentMode] opencode ${version} already installed at ${finalBinPath}`);
      // Skip the write when settings already match — avoids spuriously waking
      // every settings subscriber on healthy plugin loads.
      const cur = readOpencodeSettings();
      if (
        cur.binaryVersion !== version ||
        cur.binaryPath !== finalBinPath ||
        cur.binarySource !== "managed"
      ) {
        updateOpencodeFields({
          binaryVersion: version,
          binaryPath: finalBinPath,
          binarySource: "managed",
        });
      }
      opts.onProgress?.({ phase: "done", version, path: finalBinPath });
      return { version, path: finalBinPath };
    }

    await fs.promises.mkdir(dataDir, { recursive: true });
    const tmpDir = path.join(dataDir, `.tmp-${version}-${randomBytes(4).toString("hex")}`);
    await fs.promises.mkdir(tmpDir, { recursive: true });

    try {
      const archivePath = path.join(tmpDir, asset.name);
      await downloadToFile(asset.browser_download_url, archivePath, asset.size, opts);
      this.throwIfAborted(opts.signal);

      opts.onProgress?.({ phase: "extract", message: "Extracting archive…" });
      const extractDir = path.join(tmpDir, "extract");
      await fs.promises.mkdir(extractDir, { recursive: true });
      await extractArchive(archivePath, extractDir);
      this.throwIfAborted(opts.signal);

      const extractedBin = await locateFile(extractDir, binName);
      if (target.platform !== "windows") {
        await fs.promises.chmod(extractedBin, 0o755);
      }

      // Stage the final layout under tmpDir, then atomically rename into place.
      const stageDir = path.join(tmpDir, "stage");
      const stageBinDir = path.join(stageDir, "bin");
      await fs.promises.mkdir(stageBinDir, { recursive: true });
      await fs.promises.rename(extractedBin, path.join(stageBinDir, binName));

      const manifest: InstallManifest = {
        version,
        assetName: asset.name,
        installedAt: new Date().toISOString(),
      };
      await fs.promises.writeFile(
        path.join(stageDir, "install-manifest.json"),
        JSON.stringify(manifest, null, 2)
      );

      // Rename-aside-then-rename: move any existing versionDir out of the way,
      // promote the staged dir into place, and only then delete the old one.
      // If the second rename fails, we restore the original so the user keeps
      // a working install instead of a half-deleted one.
      let asideDir: string | null = null;
      if (await fileExists(versionDir)) {
        asideDir = `${versionDir}.old-${randomBytes(4).toString("hex")}`;
        await renameWithRetry(versionDir, asideDir);
      }
      try {
        await renameWithRetry(stageDir, versionDir);
      } catch (e) {
        if (asideDir) {
          await renameWithRetry(asideDir, versionDir).catch((restoreErr) =>
            logError("[AgentMode] failed to restore previous opencode install", restoreErr)
          );
        }
        throw e;
      }
      if (asideDir) {
        await removeDir(asideDir).catch((rmErr) =>
          logWarn(`[AgentMode] failed to remove ${asideDir}: ${rmErr}`)
        );
      }

      // Smoke-test the installed binary. Catches corrupt extracts and
      // platform/libc mismatches before the user hits them at ACP boot —
      // failures here surface in the install Modal where a Retry is one click
      // away.
      await verifyOpencodeBinary(finalBinPath);
      this.throwIfAborted(opts.signal);

      updateOpencodeFields({
        binaryVersion: version,
        binaryPath: finalBinPath,
        binarySource: "managed",
      });
      opts.onProgress?.({ phase: "done", version, path: finalBinPath });
      logInfo(`[AgentMode] opencode ${version} installed at ${finalBinPath}`);
      return { version, path: finalBinPath };
    } catch (err) {
      logError("[AgentMode] opencode install failed", err);
      throw err;
    } finally {
      await removeDir(tmpDir).catch(() => {});
    }
  }

  /**
   * Remove the active managed version dir and clear settings. For a custom
   * binary this only clears settings — the binary on disk belongs to the user
   * and we don't touch it. Other version dirs are kept either way.
   */
  async uninstall(): Promise<void> {
    const s = readOpencodeSettings();
    if (s.binarySource === "managed" && s.binaryVersion) {
      const versionDir = path.join(this.getDataDir(), s.binaryVersion);
      await removeDir(versionDir).catch((e) =>
        logError(`[AgentMode] failed to remove ${versionDir}`, e)
      );
    }
    clearOpencodeBinary();
  }

  /**
   * Point Agent Mode at a user-supplied opencode binary. Pass `null` to
   * clear the override (without removing any managed install on disk).
   * Performs filesystem checks plus a `--version` smoke test so misconfigured
   * paths are caught at config time rather than later when ACP tries to boot.
   */
  async setCustomBinaryPath(p: string | null): Promise<void> {
    if (p === null) {
      clearOpencodeBinary();
      return;
    }
    const stat = await fs.promises.stat(p).catch(() => null);
    if (!stat || !stat.isFile()) {
      throw new Error(`No file at ${p}`);
    }
    if (process.platform !== "win32") {
      try {
        await fs.promises.access(p, fs.constants.X_OK);
      } catch {
        throw new Error(`${p} is not executable. chmod +x and try again.`);
      }
    }
    const { stdout } = await verifyOpencodeBinary(p);
    const version = parseVersionFromStdout(stdout);
    if (!version) {
      throw new Error(
        `${p} --version output didn't include a version number. Is this an opencode binary?`
      );
    }
    updateOpencodeFields({ binaryVersion: version, binaryPath: p, binarySource: "custom" });
  }

  private async fetchReleaseMetadata(version: string): Promise<GithubRelease> {
    const url = OPENCODE_RELEASE_API_URL_TEMPLATE.replace("{version}", version);
    const res = await requestUrl({
      url,
      method: "GET",
      headers: { Accept: "application/vnd.github+json" },
      throw: false,
    });
    if (res.status === 403) {
      throw new Error(
        "GitHub API rate-limited (60/hour for unauthenticated requests). Set GITHUB_TOKEN or retry later."
      );
    }
    if (res.status === 404) {
      throw new Error(`opencode release v${version} not found on GitHub.`);
    }
    if (res.status < 200 || res.status >= 300) {
      throw new Error(`GitHub release fetch failed with status ${res.status}`);
    }
    return res.json as GithubRelease;
  }

  private throwIfAborted(signal?: AbortSignal): void {
    if (signal?.aborted) throw new AbortError();
  }
}

// Tolerant of leading `v`, build metadata, etc. — keeps the parser working
// across opencode releases that decorate the version string differently.
export function parseVersionFromStdout(stdout: string): string | undefined {
  const match = stdout.match(/\d+\.\d+\.\d+(?:[-+][\w.]+)?/);
  return match?.[0];
}

async function fileExists(p: string): Promise<boolean> {
  try {
    await fs.promises.access(p);
    return true;
  } catch {
    return false;
  }
}

async function readManifest(p: string): Promise<InstallManifest | null> {
  try {
    const raw = await fs.promises.readFile(p, "utf-8");
    return JSON.parse(raw) as InstallManifest;
  } catch {
    return null;
  }
}

async function removeDir(p: string): Promise<void> {
  await fs.promises.rm(p, { recursive: true, force: true });
}

/**
 * Issue a GET against `url`, following up to `maxRedirects` 3xx hops. Resolves
 * with the response stream on a 2xx or rejects on any other terminal status.
 * Honors the supplied AbortSignal at every step.
 */
function httpsGetWithRedirects(
  url: string,
  signal: AbortSignal | undefined,
  maxRedirects = 5
): Promise<IncomingMessage> {
  return new Promise((resolve, reject) => {
    const request = (currentUrl: string, redirectsLeft: number): void => {
      let onAbort: (() => void) | null = null;
      const detachAbort = (): void => {
        if (onAbort) signal?.removeEventListener("abort", onAbort);
        onAbort = null;
      };
      const req = https.get(currentUrl, (res) => {
        const status = res.statusCode ?? 0;
        if (status >= 300 && status < 400 && res.headers.location) {
          detachAbort();
          if (redirectsLeft <= 0) {
            res.resume();
            reject(new Error(`Too many redirects fetching ${url}`));
            return;
          }
          res.resume();
          const next = new URL(res.headers.location, currentUrl).toString();
          request(next, redirectsLeft - 1);
          return;
        }
        if (status !== 200) {
          detachAbort();
          res.resume();
          reject(new Error(`HTTP ${status} fetching ${currentUrl}`));
          return;
        }
        // Hand the response off without detaching: the consumer may still
        // need to abort mid-stream. Cleanup happens on res 'close'/'end'.
        res.on("close", detachAbort);
        resolve(res);
      });
      req.on("error", (e) => {
        detachAbort();
        reject(e);
      });
      if (signal) {
        if (signal.aborted) {
          req.destroy(new AbortError());
        } else {
          onAbort = (): void => {
            req.destroy(new AbortError());
          };
          signal.addEventListener("abort", onAbort, { once: true });
        }
      }
    };
    request(url, maxRedirects);
  });
}

/**
 * Stream a remote asset to `dest`, emitting progress events and aborting if
 * no bytes arrive for `DOWNLOAD_INACTIVITY_TIMEOUT_MS`. Stalled connections
 * surface a clear "download stalled" error instead of hanging forever.
 */
async function downloadToFile(
  url: string,
  dest: string,
  expectedSize: number | undefined,
  opts: InstallOptions
): Promise<void> {
  const assetName = path.basename(dest);
  const res = await httpsGetWithRedirects(url, opts.signal);
  const total =
    expectedSize ??
    (res.headers["content-length"] ? Number(res.headers["content-length"]) : undefined);

  let received = 0;
  let stalled = false;
  let inactivityTimer: number | null = null;
  const out = fs.createWriteStream(dest);
  await new Promise<void>((resolve, reject) => {
    const clearInactivity = (): void => {
      if (inactivityTimer) {
        window.clearTimeout(inactivityTimer);
        inactivityTimer = null;
      }
    };
    const armInactivity = (): void => {
      clearInactivity();
      inactivityTimer = window.setTimeout(() => {
        stalled = true;
        res.destroy(
          new Error(
            `Download stalled — no bytes received for ${Math.round(DOWNLOAD_INACTIVITY_TIMEOUT_MS / 1000)}s. Check your network and retry.`
          )
        );
      }, DOWNLOAD_INACTIVITY_TIMEOUT_MS);
    };
    armInactivity();
    res.on("data", (chunk: Uint8Array) => {
      received += chunk.length;
      armInactivity();
      opts.onProgress?.({ phase: "download", received, total, assetName });
    });
    const fail = (e: Error): void => {
      clearInactivity();
      reject(e);
    };
    res.on("error", fail);
    out.on("error", fail);
    out.on("finish", () => {
      clearInactivity();
      if (stalled) return; // already rejected via res.destroy()
      resolve();
    });
    res.pipe(out);
  });
}

/**
 * Extract `archivePath` into `destDir` by shelling out to the system `tar`
 * (bsdtar on Windows 10 1803+). Distinguishes "tar not found" from
 * non-zero exits so the user gets actionable error text.
 *
 * Path-traversal note: both GNU tar and bsdtar strip leading `/` and refuse
 * to follow `..` outside the extraction root by default, so a malicious
 * archive cannot escape `destDir`. We rely on that default rather than
 * re-implementing extraction in JS.
 */
async function extractArchive(archivePath: string, destDir: string): Promise<void> {
  // Cross-platform: macOS and Linux ship `tar`; Windows 10 1803+ ships `tar.exe`
  // built in (`bsdtar`), which handles .zip / .tar.gz / .tar.xz transparently.
  await new Promise<void>((resolve, reject) => {
    const proc = spawn("tar", ["-xf", archivePath, "-C", destDir], {
      stdio: ["ignore", "pipe", "pipe"],
    });
    let stderr = "";
    proc.stderr.on("data", (d: Uint8Array) => {
      stderr += Buffer.from(d).toString();
    });
    proc.on("error", (e: NodeJS.ErrnoException) => {
      if (e.code === "ENOENT") {
        reject(
          new Error(
            "`tar` was not found on PATH. macOS/Linux ship it by default; on Windows you need 10 1803+ (which ships `tar.exe`/bsdtar) or to install bsdtar manually."
          )
        );
      } else {
        reject(new Error(`Failed to launch tar: ${e instanceof Error ? e.message : String(e)}`));
      }
    });
    proc.on("close", (code) => {
      if (code === 0) resolve();
      else reject(new Error(`tar exited with code ${code}: ${stderr.slice(0, 500)}`));
    });
  });
}

// BFS because the binary's depth inside the upstream archive varies across
// opencode releases — first match wins.
async function locateFile(root: string, name: string): Promise<string> {
  const queue: string[] = [root];
  while (queue.length > 0) {
    const dir = queue.shift() as string;
    const entries = await fs.promises.readdir(dir, { withFileTypes: true });
    for (const e of entries) {
      const full = path.join(dir, e.name);
      if (e.isDirectory()) queue.push(full);
      else if (e.isFile() && e.name === name) return full;
    }
  }
  throw new Error(`File "${name}" not found anywhere under ${root}`);
}

export async function verifyOpencodeBinary(p: string): Promise<{ stdout: string }> {
  try {
    const { stdout } = await execFileAsync(p, ["--version"], {
      timeout: VERIFY_BINARY_TIMEOUT_MS,
      windowsHide: true,
    });
    return { stdout: stdout.toString().trim() };
  } catch (e) {
    const err = e as NodeJS.ErrnoException & { signal?: string; code?: string | number };
    if (err.code === "ENOENT") {
      throw new Error(`No file at ${p}`);
    }
    if (err.signal === "SIGTERM") {
      throw new Error(
        `${p} did not respond to --version within ${VERIFY_BINARY_TIMEOUT_MS}ms. Is this an opencode binary?`
      );
    }
    throw new Error(
      `${p} --version failed: ${err.message ?? String(err)}. Is this an opencode binary?`
    );
  }
}
