import type { AgentConfigRegistry } from '../agents/registry.js';
import type { AgentOrchestrator } from '../backends/native/orchestrator.js';
import type { ModelTier } from '../models/router.js';
import type { ChatResponseFormat, TokenUsage } from '../models/types.js';
import type { CouncilScaffold } from './scaffold.js';
import {
  COUNCIL_PIPELINE_VERSION,
  COUNCIL_SCHEMA_VERSION,
  assessmentOutputSchema,
  bridgePacketSchema,
  councilBriefSchema,
  councilDiffSchema,
  councilRunInputSchema,
  councilRunResultSchema,
  councilTraceEventSchema,
  groundingOutputSchema,
  ideationOutputSchema,
  metaSelectionSchema,
  type BridgePacket,
  type CouncilBrief,
  type CouncilDiff,
  type CouncilGroup,
  type CouncilRunInput,
  type CouncilRunResult,
  type CouncilConversationTurn,
  type CouncilTraceEvent,
  type IdeaAssessment,
  type IdeaCard,
  type StopReason,
} from './types.js';
import { canonicalStringify, hashCanonical, normalizeOptional } from './canonical.js';

interface DelegateRunner {
  delegate(request: {
    tier: ModelTier;
    systemPrompt: string;
    message: string;
    maxTokens?: number;
    responseFormat?: ChatResponseFormat;
  }): Promise<{
    content: string;
    usage: TokenUsage;
    tier: ModelTier;
  }>;
}

export interface CouncilsConfig {
  enabled: boolean;
  defaults: {
    max_rounds: number;
    ideas_per_round: number;
    top_ideas_for_bridge: number;
    bridge_packet_max_chars: number;
    bridge_field_max_bullets: number;
    bridge_entry_max_chars: number;
    novelty_delta_threshold: number;
    repetition_threshold: number;
  };
  strict_grounding: boolean;
  strict_meta_validation: boolean;
  groups: {
    D: CouncilGroupConfig;
    P: CouncilGroupConfig;
  };
  meta_arbiter_agent: string;
  meta_model_tier?: ModelTier;
  meta_writer_agent?: string;
  scaffold_path?: string;
}

interface CouncilGroupConfig {
  arbiter_agent: string;
  freethinker_agent: string;
  grounder_agent?: string;
  writer_agent?: string;
  model_tier?: ModelTier;
  group_prompt_prefix: string;
  novelty_bias: 'low' | 'medium' | 'high';
  risk_tolerance: 'low' | 'medium' | 'high';
  forbidden_approaches: string[];
}

interface GroupRoundResult {
  brief: CouncilBrief;
  convergenceQualified: boolean;
  groundingFailures: number;
}

interface AgentCallResult {
  content: string;
  usage: TokenUsage;
}

interface JsonRecoveryOptions<T> {
  rawContent: string;
  parser: (value: unknown) => T;
  agentName: string;
  callId: string;
  phaseIndex: number;
  promptPayload: unknown;
  modeDirective: string;
  schemaHint: string;
  group?: CouncilGroup;
  round?: number;
  scaffoldPrompt?: string;
  tierOverride?: ModelTier;
  maxTokens?: number;
  responseFormat?: ChatResponseFormat;
  fallback?: () => T;
}

