import type { MutationContext, PlanningSessionId, PlanningTurnId, ProjectId } from "@awp/contracts";
import type {
  PlanningBlockingPoint,
  PlanningInterviewerResolvedSelection,
  PlanningSession,
} from "@awp/domain";
import type { IdGenerator } from "./ports/runtime.js";
import type { PlanningModelProvider } from "./ports/providers.js";
import type { ProjectPlanningInterviewerConfiguration } from "./configuration.js";
import type { I2PlanningService, PlannerStructuredDisposition } from "./planning.js";

export interface PlanningInterviewerConfigurationResolver {
  resolveProjectPlanningInterviewer(
    projectId: ProjectId,
  ): Promise<ProjectPlanningInterviewerConfiguration>;
}

export class PlannerConfigurationRequiredError extends Error {
  constructor(message = "Planner interviewer account and model must be configured") {
    super(message);
    this.name = "PlannerConfigurationRequiredError";
  }
}

export class PlannerModelOutputError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "PlannerModelOutputError";
  }
}

export interface PlannerTurnResult {
  readonly session: PlanningSession;
  readonly selection: PlanningInterviewerResolvedSelection;
}

interface PlannerModelEnvelope {
  readonly reply: string;
  readonly disposition: PlannerStructuredDisposition;
}

function requiredText(value: unknown, field: string): string {
  if (typeof value !== "string" || value.trim().length === 0) {
    throw new PlannerModelOutputError(`Planner model output is missing ${field}`);
  }
  return value.trim();
}

function optionalText(value: unknown): string | undefined {
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

const blockingPoints = new Set<PlanningBlockingPoint>([
  "DEFINE",
  "DESIGN",
  "SPECIFY",
  "DELIVER",
  "LAUNCH",
  "PRODUCTION",
]);

function parseDisposition(value: unknown): PlannerStructuredDisposition {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new PlannerModelOutputError("Planner model output disposition must be an object");
  }
  const record = value as Record<string, unknown>;
  const kind = requiredText(record.kind, "disposition.kind");
  if (kind === "resolve") {
    const nextItemKey = optionalText(record.nextItemKey);
    return {
      kind,
      itemKey: requiredText(record.itemKey, "disposition.itemKey"),
      answer: requiredText(record.answer, "disposition.answer"),
      ...(nextItemKey ? { nextItemKey } : {}),
    };
  }
  if (kind === "continue" || kind === "none") {
    const itemKey = optionalText(record.itemKey);
    return { kind, ...(itemKey ? { itemKey } : {}) };
  }
  if (kind === "defer") {
    const blockingAt = requiredText(record.blockingAt, "disposition.blockingAt");
    if (!blockingPoints.has(blockingAt as PlanningBlockingPoint)) {
      throw new PlannerModelOutputError("Planner model output has invalid deferral blockingAt");
    }
    const nextItemKey = optionalText(record.nextItemKey);
    return {
      kind,
      itemKey: requiredText(record.itemKey, "disposition.itemKey"),
      reason: requiredText(record.reason, "disposition.reason"),
      blockingAt: blockingAt as PlanningBlockingPoint,
      consequence: requiredText(record.consequence, "disposition.consequence"),
      revisit: requiredText(record.revisit, "disposition.revisit"),
      ...(nextItemKey ? { nextItemKey } : {}),
    };
  }
  throw new PlannerModelOutputError(`Planner model output disposition kind is invalid: ${kind}`);
}

function modelJson(text: string): Record<string, unknown> {
  const trimmed = text.trim();
  const unfenced = trimmed
    .replace(/^```(?:json)?\s*/u, "")
    .replace(/\s*```$/u, "")
    .trim();
  const start = unfenced.indexOf("{");
  const end = unfenced.lastIndexOf("}");
  if (start < 0 || end <= start) {
    throw new PlannerModelOutputError("Planner model did not return a JSON object");
  }
  let parsed: unknown;
  try {
    parsed = JSON.parse(unfenced.slice(start, end + 1));
  } catch {
    throw new PlannerModelOutputError("Planner model returned malformed JSON");
  }
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
    throw new PlannerModelOutputError("Planner model output must be a JSON object");
  }
  return parsed as Record<string, unknown>;
}

export function parsePlannerModelOutput(text: string): PlannerModelEnvelope {
  const record = modelJson(text);
  return {
    reply: requiredText(record.reply, "reply"),
    disposition: parseDisposition(record.disposition),
  };
}

