import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import type { Availability } from "./types.js";

const execFileAsync = promisify(execFile);
const MAX_ADB_OUTPUT_BYTES = 16 * 1024;
const MAX_ADB_LIST_OUTPUT_BYTES = 512 * 1024;
const CAR_PROBE_LOCAL_PATH = fileURLToPath(new URL("../assets/car-telemetry-probe.dex", import.meta.url));
const CAR_PROBE_REMOTE_PATH = "/data/local/tmp/pi-car-telemetry-probe.dex";
const carProbeReady = new WeakMap<AdbExecutor, Set<string>>();
export const MAX_APK_BYTES = 256 * 1024 * 1024;

export type AdbConfig = {
  enabled: boolean;
  executable: string;
  serial: string | null;
  timeoutMs: number;
};

export const DISABLED_ADB_CONFIG: AdbConfig = {
  enabled: false,
  executable: "adb",
  serial: null,
  timeoutMs: 3_000
};

type ExecuteOptions = { timeout: number; maxBuffer: number };
export type AdbExecutor = (
  executable: string,
  arguments_: readonly string[],
  options: ExecuteOptions
) => Promise<{ stdout: string; stderr: string }>;

export type AdbConnectionState = "disabled" | "unavailable" | "offline" | "unauthorized" | "ambiguous";

export type AdbDeviceSnapshot = {
  state: "connected";
  identity: {
    manufacturer: Availability<string>;
    model: Availability<string>;
    androidVersion: Availability<string>;
    apiLevel: Availability<number>;
    buildId: Availability<string>;
    abi: Availability<string>;
  };
  display: {
    size: Availability<{ widthPixels: number; heightPixels: number }>;
    densityDpi: Availability<number>;
  };
};

export type AdbStatus =
  | { available: true; value: AdbDeviceSnapshot }
  | { available: false; state: AdbConnectionState; reason: string };

export type InstalledAdbPackage = {
  packageName: string;
  apkPath: string;
  system: boolean;
};

export type AdbShortcutTarget =
  | { type: "component"; value: string }
  | { type: "package"; value: string };

export type CarTelemetry = {
  available: true;
  collectedAt: string;
  speedKph: Availability<number>;
  gear: Availability<string>;
  wheelAngleDegrees: Availability<number>;
  batteryPercent: Availability<number>;
  rangeKm: Availability<number>;
  batteryVoltage: Availability<number>;
  totalConsumptionKwh: Availability<number>;
  chargingStatus: Availability<number>;
};

const defaultExecutor: AdbExecutor = async (executable, arguments_, options) => {
  const result = await execFileAsync(executable, [...arguments_], {
    timeout: options.timeout,
    maxBuffer: options.maxBuffer,
    encoding: "utf8",
    windowsHide: true
  });
  return { stdout: result.stdout, stderr: result.stderr };
};

function unavailable<T>(reason: string): Availability<T> {
  return { available: false, reason };
}

function errorText(error: unknown): string {
  if (!error || typeof error !== "object") return "";
  const candidate = error as { message?: unknown; stderr?: unknown; code?: unknown; killed?: unknown };
  return [candidate.message, candidate.stderr, candidate.code, candidate.killed ? "timed out" : ""]
    .filter((value): value is string => typeof value === "string")
    .join(" ")
    .toLowerCase();
}

function connectionFailure(error: unknown): Exclude<AdbStatus, { available: true }> {
  const text = errorText(error);
  if (text.includes("unauthorized")) {
    return { available: false, state: "unauthorized", reason: "Authorize this Pi on the infotainment screen" };
  }
  if (text.includes("offline") || text.includes("no devices") || text.includes("device not found")) {
    return { available: false, state: "offline", reason: "The configured infotainment device is offline" };
  }
  if (text.includes("enoent") || text.includes("not found")) {
    return { available: false, state: "unavailable", reason: "The configured ADB executable is unavailable" };
  }
  if (text.includes("timed out") || text.includes("etimedout")) {
    return { available: false, state: "offline", reason: "The configured infotainment device did not respond in time" };
  }
  return { available: false, state: "unavailable", reason: "ADB device state could not be collected" };
}