function deterministicJsonRepair(raw: string): string | null {
  const trimmed = raw.trim();
  const noFence = trimmed
    .replace(/^```json\s*/i, '')
    .replace(/^```\s*/i, '')
    .replace(/```$/, '')
    .trim()
    .replace(/[\u201C\u201D]/g, '"')
    .replace(/[\u2018\u2019]/g, '\'');

  const extracted = extractFirstJsonContainer(noFence);
  if (!extracted) {
    return null;
  }

  const withoutComments = stripJsonLikeComments(extracted);
  const withQuotedKeys = withoutComments.replace(
    /([{,]\s*)([A-Za-z_][A-Za-z0-9_-]*)(\s*:)/g,
    '$1"$2"$3',
  );
  const withDoubleQuotedStrings = convertSingleQuotedStrings(withQuotedKeys);

  return withDoubleQuotedStrings
    .replace(/,\s*([}\]])/g, '$1')
    .trim();
}

function stripJsonLikeComments(input: string): string {
  let output = '';
  let i = 0;
  let inString = false;
  let stringDelimiter: '"' | '\'' | null = null;
  let escaped = false;

  while (i < input.length) {
    const ch = input[i];
    const next = i + 1 < input.length ? input[i + 1] : '';

    if (inString) {
      output += ch;
      if (escaped) {
        escaped = false;
      } else if (ch === '\\') {
        escaped = true;
      } else if (ch === stringDelimiter) {
        inString = false;
        stringDelimiter = null;
      }
      i += 1;
      continue;
    }

    if (ch === '"' || ch === '\'') {
      inString = true;
      stringDelimiter = ch;
      output += ch;
      i += 1;
      continue;
    }

    if (ch === '/' && next === '/') {
      i += 2;
      while (i < input.length && input[i] !== '\n') {
        i += 1;
      }
      continue;
    }

    if (ch === '/' && next === '*') {
      i += 2;
      while (i + 1 < input.length && !(input[i] === '*' && input[i + 1] === '/')) {
        i += 1;
      }
      i = Math.min(i + 2, input.length);
      continue;
    }

    output += ch;
    i += 1;
  }

  return output;
}

function convertSingleQuotedStrings(input: string): string {
  let output = '';
  let inSingle = false;
  let inDouble = false;
  let escaped = false;

  for (let i = 0; i < input.length; i++) {
    const ch = input[i];

    if (inSingle) {
      if (escaped) {
        output += ch;
        escaped = false;
        continue;
      }
      if (ch === '\\') {
        output += ch;
        escaped = true;
        continue;
      }
      if (ch === '\'') {
        inSingle = false;
        output += '"';
        continue;
      }
      if (ch === '"') {
        output += '\\"';
        continue;
      }
      output += ch;
      continue;
    }

    if (inDouble) {
      output += ch;
      if (escaped) {
        escaped = false;
      } else if (ch === '\\') {
        escaped = true;
      } else if (ch === '"') {
        inDouble = false;
      }
      continue;
    }

    if (ch === '\'') {
      inSingle = true;
      output += '"';
      continue;
    }

    if (ch === '"') {
      inDouble = true;
      output += ch;
      continue;
    }

    output += ch;
  }

  return output;
}

function extractFirstJsonContainer(input: string): string | null {
  const start = input.search(/[\[{]/);
  if (start < 0) {
    return null;
  }
  const opener = input[start];
  const closer = opener === '{' ? '}' : ']';
  let depth = 0;
  let inString = false;
  let escaped = false;

  for (let i = start; i < input.length; i++) {
    const ch = input[i];
    if (inString) {
      if (escaped) {
        escaped = false;
        continue;
      }
      if (ch === '\\') {
        escaped = true;
      } else if (ch === '"') {
        inString = false;
      }
      continue;
    }
    if (ch === '"') {
      inString = true;
      continue;
    }
    if (ch === opener) {
      depth++;
      continue;
    }
    if (ch === closer) {
      depth--;
      if (depth === 0) {
        return input.slice(start, i + 1);
      }
    }
  }

  return null;
}

function parseWithFallbackCandidates<T>(value: unknown, parser: (value: unknown) => T): T {
  const queue: unknown[] = [value];
  const seenObjects = new Set<object>();
  const wrapperKeys = ['output', 'result', 'data', 'response', 'json', 'payload', 'content'];
  let firstError: Error | null = null;

  while (queue.length > 0) {
    const current = queue.shift();
    if (current === undefined) {
      continue;
    }

    if (current && typeof current === 'object') {
      const objectRef = current as object;
      if (seenObjects.has(objectRef)) {
        continue;
      }
      seenObjects.add(objectRef);
    }

    try {
      return parser(current);
    } catch (error) {
      if (!firstError) {
        firstError = error instanceof Error ? error : new Error(String(error));
      }
    }

    if (typeof current === 'string') {
      try {
        queue.push(JSON.parse(current));
        continue;
      } catch {
        const repaired = deterministicJsonRepair(current);
        if (repaired) {
          try {
            queue.push(JSON.parse(repaired));
            continue;
          } catch {
            // Continue searching additional candidates below.
          }
        }
      }
      continue;
    }

    if (Array.isArray(current)) {
      for (const item of current) {
        queue.push(item);
      }
      continue;
    }

    if (current && typeof current === 'object') {
      const record = current as Record<string, unknown>;
      for (const key of wrapperKeys) {
        if (record[key] !== undefined) {
          queue.push(record[key]);
        }
      }
    }
  }

  throw firstError ?? new Error('parse_failed');
}

function parseJsonWithRepair<T>(raw: string, parser: (value: unknown) => T): T {
  try {
    return parseWithFallbackCandidates(JSON.parse(raw), parser);
  } catch {
    const repaired = deterministicJsonRepair(raw);
    if (!repaired) {
      throw new Error('parse_failed');
    }
    try {
      return parseWithFallbackCandidates(JSON.parse(repaired), parser);
    } catch {
      throw new Error('repair_failed');
    }
  }
}

function uniq(values: string[]): string[] {
  return Array.from(new Set(values));
}

function jsonSchemaFormat(name: string, schema: Record<string, unknown>): ChatResponseFormat {
  return { type: 'json_schema', name, schema, strict: true };
}

const councilIdeaContentJsonSchema: Record<string, unknown> = {
  type: 'object',
  additionalProperties: false,
  required: ['title', 'hypothesis', 'mechanism', 'expected_outcome'],
  properties: {
    title: { type: 'string', minLength: 1 },
    hypothesis: { type: 'string', minLength: 1 },
    mechanism: { type: 'string', minLength: 1 },
    expected_outcome: { type: 'string', minLength: 1 },
  },
};

const councilIdeationJsonSchema: Record<string, unknown> = {
  type: 'object',
  additionalProperties: false,
  required: ['ideas'],
  properties: {
    ideas: {
      type: 'array',
      minItems: 1,
      items: councilIdeaContentJsonSchema,
    },
  },
};

const councilAssessmentJsonSchema: Record<string, unknown> = {
  type: 'object',
  additionalProperties: false,
  required: ['assessments', 'assumptions', 'risks', 'asks', 'what_to_steal', 'convergence_signal', 'novelty_score', 'repetition_rate'],
  properties: {
    assessments: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['idea_id', 'scores', 'decision', 'notes'],
        properties: {
          idea_id: { type: 'string', minLength: 1 },
          scores: {
            type: 'object',
            additionalProperties: false,
            required: ['novelty', 'feasibility', 'impact', 'testability'],
            properties: {
              novelty: { type: 'integer', minimum: 0, maximum: 100 },
              feasibility: { type: 'integer', minimum: 0, maximum: 100 },
              impact: { type: 'integer', minimum: 0, maximum: 100 },
              testability: { type: 'integer', minimum: 0, maximum: 100 },
            },
          },
          decision: { type: 'string', enum: ['shortlist', 'hold', 'reject'] },
          notes: { type: 'string', minLength: 1 },
        },
      },
    },
    assumptions: { type: 'array', items: { type: 'string', minLength: 1 } },
    risks: { type: 'array', items: { type: 'string', minLength: 1 } },
    asks: { type: 'array', items: { type: 'string', minLength: 1 } },
    what_to_steal: { type: 'array', items: { type: 'string', minLength: 1 } },
    convergence_signal: { type: 'boolean' },
    novelty_score: { type: 'integer', minimum: 0, maximum: 100 },
    repetition_rate: { type: 'integer', minimum: 0, maximum: 100 },
  },
};

const councilGroundingJsonSchema: Record<string, unknown> = {
  type: 'object',
  additionalProperties: false,
  required: ['grounded'],
  properties: {
    grounded: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['idea_id', 'mve', 'constraints', 'falsifiability_checks'],
        properties: {
          idea_id: { type: 'string', minLength: 1 },
          mve: { type: 'string', minLength: 1 },
          constraints: {
            type: 'array',
            minItems: 1,
            items: { type: 'string', minLength: 1 },
          },
          falsifiability_checks: {
            type: 'array',
            minItems: 1,
            items: { type: 'string', minLength: 1 },
          },
        },
      },
    },
  },
};

const councilMetaJsonSchema: Record<string, unknown> = {
  type: 'object',
  additionalProperties: false,
  required: ['schema_version', 'selected_primary', 'selected_secondary', 'merges', 'rejections', 'open_questions', 'next_experiments'],
  properties: {
    schema_version: { type: 'string', const: COUNCIL_SCHEMA_VERSION },
    selected_primary: { type: 'array', items: { type: 'string', minLength: 1 } },
    selected_secondary: { type: 'array', items: { type: 'string', minLength: 1 } },
    merges: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['sources', 'result_title', 'rationale'],
        properties: {
          sources: {
            type: 'array',
            minItems: 2,
            items: { type: 'string', minLength: 1 },
          },
          result_title: { type: 'string', minLength: 1 },
          rationale: { type: 'string', minLength: 1 },
        },
      },
    },
    rejections: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['idea_id', 'reason_code'],
        properties: {
          idea_id: { type: 'string', minLength: 1 },
          reason_code: {
            type: 'string',
            enum: ['low_score', 'high_risk', 'insufficient_grounding', 'duplicate', 'out_of_scope', 'unknown_id', 'other'],
          },
        },
      },
    },
    open_questions: { type: 'array', items: { type: 'string', minLength: 1 } },
    next_experiments: { type: 'array', items: { type: 'string', minLength: 1 } },
  },
};

function computeTotalScore(assessment: IdeaAssessment): number {
  const s = assessment.scores;
  return s.feasibility + s.impact + s.novelty + s.testability;
}

function buildDiff(group: CouncilGroup, fromBrief: CouncilBrief, toBrief: CouncilBrief): CouncilDiff {
  const fromIds = new Set(fromBrief.ideas.map((i) => i.idea_id));
  const toIds = new Set(toBrief.ideas.map((i) => i.idea_id));

  const ideaAdded = [...toIds].filter((id) => !fromIds.has(id));
  const ideaRemoved = [...fromIds].filter((id) => !toIds.has(id));

  const shortlistAdded = toBrief.shortlist.filter((id) => !fromBrief.shortlist.includes(id));
  const shortlistRemoved = fromBrief.shortlist.filter((id) => !toBrief.shortlist.includes(id));

  const fromAssessmentMap = new Map(fromBrief.assessments.map((a) => [a.idea_id, a]));
  const scoreChanges = toBrief.assessments
    .filter((a) => fromAssessmentMap.has(a.idea_id))
    .map((a) => {
      const prev = fromAssessmentMap.get(a.idea_id)!;
      return {
        idea_id: a.idea_id,
        from_total: computeTotalScore(prev),
        to_total: computeTotalScore(a),
      };
    })
    .filter((entry) => entry.from_total !== entry.to_total)
    .sort((a, b) => a.idea_id.localeCompare(b.idea_id));

  const assumptionsAdded = toBrief.assumptions.filter((a) => !fromBrief.assumptions.includes(a));
  const assumptionsRemoved = fromBrief.assumptions.filter((a) => !toBrief.assumptions.includes(a));

  const fromGroundingMap = new Map(fromBrief.ideas.map((i) => [i.idea_id, i.grounding?.mve]));
  const mveChanged = toBrief.ideas
    .filter((idea) => fromGroundingMap.has(idea.idea_id))
    .filter((idea) => fromGroundingMap.get(idea.idea_id) !== idea.grounding?.mve)
    .map((idea) => idea.idea_id)
    .sort();

  return councilDiffSchema.parse({
    schema_version: COUNCIL_SCHEMA_VERSION,
    group,
    from_round: fromBrief.round,
    to_round: toBrief.round,
    idea_added: ideaAdded.sort(),
    idea_removed: ideaRemoved.sort(),
    shortlist_added: shortlistAdded.sort(),
    shortlist_removed: shortlistRemoved.sort(),
    score_changes: scoreChanges,
    assumptions_added: assumptionsAdded.sort(),
    assumptions_removed: assumptionsRemoved.sort(),
    mve_changed: mveChanged,
  });
}

export class CouncilsOrchestrator {
  private readonly _registry: AgentConfigRegistry;
  private readonly _delegateRunner: DelegateRunner;
  private readonly _config: CouncilsConfig;
  private readonly _scaffold?: CouncilScaffold;
  private readonly _trace: CouncilTraceEvent[] = [];
  private readonly _conversations: CouncilConversationTurn[] = [];

  constructor(deps: {
    registry: AgentConfigRegistry;
    orchestrator: DelegateRunner;
    config: CouncilsConfig;
    scaffold?: CouncilScaffold;
  }) {
    this._registry = deps.registry;
    this._delegateRunner = deps.orchestrator;
    this._config = deps.config;
    this._scaffold = deps.scaffold;
  }

  async run(rawInput: unknown): Promise<CouncilRunResult> {
    const input = councilRunInputSchema.parse(rawInput);
    if (!this._config.enabled) {
      throw new Error('Councils are disabled in config');
    }

    this._trace.length = 0;
    this._conversations.length = 0;
    const inputHash = hashCanonical(input);
    const maxRounds = input.max_rounds ?? this._config.defaults.max_rounds;

    const phase1 = await Promise.all([
      this.runGroupRound('D', 1, input),
      this.runGroupRound('P', 1, input),
    ]);
    const briefD1 = phase1[0].brief;
    const briefP1 = phase1[1].brief;

    let bridgeDToP: BridgePacket;
    let bridgePToD: BridgePacket;
    let bridgeValidated = true;
    try {
      bridgeDToP = this.buildBridgePacket(briefD1, 'P');
      bridgePToD = this.buildBridgePacket(briefP1, 'D');
    } catch {
      bridgeValidated = false;
      bridgeDToP = bridgePacketSchema.parse({
        schema_version: COUNCIL_SCHEMA_VERSION,
        from_group: 'D',
        to_group: 'P',
        round: 1,
        top_ideas: [],
        assumptions: [],
        risks: [],
        asks: [],
        what_to_steal: [],
      });
      bridgePToD = bridgePacketSchema.parse({
        schema_version: COUNCIL_SCHEMA_VERSION,
        from_group: 'P',
        to_group: 'D',
        round: 1,
        top_ideas: [],
        assumptions: [],
        risks: [],
        asks: [],
        what_to_steal: [],
      });
    }

    let currentD = briefD1;
    let currentP = briefP1;
    let lastRound = 1;
    let groundingFailuresCount = phase1[0].groundingFailures + phase1[1].groundingFailures;
    let stopReason: StopReason = bridgeValidated ? 'max_rounds' : 'bridge_validation_failed';

    for (let round = 2; round <= maxRounds; round++) {
      if (!bridgeValidated) {
        break;
      }
      try {
        this.enforceBridgeCaps(bridgeDToP);
        this.enforceBridgeCaps(bridgePToD);
      } catch {
        bridgeValidated = false;
        stopReason = 'bridge_validation_failed';
        break;
      }

      const [dRound, pRound] = await Promise.all([
        this.runGroupRound('D', round, input, bridgePToD, currentD),
        this.runGroupRound('P', round, input, bridgeDToP, currentP),
      ]);

      currentD = dRound.brief;
      currentP = pRound.brief;
      groundingFailuresCount += dRound.groundingFailures + pRound.groundingFailures;
      lastRound = round;

      bridgeDToP = this.buildBridgePacket(currentD, 'P');
      bridgePToD = this.buildBridgePacket(currentP, 'D');

      const bothConverged = dRound.convergenceQualified && pRound.convergenceQualified;
      if (bothConverged) {
        stopReason = 'convergence';
        break;
      }
    }

    if (groundingFailuresCount > 0 && this._config.strict_grounding) {
      stopReason = 'grounding_failed';
    }

    const diffD = buildDiff('D', briefD1, currentD);
    const diffP = buildDiff('P', briefP1, currentP);

    const meta = await this.runMetaMerge(input, currentD, currentP);
    const allKnownIds = new Set([...currentD.ideas, ...currentP.ideas].map((idea) => idea.idea_id));
    if (!this.validateMetaSelection(meta, allKnownIds)) {
      if (this._config.strict_meta_validation) {
        stopReason = 'meta_validation_failed';
        throw new Error('meta_validation_failed');
      }
    }

    const stopSnapshot = {
      stop_reason: stopReason,
      round_reached: lastRound,
      final_shortlist_D: currentD.shortlist,
      final_shortlist_P: currentP.shortlist,
      bridge_validated: bridgeValidated,
      grounding_failures_count: groundingFailuresCount,
    };

    const result = councilRunResultSchema.parse({
      pipeline_version: COUNCIL_PIPELINE_VERSION,
      input_hash: inputHash,
      brief_D_v1: briefD1,
      brief_P_v1: briefP1,
      brief_D_v2: currentD,
      brief_P_v2: currentP,
      diff_D: diffD,
      diff_P: diffP,
      bridge_D_to_P: bridgeDToP,
      bridge_P_to_D: bridgePToD,
      meta,
      stop_snapshot: stopSnapshot,
      trace: this.getSortedTrace(),
      conversations: this.getSortedConversations(),
    });

    return result;
  }

  private getAgent(name: string): { tier: ModelTier; systemPrompt: string } {
    const agent = this._registry.get(name);
    if (!agent) {
      throw new Error(`Council agent "${name}" is not configured in agent_configs`);
    }
    return {
      tier: agent.modelTier ?? 'default',
      systemPrompt: agent.systemPrompt ?? `You are ${name}.`,
    };
  }

  private async callAgent(opts: {
    agentName: string;
    callId: string;
    phaseIndex: number;
    group?: CouncilGroup;
    round?: number;
    promptPayload: unknown;
    modeDirective: string;
    scaffoldPrompt?: string;
    tierOverride?: ModelTier;
    maxTokens?: number;
    responseFormat?: ChatResponseFormat;
  }): Promise<AgentCallResult> {
    const agent = this.getAgent(opts.agentName);
    const message = canonicalStringify(opts.promptPayload);
    const promptHash = hashCanonical(opts.promptPayload);
    const scaffoldPrompt = opts.scaffoldPrompt ? `${opts.scaffoldPrompt}\n\n` : '';
    const systemPrompt = `${scaffoldPrompt}${agent.systemPrompt}\n\n${opts.modeDirective}`;
    const startedAt = Date.now();

    const result = await this._delegateRunner.delegate({
      tier: opts.tierOverride ?? agent.tier,
      systemPrompt,
      message,
      maxTokens: opts.maxTokens ?? 4096,
      responseFormat: opts.responseFormat,
    });
    const latencyMs = Math.max(0, Date.now() - startedAt);

    this._trace.push(councilTraceEventSchema.parse({
      schema_version: COUNCIL_SCHEMA_VERSION,
      event_id: `${opts.phaseIndex}:${opts.callId}`,
      phase_index: opts.phaseIndex,
      call_id: opts.callId,
      group: opts.group,
      round: opts.round,
      prompt_payload_hash: promptHash,
      artifact_hash: hashCanonical(result.content),
      token_usage: result.usage,
      latency_ms: latencyMs,
    }));
    this._conversations.push({
      schema_version: COUNCIL_SCHEMA_VERSION,
      phase_index: opts.phaseIndex,
      call_id: opts.callId,
      agent: opts.agentName,
      tier: opts.tierOverride ?? agent.tier,
      group: opts.group,
      round: opts.round,
      prompt_payload: opts.promptPayload as Record<string, unknown>,
      response: result.content,
      usage: result.usage,
    });

    return result;
  }

  private async parseWithAgentRecovery<T>(opts: JsonRecoveryOptions<T>): Promise<T> {
    try {
      return parseJsonWithRepair(opts.rawContent, opts.parser);
    } catch {
      const recoveryPayload = {
        task: 'normalize_json_output',
        required_schema: opts.schemaHint,
        original_prompt_payload: opts.promptPayload,
        invalid_output: opts.rawContent,
      };

      const recoveryRaw = await this.callAgent({
        agentName: opts.agentName,
        callId: `${opts.callId}.repair`,
        phaseIndex: opts.phaseIndex,
        group: opts.group,
        round: opts.round,
        promptPayload: recoveryPayload,
        modeDirective: [
          'Your previous response did not validate.',
          'Return ONLY valid JSON matching the required schema.',
          'Do not add prose, markdown fences, or commentary.',
        ].join(' '),
        scaffoldPrompt: opts.scaffoldPrompt,
        tierOverride: opts.tierOverride,
        maxTokens: opts.maxTokens,
        responseFormat: opts.responseFormat,
      });

      try {
        return parseJsonWithRepair(recoveryRaw.content, opts.parser);
      } catch {
        if (opts.fallback) {
          this.recordValidationFailure(opts, 'repair_failed');
          return opts.fallback();
        }
        throw new Error('repair_failed');
      }
    }
  }

  private recordValidationFailure(
    opts: Pick<JsonRecoveryOptions<unknown>, 'callId' | 'phaseIndex' | 'group' | 'round' | 'promptPayload'>,
    reason: 'repair_failed' | 'parse_failed',
  ): void {
    this._trace.push(councilTraceEventSchema.parse({
      schema_version: COUNCIL_SCHEMA_VERSION,
      event_id: `${opts.phaseIndex}:${opts.callId}.validation`,
      phase_index: opts.phaseIndex,
      call_id: `${opts.callId}.validation`,
      group: opts.group,
      round: opts.round,
      prompt_payload_hash: hashCanonical(opts.promptPayload),
      validation_failure: reason,
    }));
  }

  private allocateIdeaId(group: CouncilGroup, round: number, index: number): string {
    return `${group}.r${round}.${String(index + 1).padStart(2, '0')}`;
  }

  private async runGroupRound(
    group: CouncilGroup,
    round: number,
    input: CouncilRunInput,
    peerBridge?: BridgePacket,
    previousBrief?: CouncilBrief,
  ): Promise<GroupRoundResult> {
    const groupConfig = this._config.groups[group];
    const groupTier = groupConfig.model_tier;
    const phaseBase = round * 10 + (group === 'D' ? 1 : 2);

    const ideationPayload = {
      input,
      group,
      round,
      profile: {
        group_prompt_prefix: groupConfig.group_prompt_prefix,
        novelty_bias: groupConfig.novelty_bias,
        risk_tolerance: groupConfig.risk_tolerance,
        forbidden_approaches: groupConfig.forbidden_approaches,
      },
      peer_bridge: peerBridge,
      previous_shortlist: previousBrief?.shortlist,
    };

    const ideation = await this.callAgent({
      agentName: groupConfig.freethinker_agent,
      callId: `${group}.r${round}.ft.ideation`,
      phaseIndex: phaseBase,
      group,
      round,
      promptPayload: ideationPayload,
      modeDirective: 'Return JSON only: {"ideas":[IdeaContent,...]}. Do not include IDs. No prose.',
      responseFormat: jsonSchemaFormat('council_ideation', councilIdeationJsonSchema),
      scaffoldPrompt: this._scaffold?.prompts[group].free_thinker,
      tierOverride: groupTier,
    });

    const ideaOutput = await this.parseWithAgentRecovery({
      rawContent: ideation.content,
      parser: (value) => ideationOutputSchema.parse(value),
      agentName: groupConfig.freethinker_agent,
      callId: `${group}.r${round}.ft.ideation`,
      phaseIndex: phaseBase,
      group,
      round,
      promptPayload: ideationPayload,
      modeDirective: 'Return JSON only: {"ideas":[IdeaContent,...]}. Do not include IDs. No prose.',
      schemaHint:
        '{"ideas":[{"title":"string","hypothesis":"string","mechanism":"string","expected_outcome":"string"}]}',
      responseFormat: jsonSchemaFormat('council_ideation', councilIdeationJsonSchema),
      scaffoldPrompt: this._scaffold?.prompts[group].free_thinker,
      tierOverride: groupTier,
      fallback: () => ({
        ideas: [
          {
            title: `${group} fallback idea`,
            hypothesis: `Fallback hypothesis for: ${input.task}`,
            mechanism: 'Run a minimum-viable experiment and measure signal within one iteration.',
            expected_outcome: 'A concrete go/no-go signal to reduce uncertainty.',
          },
        ],
      }),
    });
    const ideaCards: IdeaCard[] = ideaOutput.ideas
      .slice(0, this._config.defaults.ideas_per_round)
      .map((idea, index) => ({
        schema_version: COUNCIL_SCHEMA_VERSION,
        idea_id: this.allocateIdeaId(group, round, index),
        group,
        round,
        content: idea,
      }));

    const assessmentPayload = {
      input,
      group,
      round,
      ideas: ideaCards.map((idea) => ({ idea_id: idea.idea_id, ...idea.content })),
      peer_bridge: peerBridge,
      previous_shortlist: previousBrief?.shortlist,
    };

    const assessmentRaw = await this.callAgent({
      agentName: groupConfig.arbiter_agent,
      callId: `${group}.r${round}.arb.assess`,
      phaseIndex: phaseBase + 1,
      group,
      round,
      promptPayload: assessmentPayload,
      modeDirective:
        'Return JSON only. Assess provided idea IDs only. No new IDs. Include convergence_signal/novelty_score/repetition_rate.',
      responseFormat: jsonSchemaFormat('council_assessment', councilAssessmentJsonSchema),
      scaffoldPrompt: this._scaffold?.prompts[group].arbiter,
      tierOverride: groupTier,
    });

    const assessmentOutput = await this.parseWithAgentRecovery({
      rawContent: assessmentRaw.content,
      parser: (value) => assessmentOutputSchema.parse(value),
      agentName: groupConfig.arbiter_agent,
      callId: `${group}.r${round}.arb.assess`,
      phaseIndex: phaseBase + 1,
      group,
      round,
      promptPayload: assessmentPayload,
      modeDirective:
        'Return JSON only. Assess provided idea IDs only. No new IDs. Include convergence_signal/novelty_score/repetition_rate.',
      schemaHint:
        '{"assessments":[{"idea_id":"string","scores":{"novelty":0,"feasibility":0,"impact":0,"testability":0},"decision":"shortlist|hold|reject","notes":"string"}],"assumptions":["string"],"risks":["string"],"asks":["string"],"what_to_steal":["string"],"convergence_signal":false,"novelty_score":0,"repetition_rate":0}',
      responseFormat: jsonSchemaFormat('council_assessment', councilAssessmentJsonSchema),
      scaffoldPrompt: this._scaffold?.prompts[group].arbiter,
      tierOverride: groupTier,
      fallback: () => ({
        assessments: ideaCards.map((idea, index) => ({
          idea_id: idea.idea_id,
          scores: {
            novelty: 40,
            feasibility: 40,
            impact: 40,
            testability: 40,
          },
          decision: index === 0 ? 'shortlist' as const : 'hold' as const,
          notes: 'Fallback assessment used because model output could not be repaired into valid JSON.',
        })),
        assumptions: ['Model output formatting instability; assumptions are provisional.'],
        risks: ['Assessment quality degraded due to invalid structured output.'],
        asks: ['Re-run with a different provider or tier to confirm shortlist quality.'],
        what_to_steal: [],
        convergence_signal: false,
        novelty_score: 0,
        repetition_rate: 100,
      }),
    });
    const validIdeaIds = new Set(ideaCards.map((i) => i.idea_id));
    const assessments: IdeaAssessment[] = uniq(assessmentOutput.assessments.map((a) => a.idea_id))
      .map((ideaId) => assessmentOutput.assessments.find((a) => a.idea_id === ideaId)!)
      .filter((assessment) => validIdeaIds.has(assessment.idea_id))
      .map((assessment) => ({
        schema_version: COUNCIL_SCHEMA_VERSION,
        ...assessment,
      }));

    for (const idea of ideaCards) {
      if (!assessments.find((a) => a.idea_id === idea.idea_id)) {
        assessments.push({
          schema_version: COUNCIL_SCHEMA_VERSION,
          idea_id: idea.idea_id,
          scores: { novelty: 0, feasibility: 0, impact: 0, testability: 0 },
          decision: 'hold',
          notes: 'Missing assessment from arbiter output.',
        });
      }
    }

    const shortlist = assessments
      .filter((a) => a.decision === 'shortlist')
      .sort((a, b) => computeTotalScore(b) - computeTotalScore(a) || a.idea_id.localeCompare(b.idea_id))
      .map((a) => a.idea_id);

    const groundingPayload = {
      group,
      round,
      shortlisted_ideas: ideaCards
        .filter((idea) => shortlist.includes(idea.idea_id))
        .map((idea) => ({
          idea_id: idea.idea_id,
          ...idea.content,
        })),
      success_definition: input.success_definition,
      constraints: input.constraints,
    };

    const groundingAgent = groupConfig.grounder_agent ?? groupConfig.freethinker_agent;
    let groundingFailures = 0;
    let grounding = { grounded: [] as Array<{ idea_id: string; mve: string; constraints: string[]; falsifiability_checks: string[] }> };
    try {
      const groundingRaw = await this.callAgent({
        agentName: groundingAgent,
        callId: `${group}.r${round}.ft.ground`,
        phaseIndex: phaseBase + 2,
        group,
        round,
        promptPayload: groundingPayload,
        modeDirective:
          'Grounder mode. Return JSON only: {"grounded":[{"idea_id", "mve", "constraints", "falsifiability_checks"}]}. No prose.',
        responseFormat: jsonSchemaFormat('council_grounding', councilGroundingJsonSchema),
        scaffoldPrompt: this._scaffold?.prompts[group].grounder ?? this._scaffold?.prompts[group].free_thinker,
        tierOverride: groupTier,
      });
      grounding = await this.parseWithAgentRecovery({
        rawContent: groundingRaw.content,
        parser: (value) => groundingOutputSchema.parse(value),
        agentName: groundingAgent,
        callId: `${group}.r${round}.ft.ground`,
        phaseIndex: phaseBase + 2,
        group,
        round,
        promptPayload: groundingPayload,
        modeDirective:
          'Grounder mode. Return JSON only: {"grounded":[{"idea_id", "mve", "constraints", "falsifiability_checks"}]}. No prose.',
        schemaHint:
          '{"grounded":[{"idea_id":"string","mve":"string","constraints":["string"],"falsifiability_checks":["string"]}]}',
        responseFormat: jsonSchemaFormat('council_grounding', councilGroundingJsonSchema),
        scaffoldPrompt: this._scaffold?.prompts[group].grounder ?? this._scaffold?.prompts[group].free_thinker,
        tierOverride: groupTier,
      });
    } catch {
      groundingFailures = shortlist.length;
      if (this._config.strict_grounding) {
        throw new Error('grounding_failed');
      }
    }

    const groundingMap = new Map(grounding.grounded.map((item) => [item.idea_id, item]));
    const groundedIdeas = ideaCards.map((idea) => {
      const grounded = groundingMap.get(idea.idea_id);
      if (!grounded) {
        if (shortlist.includes(idea.idea_id)) {
          groundingFailures += 1;
        }
        return {
          ...idea,
          grounding_failed: shortlist.includes(idea.idea_id),
        };
      }
      return {
        ...idea,
        grounding: {
          mve: grounded.mve,
          constraints: grounded.constraints,
          falsifiability_checks: grounded.falsifiability_checks,
        },
      };
    });

    const brief = councilBriefSchema.parse({
      schema_version: COUNCIL_SCHEMA_VERSION,
      group,
      round,
      ideas: groundedIdeas,
      assessments,
      shortlist,
      assumptions: assessmentOutput.assumptions,
      risks: assessmentOutput.risks,
      asks: assessmentOutput.asks,
      what_to_steal: assessmentOutput.what_to_steal,
      convergence_signal: assessmentOutput.convergence_signal,
      novelty_score: assessmentOutput.novelty_score,
      repetition_rate: assessmentOutput.repetition_rate,
    });

    const convergenceQualified = previousBrief
      ? this.evaluateConvergence(previousBrief, brief)
      : false;

    return {
      brief,
      convergenceQualified,
      groundingFailures,
    };
  }

  private evaluateConvergence(previousBrief: CouncilBrief, currentBrief: CouncilBrief): boolean {
    if (currentBrief.round < 2) {
      return false;
    }

    const stableShortlist = previousBrief.shortlist.join('|') === currentBrief.shortlist.join('|');
    const noveltyDelta = Math.abs(previousBrief.novelty_score - currentBrief.novelty_score);
    const lowNoveltyDelta = noveltyDelta <= this._config.defaults.novelty_delta_threshold;
    const highRepetition = currentBrief.repetition_rate >= this._config.defaults.repetition_threshold;
    const deterministicSignal = stableShortlist || lowNoveltyDelta || highRepetition;

    return currentBrief.convergence_signal && deterministicSignal;
  }

  private enforceBridgeCaps(packet: BridgePacket): void {
    const defaults = this._config.defaults;
    if (packet.top_ideas.length > defaults.top_ideas_for_bridge) {
      throw new Error('cap_exceeded');
    }

    const bulletFields = [packet.assumptions, packet.risks, packet.asks, packet.what_to_steal];
    for (const value of bulletFields) {
      if (value.length > defaults.bridge_field_max_bullets) {
        throw new Error('cap_exceeded');
      }
      if (value.some((v) => v.length > defaults.bridge_entry_max_chars)) {
        throw new Error('cap_exceeded');
      }
    }

    if (packet.top_ideas.some((idea) => idea.mechanism.length > defaults.bridge_entry_max_chars)) {
      throw new Error('cap_exceeded');
    }

    const total = canonicalStringify(packet).length;
    if (total > defaults.bridge_packet_max_chars) {
      throw new Error('cap_exceeded');
    }
  }

  private buildBridgePacket(fromBrief: CouncilBrief, toGroup: CouncilGroup): BridgePacket {
    const assessmentMap = new Map(fromBrief.assessments.map((a) => [a.idea_id, a]));
    const topIdeas = fromBrief.shortlist
      .slice(0, this._config.defaults.top_ideas_for_bridge)
      .map((ideaId) => {
        const idea = fromBrief.ideas.find((i) => i.idea_id === ideaId);
        const assessment = assessmentMap.get(ideaId);
        if (!idea || !assessment) {
          throw new Error(`unknown_id:${ideaId}`);
        }
        return {
          idea_id: idea.idea_id,
          mechanism: idea.content.mechanism,
          rationale: assessment.notes,
        };
      });

    const packet = bridgePacketSchema.parse({
      schema_version: COUNCIL_SCHEMA_VERSION,
      from_group: fromBrief.group,
      to_group: toGroup,
      round: fromBrief.round,
      top_ideas: topIdeas,
      assumptions: fromBrief.assumptions,
      risks: fromBrief.risks,
      asks: fromBrief.asks,
      what_to_steal: fromBrief.what_to_steal,
    });

    this.enforceBridgeCaps(packet);
    return packet;
  }

  private async runMetaMerge(
    input: CouncilRunInput,
    briefD: CouncilBrief,
    briefP: CouncilBrief,
  ): Promise<ReturnType<typeof metaSelectionSchema.parse>> {
    const metaAgentName = this._config.meta_arbiter_agent;
    const payload = {
      input,
      brief_D: briefD,
      brief_P: briefP,
      instructions: {
        no_novel_mechanisms: true,
        require_source_ids: true,
      },
    };

    const metaRaw = await this.callAgent({
      agentName: metaAgentName,
      callId: 'meta.merge',
      phaseIndex: 999,
      promptPayload: payload,
      modeDirective:
        'Return JSON only following schema with selected_primary/selected_secondary/merges/rejections/open_questions/next_experiments. Use only known idea IDs.',
      responseFormat: jsonSchemaFormat('council_meta', councilMetaJsonSchema),
      scaffoldPrompt: this._scaffold?.prompts.meta_arbiter,
      tierOverride: this._config.meta_model_tier,
    });

    return this.parseWithAgentRecovery<ReturnType<typeof metaSelectionSchema.parse>>({
      rawContent: metaRaw.content,
      parser: (value) => metaSelectionSchema.parse(value),
      agentName: metaAgentName,
      callId: 'meta.merge',
      phaseIndex: 999,
      promptPayload: payload,
      modeDirective:
        'Return JSON only following schema with selected_primary/selected_secondary/merges/rejections/open_questions/next_experiments. Use only known idea IDs.',
      schemaHint:
        '{"schema_version":"1.0.0","selected_primary":["string"],"selected_secondary":["string"],"merges":[{"sources":["string","string"],"result_title":"string","rationale":"string"}],"rejections":[{"idea_id":"string","reason_code":"low_score|high_risk|insufficient_grounding|duplicate|out_of_scope|unknown_id|other"}],"open_questions":["string"],"next_experiments":["string"]}',
      responseFormat: jsonSchemaFormat('council_meta', councilMetaJsonSchema),
      scaffoldPrompt: this._scaffold?.prompts.meta_arbiter,
      tierOverride: this._config.meta_model_tier,
      fallback: () => metaSelectionSchema.parse({
        schema_version: COUNCIL_SCHEMA_VERSION,
        selected_primary: briefD.shortlist.slice(0, 1),
        selected_secondary: briefP.shortlist.slice(0, 1),
        merges: [],
        rejections: [],
        open_questions: ['Meta merge used fallback due to invalid structured output.'],
        next_experiments: [
          'Re-run council with a lower-latency/stronger JSON model and compare selected ideas.',
        ],
      }),
    });
  }

  private validateMetaSelection(meta: ReturnType<typeof metaSelectionSchema.parse>, knownIds: Set<string>): boolean {
    for (const id of [...meta.selected_primary, ...meta.selected_secondary]) {
      if (!knownIds.has(id)) {
        return false;
      }
    }

    for (const rej of meta.rejections) {
      if (!knownIds.has(rej.idea_id)) {
        return false;
      }
    }

    if (meta.merges) {
      for (const merge of meta.merges) {
        if (merge.sources.some((source) => !knownIds.has(source))) {
          return false;
        }
      }
    }

    return true;
  }

  private getSortedTrace(): CouncilTraceEvent[] {
    return [...this._trace]
      .sort((a, b) => a.phase_index - b.phase_index || a.call_id.localeCompare(b.call_id))
      .map((event) => councilTraceEventSchema.parse({
        ...event,
        artifact_hash: normalizeOptional(event.artifact_hash),
        group: normalizeOptional(event.group),
        round: normalizeOptional(event.round),
        token_usage: normalizeOptional(event.token_usage),
        latency_ms: normalizeOptional(event.latency_ms),
        dropped_reason: normalizeOptional(event.dropped_reason),
        validation_failure: normalizeOptional(event.validation_failure),
      }));
  }

  private getSortedConversations(): CouncilConversationTurn[] {
    return [...this._conversations]
      .sort((a, b) => a.phase_index - b.phase_index || a.call_id.localeCompare(b.call_id))
      .map((entry) => ({
        ...entry,
        prompt_payload: JSON.parse(canonicalStringify(entry.prompt_payload)) as Record<string, unknown>,
      }));
  }
}

export function createCouncilsOrchestrator(deps: {
  registry: AgentConfigRegistry;
  orchestrator: AgentOrchestrator;
  config: CouncilsConfig;
}): CouncilsOrchestrator {
  return new CouncilsOrchestrator({
    registry: deps.registry,
    orchestrator: deps.orchestrator,
    config: deps.config,
  });
}
