import { join } from "node:path";
import type { DecisionSource } from "#src/authority/decision-source";
import {
  type ForwarderContext,
  getSessionId,
} from "#src/authority/forwarder-context";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import {
  type ForwardedAccessFacts,
  type ForwardedAccessIntent,
  type ForwardedPermissionRequest,
  type ForwardedPermissionResponse,
  isForwardedPermissionRequestForSession,
  type PermissionForwardingLocation,
} from "#src/authority/permission-forwarding";
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
import { SessionApproval } from "#src/session-approval";
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
import type { DebugReviewLogger } from "#src/session-logger";
import type { PermissionCheckResult } from "#src/types";
import type { AskEscalator } from "./authorizer-selection";
import {
  cleanupPermissionForwardingLocationIfEmpty,
  ensureDirectoryExists,
  formatUnknownErrorMessage,
  getExistingPermissionForwardingLocation,
  listRequestFiles,
  logPermissionForwardingError,
  logPermissionForwardingWarning,
  readForwardedPermissionRequest,
  safeDeleteFile,
  writeJsonFileAtomic,
} from "./forwarding-io";
import type { PromptPermissionDetails } from "./permission-prompter";

/**
 * Narrow seam describing what `ForwardingManager` needs from the server: a
 * single method that drains this session's forwarded-permission inbox.
 *
 * Depending on the interface (not the concrete `ForwardedRequestServer`)
 * keeps the manager's unit tests free of casts — they inject a plain
 * `{ processInbox: vi.fn() }` mock.
 */
export interface InboxProcessor {
  processInbox(ctx: ForwarderContext): Promise<void>;
}

/**
 * Recorded-authority view the serving node resolves a forwarded request
 * against: answer one {@link ForwardedAccessIntent} query on the serving
 * session's composed ruleset, agent-scoped to the requester
 * (`principal.agentName`, ADR 0008 §3) — the child-fixed `matchValues` are
 * used as-is, never re-derived through this session's `PathNormalizer`/cwd.
 *
 * Narrow by design (ISP): the server needs one decision, not the whole
 * resolver. The composition root satisfies it with
 * `buildResolvedIntentFromMatchValues` plus `resolver.resolve`, the same
 * `resolve` entry point `LocalPermissionsService` composes.
 */
export interface ServingPolicy {
  resolve(intent: ForwardedAccessIntent): PermissionCheckResult;
}

/** Constructor config for `ForwardedRequestServer`. */
export interface ForwardedRequestServerDeps {
  forwardingDir: string;
  logger: DebugReviewLogger;
  /** Recorded-authority resolution for a forwarded `ForwardedAccessIntent`. */
  policy: ServingPolicy;
  /** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
  escalator: AskEscalator;
  /**
   * The serving session's `SessionRules`. Records a whole-session grant when a
   * human approves a forwarded request for the entire serving session.
   */
  recorder: SessionApprovalRecorder;
  /** In-process subagent registry, read only by the one-hop canary. */
  registry?: SubagentSessionRegistry;
}

// ── Module-private helpers ────────────────────────────────────────────────

/**
 * Map a forwarded request onto the escalated ask's details, carrying the
 * forwarded provenance (requester agent/session + the child's original display
 * projection) so `LocalUserAuthorizer` emits a non-degraded broadcast (#292),
 * plus the child-fixed access facts so the serving node's `Authorizer` chain
 * judges a forwarded ask on the same evidence as a local one (ADR 0008; #635).
 *
 * The display `surface` and the fact `surface` are distinct and both belong
 * here: the former is the child's tool name (what the UI shows), the latter the
 * gate surface the rule fired on (what the bounded-delegation checkpoint
 * excludes on).
 */
function buildForwardedAskDetails(
  request: ForwardedPermissionRequest,
): PromptPermissionDetails {
  const payload = buildForwardedAskPayload(request);
  return {
    requestId: request.id,
    source: request.source ?? "tool_call",
    agentName: request.requesterAgentName || null,
    payload,
    surface: request.surface ?? null,
    value: request.value ?? null,
    forwarding: {
      requesterAgentName: request.requesterAgentName || null,
      requesterSessionId: request.requesterSessionId || null,
    },
    // Carries the child's suggestion so LocalUserAuthorizer can offer the
    // whole-session grant scope; absent for a legacy/version-skew request.
    ...(request.sessionApproval
      ? { sessionApproval: request.sessionApproval }
      : {}),
    // Absent for a version-skew request that carried no intent — which the
    // delegation envelope reads as "surface undetermined" and fail-safes to
    // excluded, so absence must stay absence rather than become `undefined`.
    ...(request.accessIntent
      ? { accessIntent: toAccessFacts(request.accessIntent) }
      : {}),
  };
}

