/**
 * WorkerBindingService — Worker Binding (WP-05 Shadow + WP-10 separation).
 *
 * - Writes readable binding revisions via WorkerBindingStore
 * - Allows unbound workers (identity without host binding)
 * - WP-10: ensureBinding / replaceBinding / markBindingLost
 * - NEVER calls AgentSdkAdapter.send; Host prepareSession only when
 *   an AgentHostRegistry is explicitly provided (still not production Active)
 * - switchboard_mode stays off|shadow; no Active wiring
 */

import { randomUUID } from "node:crypto";

import type { HostSessionRef } from "../contracts/HostSession.ts";
import type {
  BindingState,
  WorkerBindingRevision,
} from "../contracts/WorkerBinding.ts";
import type { AgentHostRegistry } from "../hosts/AgentHostRegistry.ts";
import { SwitchboardPaths } from "../paths/SwitchboardPaths.ts";
import { WorkerBindingStore } from "../store/WorkerBindingStore.ts";
import type { WorkerIdentityService } from "./WorkerIdentityService.ts";

export type ShadowSwitchboardMode = "off" | "shadow";

/** Sentinel external session id for shadow bindings (not a live SDK session). */
export const SHADOW_EXTERNAL_SESSION_PREFIX = "shadow:" as const;

export function isShadowExternalSessionId(id: string): boolean {
  return String(id ?? "").startsWith(SHADOW_EXTERNAL_SESSION_PREFIX);
}

export function makeShadowExternalSessionId(tag = "recorded"): string {
  const safe = String(tag ?? "recorded").trim() || "recorded";
  return `${SHADOW_EXTERNAL_SESSION_PREFIX}${safe}:${randomUUID()}`;
}

export interface WorkerBindingServiceOptions {
  paths: SwitchboardPaths;
  /** Defaults to "shadow". Active is intentionally unsupported here. */
  mode?: ShadowSwitchboardMode;
  createdBy?: string;
  store?: WorkerBindingStore;
  /** Optional — when set, ensureBinding may call prepareSession on that host. */
  hostRegistry?: AgentHostRegistry;
  /** Optional — when set, ensureBinding requires prior registerIdentity. */
  identities?: WorkerIdentityService;
}

export interface ShadowBindInput {
  workerId: string;
  hostId: string;
  /** Optional; defaults to a shadow:* sentinel (not a live SDK session). */
  externalSessionId?: string;
  workspaceId?: string;
  modelHint?: string;
  reason?: string;
  createdBy?: string;
  hostMode?: HostSessionRef["mode"];
}

export interface ShadowBindResult {
  revision: WorkerBindingRevision;
  path: string;
  /** Explicit proof this path never invoked sdk.send. */
  openedHostSession: false;
  invokedSdkSend: false;
  mode: ShadowSwitchboardMode;
}

export type EnsureBindingResult =
  | {
      status: "bound" | "reused" | "replaced";
      revision: WorkerBindingRevision;
      path: string;
      openedHostSession: boolean;
      invokedSdkSend: false;
      mode: ShadowSwitchboardMode;
    }
  | {
      status: "binding_failed";
      reason: string;
      /** Identity must remain queryable after host failure. */
      identityPreserved: true;
      revision?: WorkerBindingRevision;
      path?: string;
      openedHostSession: false;
      invokedSdkSend: false;
    };

const BOUND_STATES: ReadonlySet<BindingState> = new Set(["ready", "recovering"]);

/**
 * Binding orchestration. Does not accept AgentSdkAdapter.
 * Optional AgentHostRegistry is only for prepareSession in tests / future Gate B.
 */
export class WorkerBindingService {
  readonly #paths: SwitchboardPaths;
  readonly #store: WorkerBindingStore;
  readonly #mode: ShadowSwitchboardMode;
  readonly #createdBy: string;
  readonly #hostRegistry: AgentHostRegistry | undefined;
  readonly #identities: WorkerIdentityService | undefined;
  /** Diagnostic counter — must remain 0 (tests assert). */
  #sdkSendAttempts = 0;
  #prepareSessionAttempts = 0;

  constructor(opts: WorkerBindingServiceOptions) {
    this.#paths = opts.paths;
    this.#store = opts.store ?? new WorkerBindingStore(opts.paths);
    this.#mode = opts.mode ?? "shadow";
    this.#createdBy = opts.createdBy?.trim() || "WorkerBindingService";
    this.#hostRegistry = opts.hostRegistry;
    this.#identities = opts.identities;
    if (this.#mode !== "off" && this.#mode !== "shadow") {
      throw new Error(
        `WorkerBindingService: unsupported mode "${String(this.#mode)}" (Active forbidden)`,
      );
    }
  }