async function resolveSerial(config: AdbConfig, execute: AdbExecutor): Promise<string> {
  if (!config.enabled) throw new Error("ADB_DISABLED");
  if (config.serial) return config.serial;
  const output = (await execute(config.executable, ["devices"], {
    timeout: config.timeoutMs,
    maxBuffer: MAX_ADB_OUTPUT_BYTES
  })).stdout;
  const devices = output
    .split(/\r?\n/)
    .slice(1)
    .map((line) => line.trim().split(/\s+/, 3))
    .filter((parts) => parts[0] && parts[1]);
  if (devices.length !== 1 || devices[0]?.[1] !== "device") throw new Error("ADB_DEVICE_UNAVAILABLE");
  return devices[0][0]!;
}

async function executeForDevice(
  config: AdbConfig,
  arguments_: readonly string[],
  execute: AdbExecutor,
  maxBuffer = MAX_ADB_OUTPUT_BYTES,
  timeout = config.timeoutMs
): Promise<{ stdout: string; stderr: string }> {
  const serial = await resolveSerial(config, execute);
  return execute(config.executable, ["-s", serial, ...arguments_], { timeout, maxBuffer });
}

function validatePackageName(value: string): boolean {
  return /^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*$/.test(value);
}

function validateComponent(value: string): boolean {
  const [packageName, activity, extra] = value.split("/");
  return !extra && Boolean(packageName && activity) && validatePackageName(packageName!) &&
    /^(?:\.|[a-zA-Z])[a-zA-Z0-9_.$]*$/.test(activity!);
}

export async function listInstalledAdbPackages(
  config: AdbConfig,
  execute: AdbExecutor = defaultExecutor
): Promise<InstalledAdbPackage[]> {
  const output = (await executeForDevice(
    config,
    ["shell", "pm", "list", "packages", "-f"],
    execute,
    MAX_ADB_LIST_OUTPUT_BYTES
  )).stdout;
  return output
    .split(/\r?\n/)
    .map((line) => /^package:(.+)=([a-zA-Z0-9_.]+)$/.exec(line.trim()))
    .filter((match): match is RegExpExecArray => Boolean(match))
    .map((match) => ({
      apkPath: match[1]!,
      packageName: match[2]!,
      system: match[1]!.startsWith("/system/") || match[1]!.startsWith("/vendor/") || match[1]!.startsWith("/product/")
    }))
    .sort((left, right) => left.packageName.localeCompare(right.packageName));
}

export async function launchAdbTarget(
  config: AdbConfig,
  target: AdbShortcutTarget,
  execute: AdbExecutor = defaultExecutor
): Promise<void> {
  if (target.type === "component") {
    if (!validateComponent(target.value)) throw new Error("INVALID_ADB_COMPONENT");
    const result = await executeForDevice(config, ["shell", "am", "start", "-n", target.value], execute);
    if (/\b(?:error|exception|does not exist)\b/i.test(`${result.stdout}\n${result.stderr}`)) {
      throw new Error("ADB_LAUNCH_FAILED");
    }
    return;
  }
  if (!validatePackageName(target.value)) throw new Error("INVALID_ADB_PACKAGE");
  const result = await executeForDevice(
    config,
    ["shell", "monkey", "-p", target.value, "-c", "android.intent.category.LAUNCHER", "1"],
    execute
  );
  if (/no activities found|monkey aborted|\berror\b/i.test(`${result.stdout}\n${result.stderr}`)) {
    throw new Error("ADB_LAUNCH_FAILED");
  }
}

export async function installAdbApk(
  config: AdbConfig,
  apk: Buffer,
  execute: AdbExecutor = defaultExecutor
): Promise<void> {
  if (apk.byteLength < 4 || apk.byteLength > MAX_APK_BYTES || apk.readUInt32BE(0) !== 0x504b0304) {
    throw new Error("INVALID_APK");
  }
  const directory = await mkdtemp(join(tmpdir(), "pi-car-apk-"));
  const apkPath = join(directory, "upload.apk");
  try {
    await writeFile(apkPath, apk, { mode: 0o600 });
    const serial = await resolveSerial(config, execute);
    const result = await execute(config.executable, ["-s", serial, "install", "-r", apkPath], {
      timeout: Math.max(config.timeoutMs, 120_000),
      maxBuffer: MAX_ADB_OUTPUT_BYTES
    });
    if (!/\bSuccess\b/i.test(result.stdout) || /\bFailure\b/i.test(`${result.stdout}\n${result.stderr}`)) {
      throw new Error("APK_INSTALL_FAILED");
    }
  } finally {
    await rm(directory, { recursive: true, force: true });
  }
}

function parseGetprop(output: string): Map<string, string> {
  const properties = new Map<string, string>();
  for (const line of output.split(/\r?\n/)) {
    const match = /^\[([^\]]+)\]: \[(.*)\]$/.exec(line.trim());
    if (match) properties.set(match[1]!, match[2]!);
  }
  return properties;
}