function recentConversation(session: PlanningSession): readonly Record<string, unknown>[] {
  return session.turns.slice(-12).map((turn) => ({
    role: turn.role,
    content: turn.content,
    status: turn.status,
    ...(turn.itemKey ? { itemKey: turn.itemKey } : {}),
  }));
}

function plannerPrompt(session: PlanningSession, userMessage: string): string {
  const active = session.items.find((item) => item.key === session.activeItemKey);
  const unresolved = session.items
    .filter((item) => ["active", "pending", "blocked"].includes(item.status))
    .map((item) => ({
      key: item.key,
      stage: item.stage,
      title: item.title,
      summary: item.summary,
      participation: item.participation,
      status: item.status,
      recommendation: item.recommendation,
      confidence: item.confidence,
      alternatives: item.alternatives,
    }));
  return [
    "You are the AWP Planner Agent. You conduct the approved interactive software-planning interview while structured AWP state remains authoritative.",
    "Do not behave like a passive questionnaire. Respond to the user's current message, then lead the highest-value next unresolved planning item when the current item is sufficiently resolved.",
    "Recommendation comes before interrogation when evidence is sufficient. Ask at most one consequential question in the reply.",
    "Simple mode: resolve DelegableExpert choices yourself when evidence is sufficient; ask primarily OwnerRequired/PolicyRequired choices. Expert mode: technical recommendations may be conversational review items.",
    "Never claim a protected action happened. Never resolve a PolicyRequired item without explicit user authorization in the current message.",
    "Return ONLY one JSON object matching the schema below. Do not include markdown fences or chain-of-thought.",
    JSON.stringify(
      {
        reply: "concise planner response and, when needed, one next question",
        disposition: {
          kind: "resolve | continue | defer | none",
          itemKey: "current active item key when applicable",
          answer: "typed durable answer when kind=resolve",
          reason: "required when kind=defer",
          blockingAt: "DEFINE | DESIGN | SPECIFY | DELIVER | LAUNCH | PRODUCTION",
          consequence: "required when kind=defer",
          revisit: "required when kind=defer",
          nextItemKey: "optional highest-value unresolved item after resolving/deferring current",
        },
      },
      null,
      2,
    ),
    "Use kind=continue when the user is discussing/clarifying the current item but has not resolved it.",
    "Use kind=none for a side question that should not mutate the current item.",
    "Use kind=defer only when the user's message clearly authorizes a safe deferral and all deferral fields can be bounded.",
    "Current PlanningSession state:",
    JSON.stringify(
      {
        id: session.id,
        revision: session.revision,
        intent: session.intent,
        mode: session.mode,
        profile: session.profile,
        readiness: session.readiness,
        activeItem: active,
        unresolved,
        deferrals: session.deferrals,
        delivery: session.delivery,
        recentConversation: recentConversation(session),
      },
      null,
      2,
    ),
    "Current user message:",
    userMessage,
  ].join("\n\n");
}

function sourceFor(
  configuration: ProjectPlanningInterviewerConfiguration,
): PlanningInterviewerResolvedSelection["source"] {
  const sources = [
    configuration.providerSource?.scopeType,
    configuration.accountSource?.scopeType,
    configuration.modelSource?.scopeType,
    configuration.reasoningEffortSource?.scopeType,
  ].filter((value): value is "system" | "project" => value === "system" || value === "project");
  if (sources.length === 4 && sources.every((value) => value === "project")) return "project";
  if (sources.length === 4 && sources.every((value) => value === "system")) return "system";
  return "mixed";
}

function safeProviderFailure(error: unknown): {
  readonly category: string;
  readonly safeMessage: string;
  readonly retryable: boolean;
} {
  if (error && typeof error === "object") {
    const record = error as Record<string, unknown>;
    const category = optionalText(record.category) ?? "provider-failure";
    const safeMessage =
      optionalText(record.safeMessage) ??
      (error instanceof Error ? error.message : "Planner model invocation failed");
    return {
      category,
      safeMessage,
      retryable: typeof record.retryable === "boolean" ? record.retryable : true,
    };
  }
  return {
    category: "provider-failure",
    safeMessage: "Planner model invocation failed",
    retryable: true,
  };
}