  get mode(): ShadowSwitchboardMode {
    return this.#mode;
  }

  get paths(): SwitchboardPaths {
    return this.#paths;
  }

  get store(): WorkerBindingStore {
    return this.#store;
  }

  /** Always 0 — service has no SDK send path. */
  get sdkSendAttempts(): number {
    return this.#sdkSendAttempts;
  }

  get prepareSessionAttempts(): number {
    return this.#prepareSessionAttempts;
  }

  async latest(workerId: string): Promise<WorkerBindingRevision | null> {
    return this.#store.latest(workerId);
  }

  async list(workerId: string): Promise<WorkerBindingRevision[]> {
    return this.#store.list(workerId);
  }

  /** WP-10 — active (ready/recovering) binding or null if unbound. */
  async getActiveBinding(
    workerId: string,
  ): Promise<WorkerBindingRevision | null> {
    const latest = await this.#store.latest(workerId);
    if (!latest || !BOUND_STATES.has(latest.state)) return null;
    return latest;
  }

  /**
   * Unbound = no revision, or latest state is closed/lost.
   * Workers may exist with identity only (WP-05/10 allows unbound).
   */
  async isBound(workerId: string): Promise<boolean> {
    return (await this.getActiveBinding(workerId)) !== null;
  }

  async isUnbound(workerId: string): Promise<boolean> {
    return !(await this.isBound(workerId));
  }

  /**
   * Append a shadow binding revision (readable Markdown under bindings/).
   * Does NOT call prepareSession / send.
   */
  async shadowBind(input: ShadowBindInput): Promise<ShadowBindResult> {
    if (this.#mode === "off") {
      throw new Error(
        "WorkerBindingService.shadowBind: mode=off (enable shadow to record bindings)",
      );
    }
    const workerId = String(input.workerId ?? "").trim();
    const hostId = String(input.hostId ?? "").trim();
    if (!workerId) throw new Error("shadowBind: workerId required");
    if (!hostId) throw new Error("shadowBind: hostId required");

    const prev = await this.#store.latest(workerId);
    const revisionNum = (prev?.revision ?? 0) + 1;
    const externalSessionId =
      input.externalSessionId?.trim() ||
      makeShadowExternalSessionId("bind");

    const host: HostSessionRef = {
      hostId,
      externalSessionId,
      mode: input.hostMode ?? "local",
      metadata: {
        shadow: "true",
        switchboard_mode: this.#mode,
        opens_host_session: "false",
      },
    };

    const revision: WorkerBindingRevision = {
      schema: "codeflowmu.worker-binding/v1",
      bindingId: prev?.bindingId || `bind-${workerId}-${randomUUID().slice(0, 8)}`,
      revision: revisionNum,
      workerId,
      host,
      state: "ready",
      reason: input.reason ?? "shadow-bind",
      createdAt: new Date().toISOString(),
      createdBy: input.createdBy?.trim() || this.#createdBy,
      ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
      ...(input.modelHint ? { modelHint: input.modelHint } : {}),
      ...(prev
        ? {
            previousRevisionRef: `${prev.bindingId}@${prev.revision}`,
          }
        : {}),
    };

    const path = await this.#store.append(revision);
    return {
      revision,
      path,
      openedHostSession: false,
      invokedSdkSend: false,
      mode: this.#mode,
    };
  }