function numericProperty(properties: Map<string, string>, key: string, label: string): Availability<number> {
  const raw = properties.get(key);
  const value = raw === undefined || raw === "" ? Number.NaN : Number(raw);
  return Number.isFinite(value) ? { available: true, value } : unavailable(`${label} is not exposed by the head unit`);
}

function parseProbeOutput(output: string): Map<string, string> | null {
  const line = output.split(/\r?\n/).find((candidate) => candidate.trim().startsWith("{"));
  if (!line) return null;
  try {
    const parsed = JSON.parse(line) as Record<string, unknown>;
    if (parsed.status !== "ok") return null;
    return new Map(Object.entries(parsed).map(([key, value]) => [key, String(value)]));
  } catch {
    return null;
  }
}

function bundleNumber(
  values: Map<string, string> | null,
  key: string,
  label: string,
  minimum: number,
  maximum: number
): Availability<number> {
  const value = Number(values?.get(key));
  return Number.isFinite(value) && value >= minimum && value <= maximum
    ? { available: true, value }
    : unavailable(`${label} is unavailable from the factory vehicle service over ADB`);
}

async function sampleVehicleService(config: AdbConfig, execute: AdbExecutor): Promise<Map<string, string> | null> {
  const serial = await resolveSerial(config, execute);
  let readySerials = carProbeReady.get(execute);
  if (!readySerials) {
    readySerials = new Set<string>();
    carProbeReady.set(execute, readySerials);
  }
  if (!readySerials.has(serial)) {
    await execute(config.executable, ["-s", serial, "push", CAR_PROBE_LOCAL_PATH, CAR_PROBE_REMOTE_PATH], {
      timeout: Math.max(config.timeoutMs, 10_000),
      maxBuffer: MAX_ADB_OUTPUT_BYTES
    });
    readySerials.add(serial);
  }

  try {
    const result = await execute(
      config.executable,
      ["-s", serial, "shell", `CLASSPATH=${CAR_PROBE_REMOTE_PATH}`, "app_process", "/system/bin", "cloud.molberg.picarprobe.CarTelemetryProbe"],
      { timeout: Math.max(config.timeoutMs, 7_000), maxBuffer: MAX_ADB_OUTPUT_BYTES }
    );
    return parseProbeOutput(result.stdout);
  } catch (error) {
    const text = errorText(error);
    if (text.includes("classnotfound") || text.includes("class not found") || text.includes("no such file") || text.includes("dex")) {
      readySerials.delete(serial);
    }
    throw error;
  }
}

export async function collectCarTelemetry(
  config: AdbConfig,
  execute: AdbExecutor = defaultExecutor
): Promise<CarTelemetry> {
  const [propertiesResult, vehicleValues] = await Promise.all([
    executeForDevice(config, ["shell", "getprop"], execute, MAX_ADB_LIST_OUTPUT_BYTES),
    sampleVehicleService(config, execute).catch(() => null)
  ]);
  const output = propertiesResult.stdout;
  const properties = parseGetprop(output);
  const rawGear = properties.get("arcsoft.avm.mCurCarGear");
  const serviceSpeed = bundleNumber(vehicleValues, "speedKph", "Vehicle speed", 0, 400);
  return {
    available: true,
    collectedAt: new Date().toISOString(),
    speedKph: serviceSpeed.available ? serviceSpeed : numericProperty(properties, "arcsoft.avm.mCurCarSpeed", "Vehicle speed"),
    gear: rawGear ? { available: true, value: rawGear } : unavailable("Gear is not exposed by the head unit"),
    wheelAngleDegrees: numericProperty(properties, "arcsoft.avm.mCurCarWheelAngle", "Wheel angle"),
    batteryPercent: bundleNumber(vehicleValues, "soc", "Traction battery", 0, 100),
    rangeKm: bundleNumber(vehicleValues, "rangeKm", "Estimated range", 0, 1_500),
    batteryVoltage: bundleNumber(vehicleValues, "batteryVolts", "Battery voltage", 0, 1_000),
    totalConsumptionKwh: bundleNumber(vehicleValues, "totalConsumptionKwh", "Consumption", 0, 10_000),
    chargingStatus: bundleNumber(vehicleValues, "chargingStatus", "Charging status", -1, 100)
  };
}