/**
 * Project the wire intent down to the child-fixed access facts an `Authorizer`
 * may see.
 *
 * Field-by-field rather than a spread, because this is a disclosure boundary:
 * `requesterCwd` and `principal` are requester identity for the serving node's
 * own resolution (ADR 0008 §3) and stay off the ask details. A link that needs
 * requester identity reads `details.forwarding`. `ForwardedAccessIntent`
 * extends `ForwardedAccessFacts`, so a spread would type-check while widening
 * disclosure at runtime; the explicit return type makes any future field on
 * `ForwardedAccessFacts` a compile error here until it is deliberately
 * projected or deliberately withheld.
 */
function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
  return {
    surface: intent.surface,
    matchValues: intent.matchValues,
    boundaryValue: intent.boundaryValue,
  };
}

// ── ForwardedRequestServer ────────────────────────────────────────────────

/**
 * Owner of the serving-down role of the forwarded-permission behavior:
 * draining this session's forwarded-permission inbox and answering each
 * request the same way the session resolves a local action — resolving its
 * `ForwardedAccessIntent` against recorded authority (`ServingPolicy`), then
 * escalation to its selected `Authorizer` (`AskEscalator`) on `ask` (ADR
 * 0008).
 */
export class ForwardedRequestServer implements InboxProcessor {
  private readonly forwardingDir: string;
  private readonly logger: DebugReviewLogger;
  private readonly policy: ServingPolicy;
  private readonly escalator: AskEscalator;
  private readonly recorder: SessionApprovalRecorder;
  private readonly registry: SubagentSessionRegistry | undefined;

  constructor(deps: ForwardedRequestServerDeps) {
    this.forwardingDir = deps.forwardingDir;
    this.logger = deps.logger;
    this.policy = deps.policy;
    this.escalator = deps.escalator;
    this.recorder = deps.recorder;
    this.registry = deps.registry;
  }

  /** Drain and respond to this session's forwarded-permission inbox. */
  async processInbox(ctx: ForwarderContext): Promise<void> {
    const currentSessionId = getSessionId(ctx);
    const location = getExistingPermissionForwardingLocation(
      this.forwardingDir,
      currentSessionId,
    );
    if (!location) {
      return;
    }

    const requestFiles = listRequestFiles(this.logger, location.requestsDir);
    if (requestFiles.length === 0) {
      return;
    }

    // Defensively recreate responses/ before writing any response — a
    // concurrent cleanup pass may have removed it between the requestsDir
    // existence check above and the write inside processSingleForwardedRequest
    // (the ENOENT write loop reported in issue #398).
    if (
      !ensureDirectoryExists(
        this.logger,
        location.responsesDir,
        "permission forwarding responses",
      )
    ) {
      return;
    }

    for (const fileName of requestFiles) {
      const requestPath = join(location.requestsDir, fileName);
      const request = readForwardedPermissionRequest(this.logger, requestPath);
      if (!request) {
        safeDeleteFile(
          this.logger,
          requestPath,
          `${location.label} forwarded permission request`,
        );
        continue;
      }

      await this.processSingleForwardedRequest(
        request,
        location,
        requestPath,
        currentSessionId,
      );
    }

    cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
  }

  // ── Private methods ────────────────────────────────────────────────────

  private async processSingleForwardedRequest(
    request: ForwardedPermissionRequest,
    location: PermissionForwardingLocation,
    requestPath: string,
    currentSessionId: string,
  ): Promise<void> {
    if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
      logPermissionForwardingWarning(
        this.logger,
        `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
      );
      safeDeleteFile(
        this.logger,
        requestPath,
        `${location.label} forwarded permission request`,
      );
      return;
    }

    this.warnOnMultiHop(request, currentSessionId);

    const forwardedPermissionLogDetails = {
      requestId: request.id,
      source: location.label,
      requesterAgentName: request.requesterAgentName,
      requesterSessionId: request.requesterSessionId,
      targetSessionId: request.targetSessionId,
      requestPath,
    };

    const decision = await this.resolveDecision(
      request,
      forwardedPermissionLogDetails,
    );

    this.recordForwardedDecision(
      request,
      location,
      requestPath,
      currentSessionId,
      this.applyGrantScope(request, decision, forwardedPermissionLogDetails),
    );
  }

  /**
   * Apply the human's grant-scope choice on a forwarded approval.
   *
   * A whole-session grant (`approved_for_serving_session`) records the child's
   * suggested pattern into this serving node's `SessionRules` — the single
   * source of truth for the scope — and is then translated to a plain
   * `approved` so the child records nothing (its next identical action
   * re-forwards and resolves as recorded authority). Every other decision
   * passes through unchanged (`approved_for_session` → the child records).
   *
   * The translation rewrites the grant's *scope*, never its decider: the human
   * who chose the wider scope is still the one who decided (#726).
   */
  private applyGrantScope(
    request: ForwardedPermissionRequest,
    decision: PermissionPromptDecision,
    logDetails: Record<string, unknown>,
  ): PermissionPromptDecision {
    if (decision.state !== "approved_for_serving_session") {
      return decision;
    }
    if (request.sessionApproval) {
      this.recorder.recordSessionApproval(
        SessionApproval.multiple(
          request.sessionApproval.surface,
          request.sessionApproval.patterns,
        ),
      );
      this.logger.review("forwarded_permission.session_recorded", {
        ...logDetails,
        surface: request.sessionApproval.surface,
        patterns: request.sessionApproval.patterns,
      });
    }
    return {
      approved: true,
      state: "approved",
      decidedBy: decision.decidedBy,
    };
  }

  /**
   * Persist the served decision: write the response file the child polls for,
   * log the outcome, and delete the drained request. The symmetric "respond"
   * half to {@link resolveDecision}'s "decide" half.
   */
  private recordForwardedDecision(
    request: ForwardedPermissionRequest,
    location: PermissionForwardingLocation,
    requestPath: string,
    currentSessionId: string,
    decision: PermissionPromptDecision,
  ): void {
    const responsePath = join(location.responsesDir, `${request.id}.json`);
    this.logger.review(
      decision.approved
        ? "forwarded_permission.approved"
        : "forwarded_permission.denied",
      {
        requestId: request.id,
        source: location.label,
        requesterAgentName: request.requesterAgentName,
        requesterSessionId: request.requesterSessionId,
        targetSessionId: request.targetSessionId,
        responsePath,
        resolution: decision.state,
        denialReason: decision.denialReason ?? null,
        decidedBy: decision.decidedBy,
      },
    );
    try {
      writeJsonFileAtomic(this.logger, responsePath, {
        approved: decision.approved,
        state: decision.state,
        denialReason: decision.denialReason,
        responderSessionId: currentSessionId,
        respondedAt: Date.now(),
        // Carried onto the wire so the requester can name what decided inside
        // this session, not merely that this session answered (#726).
        decidedBy: decision.decidedBy,
      } satisfies ForwardedPermissionResponse);
    } catch (error) {
      logPermissionForwardingError(
        this.logger,
        `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
        error,
      );
      return;
    }