export class PlanningAgentService {
  constructor(
    private readonly planning: I2PlanningService,
    private readonly configuration: PlanningInterviewerConfigurationResolver,
    private readonly provider: PlanningModelProvider,
    private readonly ids: IdGenerator,
  ) {}

  async effectiveSelection(
    session: PlanningSession,
  ): Promise<PlanningInterviewerResolvedSelection> {
    if (session.plannerOverride) {
      return { ...session.plannerOverride, source: "session" };
    }
    const resolved = await this.configuration.resolveProjectPlanningInterviewer(session.projectId);
    if (
      !resolved.providerId ||
      !resolved.accountId ||
      !resolved.model ||
      !resolved.reasoningEffort
    ) {
      throw new PlannerConfigurationRequiredError(
        "Configure Planner provider, account, model, and reasoning effort in Settings → Planning before using the interview",
      );
    }
    return {
      providerId: resolved.providerId,
      accountId: resolved.accountId,
      model: resolved.model,
      reasoningEffort: resolved.reasoningEffort,
      source: sourceFor(resolved),
    };
  }

  async turn(input: {
    readonly sessionId: PlanningSessionId;
    readonly message: string;
    readonly context: MutationContext;
  }): Promise<PlannerTurnResult> {
    const message = input.message.trim();
    if (!message) throw new Error("Planner message is required");
    const session = await this.planning.get(input.sessionId);
    if (!session) throw new Error(`Unknown PlanningSession ${input.sessionId}`);
    const selection = await this.effectiveSelection(session);
    if (!this.provider.supportsProvider(selection.providerId)) {
      throw new PlannerConfigurationRequiredError(
        `Configured Planner provider ${selection.providerId} is not supported by the active model gateway`,
      );
    }
    const catalog = await this.provider.listModels(selection.accountId);
    const selectedModel = catalog.find((entry) => entry.model === selection.model);
    if (!selectedModel) {
      throw new PlannerConfigurationRequiredError(
        `Configured Planner model ${selection.model} is no longer available for ${selection.accountId}`,
      );
    }
    if (
      !selectedModel.reasoningEfforts.some((entry) => entry.effort === selection.reasoningEffort)
    ) {
      throw new PlannerConfigurationRequiredError(
        `Configured Planner reasoning effort ${selection.reasoningEffort} is not supported by ${selection.model}`,
      );
    }
    const userTurnId = this.ids.next<PlanningTurnId>();
    const plannerTurnId = this.ids.next<PlanningTurnId>();
    let invocation:
      | {
          readonly providerId: string;
          readonly outputText: string;
          readonly invocationId?: string;
          readonly observedReasoningEffort?: string;
        }
      | undefined;
    try {
      invocation = await this.provider.invoke({
        sessionId: session.id,
        turnId: plannerTurnId,
        accountId: selection.accountId,
        model: selection.model,
        reasoningEffort: selection.reasoningEffort,
        prompt: plannerPrompt(session, message),
      });
      const parsed = parsePlannerModelOutput(invocation.outputText);
      const updated = await this.planning.applyPlannerTurn({
        sessionId: session.id,
        expectedRevision: session.revision,
        userTurnId,
        plannerTurnId,
        userMessage: message,
        plannerMessage: parsed.reply,
        selection,
        providerId: invocation.providerId,
        ...(invocation.invocationId ? { invocationId: invocation.invocationId } : {}),
        ...(invocation.observedReasoningEffort
          ? { observedReasoningEffort: invocation.observedReasoningEffort }
          : {}),
        disposition: parsed.disposition,
        context: input.context,
      });
      return { session: updated, selection };
    } catch (error) {
      if (error instanceof PlannerModelOutputError || invocation === undefined) {
        const failure =
          error instanceof PlannerModelOutputError
            ? {
                category: "malformed-output",
                safeMessage: error.message,
                retryable: true,
              }
            : safeProviderFailure(error);
        const providerId = invocation?.providerId ?? "provider:model-gateway";
        const failed = await this.planning.recordPlannerFailure({
          sessionId: session.id,
          expectedRevision: session.revision,
          userTurnId,
          plannerTurnId,
          userMessage: message,
          selection,
          providerId,
          failureCategory: failure.category,
          safeMessage: failure.safeMessage,
          retryable: failure.retryable,
          context: input.context,
        });
        return { session: failed, selection };
      }
      throw error;
    }
  }
}