function parseSize(value: string): Availability<{ widthPixels: number; heightPixels: number }> {
  const matches = [...value.matchAll(/(?:physical|override) size:\s*(\d+)x(\d+)/gi)];
  const match = matches.at(-1);
  const widthPixels = Number(match?.[1]);
  const heightPixels = Number(match?.[2]);
  return widthPixels > 0 && heightPixels > 0
    ? { available: true, value: { widthPixels, heightPixels } }
    : unavailable("ADB returned an unrecognized display size");
}

function parseDensity(value: string): Availability<number> {
  const matches = [...value.matchAll(/(?:physical|override) density:\s*(\d+)/gi)];
  const density = Number(matches.at(-1)?.[1]);
  return density > 0 ? { available: true, value: density } : unavailable("ADB returned an unrecognized display density");
}

export async function collectAdbStatus(
  config: AdbConfig,
  execute: AdbExecutor = defaultExecutor
): Promise<AdbStatus> {
  if (!config.enabled) {
    return { available: false, state: "disabled", reason: "ADB integration is not configured" };
  }

  const options = { timeout: config.timeoutMs, maxBuffer: MAX_ADB_OUTPUT_BYTES };
  let serial = config.serial;
  if (!serial) {
    try {
      const output = (await execute(config.executable, ["devices"], options)).stdout;
      const devices = output
        .split(/\r?\n/)
        .slice(1)
        .map((line) => line.trim().split(/\s+/, 3))
        .filter((parts): parts is [string, string, ...string[]] => Boolean(parts[0] && parts[1]));
      if (devices.length === 0) {
        return { available: false, state: "offline", reason: "No ADB infotainment device is connected" };
      }
      if (devices.length > 1) {
        return {
          available: false,
          state: "ambiguous",
          reason: "Multiple ADB devices are connected; configure ADB_SERIAL to select the infotainment unit"
        };
      }
      const [discoveredSerial, discoveredState] = devices[0]!;
      if (discoveredState === "unauthorized") {
        return { available: false, state: "unauthorized", reason: "Authorize this Pi on the infotainment screen" };
      }
      if (discoveredState !== "device") {
        return { available: false, state: "offline", reason: "The discovered infotainment device is offline" };
      }
      serial = discoveredSerial;
    } catch (error) {
      return connectionFailure(error);
    }
  }

  const run = async (arguments_: readonly string[]) =>
    execute(config.executable, ["-s", serial, ...arguments_], options);

  try {
    const state = (await run(["get-state"])).stdout.trim().toLowerCase();
    if (state !== "device") {
      return state.includes("unauthorized")
        ? { available: false, state: "unauthorized", reason: "Authorize this Pi on the infotainment screen" }
        : { available: false, state: "offline", reason: "The configured infotainment device is offline" };
    }
  } catch (error) {
    return connectionFailure(error);
  }

  const read = async (arguments_: readonly string[], label: string): Promise<Availability<string>> => {
    try {
      const value = (await run(arguments_)).stdout.trim();
      return value ? { available: true, value } : unavailable(`ADB returned no ${label}`);
    } catch {
      return unavailable(`ADB could not read ${label}`);
    }
  };

  const [manufacturer, model, androidVersion, apiLevelRaw, buildId, abi, sizeRaw, densityRaw] = await Promise.all([
    read(["shell", "getprop", "ro.product.manufacturer"], "manufacturer"),
    read(["shell", "getprop", "ro.product.model"], "model"),
    read(["shell", "getprop", "ro.build.version.release"], "Android version"),
    read(["shell", "getprop", "ro.build.version.sdk"], "API level"),
    read(["shell", "getprop", "ro.build.display.id"], "build ID"),
    read(["shell", "getprop", "ro.product.cpu.abi"], "CPU ABI"),
    read(["shell", "wm", "size"], "display size"),
    read(["shell", "wm", "density"], "display density")
  ]);
  const apiLevel = apiLevelRaw.available && /^\d+$/.test(apiLevelRaw.value)
    ? { available: true as const, value: Number(apiLevelRaw.value) }
    : unavailable<number>(apiLevelRaw.available ? "ADB returned an invalid API level" : apiLevelRaw.reason);

  return {
    available: true,
    value: {
      state: "connected",
      identity: { manufacturer, model, androidVersion, apiLevel, buildId, abi },
      display: {
        size: sizeRaw.available ? parseSize(sizeRaw.value) : unavailable(sizeRaw.reason),
        densityDpi: densityRaw.available ? parseDensity(densityRaw.value) : unavailable(densityRaw.reason)
      }
    }
  };
}