  /**
   * WP-10 — ensure worker is bound to hostId.
   * Requires prior identity when identities service is wired.
   * Only prepares Host Session when hostRegistry is provided.
   */
  async ensureBinding(
    workerId: string,
    hostId: string,
    opts: {
      reason?: string;
      externalSessionId?: string;
      workspaceId?: string;
      modelHint?: string;
      roleId?: string;
      layer?: "leader" | "worker" | "governance" | "observer" | "admin";
    } = {},
  ): Promise<EnsureBindingResult> {
    const id = String(workerId ?? "").trim();
    const hid = String(hostId ?? "").trim();
    if (!id) throw new Error("ensureBinding: workerId required");
    if (!hid) throw new Error("ensureBinding: hostId required");
    if (this.#mode === "off") {
      return {
        status: "binding_failed",
        reason: "mode=off",
        identityPreserved: true,
        openedHostSession: false,
        invokedSdkSend: false,
      };
    }

    if (this.#identities) {
      const identity = await this.#identities.getIdentity(id);
      if (!identity) {
        return {
          status: "binding_failed",
          reason: `worker identity not registered: ${id}`,
          identityPreserved: true,
          openedHostSession: false,
          invokedSdkSend: false,
        };
      }
    }

    const active = await this.getActiveBinding(id);
    if (active && active.host.hostId === hid) {
      return {
        status: "reused",
        revision: active,
        path: "",
        openedHostSession: false,
        invokedSdkSend: false,
        mode: this.#mode,
      };
    }
    if (active && active.host.hostId !== hid) {
      return this.replaceBinding(
        id,
        hid,
        opts.reason ?? "ensureBinding-replace",
        opts,
      );
    }

    let externalSessionId =
      opts.externalSessionId?.trim() || makeShadowExternalSessionId("ensure");
    let openedHostSession = false;

    if (this.#hostRegistry?.has(hid)) {
      try {
        const adapter = this.#hostRegistry.get(hid);
        this.#prepareSessionAttempts += 1;
        const prepared = await adapter.prepareSession({
          worker: {
            workerId: id,
            roleId: opts.roleId?.trim() || id.split("-")[0] || "DEV",
            layer: opts.layer ?? "worker",
          },
        });
        externalSessionId = prepared.reference.externalSessionId;
        openedHostSession = true;
      } catch (err) {
        const failed = await this.#appendState(id, hid, "lost", {
          reason: `ensureBinding host failure: ${
            err instanceof Error ? err.message : String(err)
          }`,
          externalSessionId,
          workspaceId: opts.workspaceId,
          modelHint: opts.modelHint,
        });
        return {
          status: "binding_failed",
          reason: err instanceof Error ? err.message : String(err),
          identityPreserved: true,
          revision: failed.revision,
          path: failed.path,
          openedHostSession: false,
          invokedSdkSend: false,
        };
      }
    }

    const bound = await this.shadowBind({
      workerId: id,
      hostId: hid,
      externalSessionId,
      workspaceId: opts.workspaceId,
      modelHint: opts.modelHint,
      reason: opts.reason ?? "ensureBinding",
    });
    return {
      status: "bound",
      revision: bound.revision,
      path: bound.path,
      openedHostSession,
      invokedSdkSend: false,
      mode: this.#mode,
    };
  }

  /** WP-10 — revise binding to a different host. */
  async replaceBinding(
    workerId: string,
    nextHostId: string,
    reason = "replaceBinding",
    opts: {
      externalSessionId?: string;
      workspaceId?: string;
      modelHint?: string;
      roleId?: string;
      layer?: "leader" | "worker" | "governance" | "observer" | "admin";
    } = {},
  ): Promise<EnsureBindingResult> {
    const id = String(workerId ?? "").trim();
    const hid = String(nextHostId ?? "").trim();
    if (!id || !hid) {
      return {
        status: "binding_failed",
        reason: "replaceBinding requires workerId and nextHostId",
        identityPreserved: true,
        openedHostSession: false,
        invokedSdkSend: false,
      };
    }
    await this.unbind(id, `replace→${hid}:${reason}`).catch(() => undefined);

    // Direct bind path after unbind (avoid recursive ensure→replace).
    let externalSessionId =
      opts.externalSessionId?.trim() || makeShadowExternalSessionId("replace");
    let openedHostSession = false;
    if (this.#hostRegistry?.has(hid)) {
      try {
        const adapter = this.#hostRegistry.get(hid);
        this.#prepareSessionAttempts += 1;
        const prepared = await adapter.prepareSession({
          worker: {
            workerId: id,
            roleId: opts.roleId?.trim() || id.split("-")[0] || "DEV",
            layer: opts.layer ?? "worker",
          },
        });
        externalSessionId = prepared.reference.externalSessionId;
        openedHostSession = true;
      } catch (err) {
        const failed = await this.#appendState(id, hid, "lost", {
          reason: `replaceBinding host failure: ${
            err instanceof Error ? err.message : String(err)
          }`,
          externalSessionId,
          workspaceId: opts.workspaceId,
          modelHint: opts.modelHint,
        });
        return {
          status: "binding_failed",
          reason: err instanceof Error ? err.message : String(err),
          identityPreserved: true,
          revision: failed.revision,
          path: failed.path,
          openedHostSession: false,
          invokedSdkSend: false,
        };
      }
    }

    const bound = await this.shadowBind({
      workerId: id,
      hostId: hid,
      externalSessionId,
      workspaceId: opts.workspaceId,
      modelHint: opts.modelHint,
      reason,
    });
    return {
      status: "replaced",
      revision: bound.revision,
      path: bound.path,
      openedHostSession,
      invokedSdkSend: false,
      mode: this.#mode,
    };
  }

  /** WP-10 — mark binding lost without deleting worker identity. */
  async markBindingLost(
    workerId: string,
    reason = "markBindingLost",
  ): Promise<ShadowBindResult | { alreadyUnbound: true; workerId: string }> {
    const id = String(workerId ?? "").trim();
    if (!id) throw new Error("markBindingLost: workerId required");
    const prev = await this.#store.latest(id);
    if (!prev || prev.state === "lost" || prev.state === "closed") {
      return { alreadyUnbound: true, workerId: id };
    }
    if (this.#mode === "off") {
      throw new Error("WorkerBindingService.markBindingLost: mode=off");
    }
    return this.#appendState(id, prev.host.hostId, "lost", {
      reason,
      externalSessionId: prev.host.externalSessionId,
      workspaceId: prev.workspaceId,
      modelHint: prev.modelHint,
      bindingId: prev.bindingId,
      previousRevisionRef: `${prev.bindingId}@${prev.revision}`,
    });
  }

  /**
   * Mark worker unbound (append closed revision) or no-op if already unbound.
   */
  async unbind(
    workerId: string,
    reason = "shadow-unbind",
  ): Promise<ShadowBindResult | { alreadyUnbound: true; workerId: string }> {
    const id = String(workerId ?? "").trim();
    if (!id) throw new Error("unbind: workerId required");
    const prev = await this.#store.latest(id);
    if (!prev || !BOUND_STATES.has(prev.state)) {
      return { alreadyUnbound: true, workerId: id };
    }
    if (this.#mode === "off") {
      throw new Error("WorkerBindingService.unbind: mode=off");
    }

    return this.#appendState(id, prev.host.hostId, "closed", {
      reason,
      externalSessionId: prev.host.externalSessionId,
      workspaceId: prev.workspaceId,
      modelHint: prev.modelHint,
      bindingId: prev.bindingId,
      previousRevisionRef: `${prev.bindingId}@${prev.revision}`,
    });
  }

  /**
   * Intentionally NOT implemented for Shadow / WP-10 default:
   * opening a Host Session without going through ensureBinding+registry.
   */
  openHostSession(): never {
    throw new Error(
      "WorkerBindingService.openHostSession: forbidden in Shadow WP-05 — " +
        "agent registration / worker binding is separated from Host Session open; " +
        "use ensureBinding with hostRegistry (WP-10), or AgentHostAdapter.prepareSession",
    );
  }

  async #appendState(
    workerId: string,
    hostId: string,
    state: BindingState,
    opts: {
      reason: string;
      externalSessionId: string;
      workspaceId?: string;
      modelHint?: string;
      bindingId?: string;
      previousRevisionRef?: string;
    },
  ): Promise<ShadowBindResult> {
    const prev = await this.#store.latest(workerId);
    const revision: WorkerBindingRevision = {
      schema: "codeflowmu.worker-binding/v1",
      bindingId:
        opts.bindingId ||
        prev?.bindingId ||
        `bind-${workerId}-${randomUUID().slice(0, 8)}`,
      revision: (prev?.revision ?? 0) + 1,
      workerId,
      host: {
        hostId,
        externalSessionId: opts.externalSessionId,
        mode: "local",
        metadata: {
          shadow: "true",
          switchboard_mode: this.#mode,
          opens_host_session: "false",
        },
      },
      state,
      reason: opts.reason,
      createdAt: new Date().toISOString(),
      createdBy: this.#createdBy,
      ...(opts.previousRevisionRef
        ? { previousRevisionRef: opts.previousRevisionRef }
        : prev
          ? { previousRevisionRef: `${prev.bindingId}@${prev.revision}` }
          : {}),
      ...(opts.workspaceId ? { workspaceId: opts.workspaceId } : {}),
      ...(opts.modelHint ? { modelHint: opts.modelHint } : {}),
    };
    const path = await this.#store.append(revision);
    return {
      revision,
      path,
      openedHostSession: false,
      invokedSdkSend: false,
      mode: this.#mode,
    };
  }
}