    safeDeleteFile(
      this.logger,
      requestPath,
      `${location.label} forwarded permission request`,
    );
  }

  /**
   * Resolve the request the same way the session resolves a local action:
   * recorded authority first (a request carrying an `accessIntent` — the
   * child-fixed facts, ADR 0008 §2 — resolves against the serving node's
   * composed ruleset — `allow`, including yolo-rewritten, auto-approves;
   * `deny` auto-denies), then escalate `ask` (or a request missing
   * `accessIntent`, the version-skew floor, ADR 0008 §4) to the selected
   * `Authorizer`.
   */
  private async resolveDecision(
    request: ForwardedPermissionRequest,
    logDetails: Record<string, unknown>,
  ): Promise<PermissionPromptDecision> {
    const check = request.accessIntent
      ? this.policy.resolve(request.accessIntent)
      : null;

    if (check && check.state !== "ask") {
      // The rule is carried in full rather than left to the event name: the
      // response file has no surface, pattern, or origin column for the
      // requester's record to lean on.
      const decidedBy: DecisionSource = {
        kind: "rule",
        surface: request.accessIntent?.surface ?? check.toolName,
        pattern: check.matchedPattern ?? null,
        origin: check.origin,
      };
      const approved = check.state === "allow";
      this.logger.review(
        approved
          ? "forwarded_permission.auto_approved"
          : "forwarded_permission.auto_denied",
        { ...logDetails, decidedBy },
      );
      return approved
        ? { approved: true, state: "approved", decidedBy }
        : { approved: false, state: "denied", decidedBy };
    }

    this.logger.review("forwarded_permission.prompted", logDetails);
    try {
      return await this.escalator.escalate(buildForwardedAskDetails(request));
    } catch (error) {
      const reason = formatUnknownErrorMessage(error);
      logPermissionForwardingError(
        this.logger,
        `Failed to escalate forwarded permission request '${request.id}'`,
        error,
      );
      // Nobody denied this; the escalation broke and the node failed closed.
      return {
        approved: false,
        state: "denied",
        decidedBy: { kind: "gate_error", reason },
      };
    }
  }

  /**
   * One-hop canary: forwarding is depth-1 (child → root). If the requester is
   * itself a registered subagent whose parent is not this serving session, the
   * request came through more than one hop (or was misrouted) — resolution is
   * still well-defined, so keep serving, but warn loudly so a future
   * recursion-guard break is visible rather than silent. Unregistered
   * (external file-based) requesters have no recorded parent and are silent.
   */
  private warnOnMultiHop(
    request: ForwardedPermissionRequest,
    currentSessionId: string,
  ): void {
    const requesterInfo = this.registry?.get(request.requesterSessionId);
    if (
      requesterInfo?.parentSessionId &&
      requesterInfo.parentSessionId !== currentSessionId
    ) {
      logPermissionForwardingWarning(
        this.logger,
        `Forwarded permission request '${request.id}' violates the one-hop ` +
          `invariant: requester '${request.requesterSessionId}' is a registered ` +
          `subagent whose parent '${requesterInfo.parentSessionId}' is not this ` +
          `serving session '${currentSessionId}' (multi-hop or misrouted).`,
      );
    }
  }
}
