import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "./arena";
import { BULL_CHARGE, BULL_POUNCE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool";
import { angularDistance, moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
import type { BossMotionState, MemberId, PartyCombatKitId, PartyMember, WorldPosition } from "./types";

export type AiMemberId = Exclude<MemberId, "aelia">;

export interface PartyBehaviorContext {
  memberId: AiMemberId;
  combatKitId: PartyCombatKitId;
  current: WorldPosition;
  formationTarget: WorldPosition;
  bossMotion: BossMotionState;
  bossMotions: readonly BossMotionState[];
  partyPositions: Record<MemberId, WorldPosition>;
  time: number;
}

export interface PartyBehaviorDecision {
  target: WorldPosition;
  speed: number;
}

export interface PartyBehavior {
  id: string;
  decide: (context: PartyBehaviorContext) => PartyBehaviorDecision | null;
}

const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"];
const LEGACY_COMBAT_KITS: Record<AiMemberId, PartyCombatKitId> = {
  brann: "tank",
  nia: "ranged",
  orin: "caster",
  vale: "melee",
};
const MOVE_SPEEDS: Record<PartyCombatKitId, number> = { tank: 1.45, ranged: 1.2, caster: 1.1, melee: 2.2 };
const EVADE_SIDES: Record<AiMemberId, -1 | 1> = { brann: -1, nia: -1, orin: 1, vale: 1 };
const HAZARD_LOOKAHEAD = 2.2;
const CIRCLE_HAZARD_CLEARANCE = 0.55;
const LANE_HAZARD_CLEARANCE = 0.7;
const STACK_EDGE_PADDING = 0.35;
const STACK_SAMPLE_RINGS = 3;
const STACK_SAMPLES_PER_RING = 24;
const STACK_CENTER_OFFSET: WorldPosition = [0, 0];
const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
  brann: [-0.55, 0],
  nia: [0.45, 0.4],
  orin: [0.45, -0.4],
  vale: [0, 0.65],
};
const LANE_EVADE_MODES: readonly BossMotionState["mode"][] = [
  "mantis_line_telegraph",
  "mantis_cross_telegraph",
];
const FORMATION_MODES: readonly BossMotionState["mode"][] = [
  "holding", "telegraph", "tethering", "venom_cast", "skyfall", "cinderback_slam", "sandglass_hourglass", "golem_shockwave", "golem_crownfall",
];
const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging"];

const FORMATION_LATERAL_OFFSETS: Record<AiMemberId, number> = { brann: 0, nia: -3.3, orin: 3.3, vale: 0 };
const FORMATION_DEPTHS: Record<PartyCombatKitId, number> = {
  // Bosses face tanks during normal uptime, making positive Z their front.
  tank: 4.25,
  ranged: 7.2,
  caster: 7.2,
  melee: -1.7,
};

function combatKit(memberId: AiMemberId, party?: readonly PartyMember[]): PartyCombatKitId {
  return party?.find((member) => member.id === memberId)?.runProfile?.combatKitId ?? LEGACY_COMBAT_KITS[memberId];
}

export function combatFormation(boss: WorldPosition, party?: readonly PartyMember[]): Record<AiMemberId, WorldPosition> {
  const formation = {} as Record<AiMemberId, WorldPosition>;
  for (const memberId of AI_MEMBER_IDS) {
    formation[memberId] = [
      boss[0] + FORMATION_LATERAL_OFFSETS[memberId],
      boss[1] + FORMATION_DEPTHS[combatKit(memberId, party)],
    ];
  }
  return formation;
}

function circleDanger(
  x: number,
  z: number,
  center: WorldPosition,
  radius: number,
  innerRadius = 0,
) {
  const distance = Math.hypot(x - center[0], z - center[1]);
  const unsafeInnerRadius = Math.max(0, innerRadius - CIRCLE_HAZARD_CLEARANCE);
  const unsafeOuterRadius = radius + CIRCLE_HAZARD_CLEARANCE;
  if (distance >= unsafeOuterRadius || (unsafeInnerRadius > 0 && distance <= unsafeInnerRadius)) return 0;
  const penetration = unsafeInnerRadius > 0
    ? Math.min(distance - unsafeInnerRadius, unsafeOuterRadius - distance)
    : unsafeOuterRadius - distance;
  return 1 + Math.max(0, penetration);
}

function laneDanger(x: number, z: number, start: WorldPosition, end: WorldPosition, width: number) {
  const clearance = width * 0.5 + LANE_HAZARD_CLEARANCE;
  const laneX = end[0] - start[0];
  const laneZ = end[1] - start[1];
  const lengthSquared = laneX * laneX + laneZ * laneZ;
  const projection = lengthSquared < 0.0001
    ? 0
    : Math.max(0, Math.min(1, ((x - start[0]) * laneX + (z - start[1]) * laneZ) / lengthSquared));
  const distance = Math.hypot(x - (start[0] + projection * laneX), z - (start[1] + projection * laneZ));
  return distance < clearance ? 1 + clearance - distance : 0;
}

function bossDangerAt(x: number, z: number, bossMotions: readonly BossMotionState[], time: number) {
  let danger = 0;
  for (const motion of bossMotions) {
    for (const hazard of motion.hazards) {
      if (hazard.resolved || hazard.expiresAt <= time || hazard.activatesAt - time > HAZARD_LOOKAHEAD) continue;
      danger += circleDanger(x, z, hazard.center, hazard.radius, hazard.innerRadius);
    }

    for (const telegraph of motion.poolTelegraphs) {
      if (telegraph.resolved || telegraph.expiresAt <= time || telegraph.activatesAt - time > HAZARD_LOOKAHEAD) continue;
      if (telegraph.kind === "soak" || telegraph.kind === "memory" || telegraph.kind === "soul-siphon") continue;
      if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
        danger += laneDanger(x, z, telegraph.start, telegraph.end, telegraph.width ?? 0);
      } else {
        danger += circleDanger(x, z, telegraph.center, telegraph.radius, telegraph.innerRadius);
      }
    }

    for (const lane of motion.slashLanes) {
      danger += laneDanger(x, z, lane.start, lane.end, lane.width);
    }

    if (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping") {
      const dx = x - motion.position[0];
      const dz = z - motion.position[1];
      const distance = Math.hypot(dx, dz);
      const angle = Math.atan2(dx, dz);
      const breathAngle = motion.mode === "breath_telegraph" ? motion.breathStartAngle : motion.breathAngle;
      const radialPenetration = SKY_SWEEPER_BREATH.range + CIRCLE_HAZARD_CLEARANCE - distance;
      const angularPenetration = SKY_SWEEPER_BREATH.halfAngle + 0.08 - angularDistance(angle, breathAngle);
      if (radialPenetration > 0 && angularPenetration > 0) {
        danger += 1 + Math.min(radialPenetration, angularPenetration * Math.max(1, distance));
      }
    }
  }
  return danger;
}

function safeStackTarget(
  memberId: AiMemberId,
  current: WorldPosition,
  center: WorldPosition,
  preferredOffset: WorldPosition,
  stackRadius: number,
  bossMotions: readonly BossMotionState[],
  time: number,
) {
  const preferred = clampToArena([center[0] + preferredOffset[0], center[1] + preferredOffset[1]]);
  const preferredDanger = bossDangerAt(preferred[0], preferred[1], bossMotions, time);
  if (preferredDanger === 0) return preferred;

  const insideRadius = Math.max(0, stackRadius - STACK_EDGE_PADDING);
  let bestX = preferred[0];
  let bestZ = preferred[1];
  let bestScore = preferredDanger * 1_000
    + Math.hypot(bestX - current[0], bestZ - current[1]) * 0.05;
  const angleStep = Math.PI * 2 / STACK_SAMPLES_PER_RING;
  const memberPhase = AI_MEMBER_IDS.indexOf(memberId) * angleStep * 0.5;

  for (let ring = 1; ring <= STACK_SAMPLE_RINGS; ring += 1) {
    const sampleRadius = insideRadius * ring / STACK_SAMPLE_RINGS;
    for (let sample = 0; sample < STACK_SAMPLES_PER_RING; sample += 1) {
      const angle = memberPhase + sample * angleStep;
      let candidateX = center[0] + Math.sin(angle) * sampleRadius;
      let candidateZ = center[1] + Math.cos(angle) * sampleRadius;
      const arenaX = candidateX - ARENA_CENTER[0];
      const arenaZ = candidateZ - ARENA_CENTER[1];
      const arenaDistance = Math.hypot(arenaX, arenaZ);
      if (arenaDistance > ARENA_RADIUS) {
        const scale = ARENA_RADIUS / arenaDistance;
        candidateX = ARENA_CENTER[0] + arenaX * scale;
        candidateZ = ARENA_CENTER[1] + arenaZ * scale;
      }

      const danger = bossDangerAt(candidateX, candidateZ, bossMotions, time);
      const preferredDistance = Math.hypot(candidateX - preferred[0], candidateZ - preferred[1]);
      const travelDistance = Math.hypot(candidateX - current[0], candidateZ - current[1]);
      const score = danger * 1_000 + preferredDistance + travelDistance * 0.05;
      if (score >= bestScore) continue;
      bestX = candidateX;
      bestZ = candidateZ;
      bestScore = score;
    }
  }

  return [bestX, bestZ] as WorldPosition;
}

export const stackForPounceBehavior: PartyBehavior = {
  id: "stack-for-pounce",
  decide: ({ memberId, current, bossMotion, bossMotions, time }) => {
    if (bossMotion.mode !== "stacking") return null;
    const offset = memberId === bossMotion.pounceTargetId ? STACK_CENTER_OFFSET : STACK_OFFSETS[memberId];
    return {
      target: safeStackTarget(memberId, current, bossMotion.pounceCenter, offset, BULL_POUNCE.stackRadius, bossMotions, time),
      speed: 3.4,
    };
  },
};

export const stackForPooledSoakBehavior: PartyBehavior = {
  id: "stack-for-pooled-soak",
  decide: ({ memberId, bossMotion, time }) => {
    const soak = bossMotion.poolTelegraphs.find((telegraph) =>
      telegraph.kind === "soak" && !telegraph.resolved && telegraph.activatesAt > time && telegraph.activatesAt - time <= 2.2,
    );
    if (!soak) return null;
    const offset = STACK_OFFSETS[memberId];
    return {
      target: [soak.center[0] + offset[0], soak.center[1] + offset[1]],
      speed: 3.8,
    };
  },
};

export const breakTetherBehavior: PartyBehavior = {
  id: "break-tether",
  decide: ({ memberId, current, bossMotion, partyPositions }) => {
    if (bossMotion.mode !== "tethering" || !bossMotion.tetherIds.includes(memberId)) return null;
    const otherId = bossMotion.tetherIds.find((id) => id !== memberId);
    if (!otherId) return null;
    const other = partyPositions[otherId];
    const dx = current[0] - other[0];
    const dz = current[1] - other[1];
    const length = Math.hypot(dx, dz);
    const fallback = EVADE_SIDES[memberId] * Math.PI * 0.5;
    const target = length < 0.01
      ? [current[0] + Math.sin(fallback) * bossMotion.tetherBreakDistance, current[1] + Math.cos(fallback) * bossMotion.tetherBreakDistance] as WorldPosition
      : [current[0] + (dx / length) * bossMotion.tetherBreakDistance, current[1] + (dz / length) * bossMotion.tetherBreakDistance] as WorldPosition;
    return { target: clampToArena(target), speed: 3.7 };
  },
};

export const evadeChargeBehavior: PartyBehavior = {
  id: "evade-charge",
  decide: ({ memberId, current, formationTarget, bossMotion }) => {
    if (bossMotion.mode !== "telegraph" && bossMotion.mode !== "charging") return null;
    const { chargeStart, chargeEnd } = bossMotion;
    const currentUnsafe = pointToSegmentDistance(current, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance;
    const formationUnsafe = pointToSegmentDistance(formationTarget, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance;
    if (!currentUnsafe && !formationUnsafe) return null;

    // Derive from the stable formation slot so the target cannot flip sides as the member moves.
    const evadeTarget = pointOutsideLane(
      formationTarget,
      chargeStart,
      chargeEnd,
      BULL_CHARGE.aiClearance,
      EVADE_SIDES[memberId],
    );
    return { target: clampToArena(evadeTarget), speed: BULL_CHARGE.aiEvadeSpeed };
  },
};

export const avoidBreathBehavior: PartyBehavior = {
  id: "avoid-breath",
  decide: ({ memberId, combatKitId, bossMotion }) => {
    if (bossMotion.mode !== "breath_telegraph" && bossMotion.mode !== "breath_sweeping") return null;
    const side = EVADE_SIDES[memberId];
    const safeAngle = bossMotion.breathAngle + side * (SKY_SWEEPER_BREATH.halfAngle + Math.PI * 0.42);
    const radius = combatKitId === "tank" ? 4.1 : combatKitId === "melee" ? 3.5 : 5.4;
    return {
      target: clampToArena([
        bossMotion.position[0] + Math.sin(safeAngle) * radius,
        bossMotion.position[1] + Math.cos(safeAngle) * radius,
      ]),
      speed: 4.1,
    };
  },
};

export const evadeSlashLanesBehavior: PartyBehavior = {
  id: "evade-slash-lanes",
  decide: ({ memberId, current, formationTarget, bossMotion }) => {
    if (!LANE_EVADE_MODES.includes(bossMotion.mode)) return null;
    if (!bossMotion.slashLanes.length) return null;
    const unsafe = (position: WorldPosition) => bossMotion.slashLanes.some((lane) =>
      pointToSegmentDistance(position, lane.start, lane.end) < lane.width * 0.5 + 0.7,
    );
    if (!unsafe(current) && !unsafe(formationTarget)) return null;

    const origin = unsafe(formationTarget) ? formationTarget : current;
    const memberOffset = AI_MEMBER_IDS.indexOf(memberId) * (Math.PI / 4);
    let best = current;
    let bestScore = Number.NEGATIVE_INFINITY;
    for (const radius of [2.2, 3.4]) {
      for (let index = 0; index < 8; index += 1) {
        const angle = memberOffset + (index / 8) * Math.PI * 2;
        const candidate = clampToArena([
          origin[0] + Math.sin(angle) * radius,
          origin[1] + Math.cos(angle) * radius,
        ]);
        let laneDistance = Number.POSITIVE_INFINITY;
        for (const lane of bossMotion.slashLanes) {
          laneDistance = Math.min(laneDistance, pointToSegmentDistance(candidate, lane.start, lane.end));
        }
        const travelPenalty = Math.hypot(candidate[0] - current[0], candidate[1] - current[1]) * 0.08;
        const score = laneDistance - travelPenalty;
        if (score > bestScore) {
          best = candidate;
          bestScore = score;
        }
      }
    }
    return { target: best, speed: 4.8 };
  },
};

export const avoidCircleHazardsBehavior: PartyBehavior = {
  id: "avoid-circle-hazards",
  decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
    for (let index = 0; index < bossMotion.hazards.length; index += 1) {
      const hazard = bossMotion.hazards[index];
      if (hazard.expiresAt <= time || hazard.activatesAt - time > 2.2) continue;
      const clearance = hazard.radius + 0.55;
      const currentUnsafe = Math.hypot(current[0] - hazard.center[0], current[1] - hazard.center[1]) < clearance;
      const formationUnsafe = Math.hypot(formationTarget[0] - hazard.center[0], formationTarget[1] - hazard.center[1]) < clearance;
      if (!currentUnsafe && !formationUnsafe) continue;
      const source = formationUnsafe ? formationTarget : current;
      const fallbackAngle = (AI_MEMBER_IDS.indexOf(memberId) / AI_MEMBER_IDS.length) * Math.PI * 2;
      return { target: clampToArena(pointOutsideCircle(source, hazard.center, clearance, fallbackAngle)), speed: 4 };
    }
    return null;
  },
};

export const reactToPooledTelegraphsBehavior: PartyBehavior = {
  id: "react-to-pooled-telegraphs",
  decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
    for (const telegraph of bossMotion.poolTelegraphs) {
      if (telegraph.resolved || telegraph.kind === "soak" || telegraph.kind === "memory" || telegraph.activatesAt - time > 2.2) continue;
      const fallbackAngle = (AI_MEMBER_IDS.indexOf(memberId) / AI_MEMBER_IDS.length) * Math.PI * 2;
      if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
        const clearance = (telegraph.width ?? 0) * 0.5 + 0.7;
        const currentUnsafe = pointToSegmentDistance(current, telegraph.start, telegraph.end) < clearance;
        const formationUnsafe = pointToSegmentDistance(formationTarget, telegraph.start, telegraph.end) < clearance;
        if (!currentUnsafe && !formationUnsafe) continue;
        const source = formationUnsafe ? formationTarget : current;
        return {
          target: clampToArena(pointOutsideLane(source, telegraph.start, telegraph.end, clearance, EVADE_SIDES[memberId])),
          speed: 4.8,
        };
      }

      const distanceFromCenter = (position: WorldPosition) => Math.hypot(position[0] - telegraph.center[0], position[1] - telegraph.center[1]);
      if (telegraph.kind === "donut") {
        const innerSafeRadius = Math.max(0.5, (telegraph.innerRadius ?? 0) - 0.45);
        const isUnsafe = (position: WorldPosition) => {
          const distance = distanceFromCenter(position);
          return distance >= innerSafeRadius && distance <= telegraph.radius + 0.45;
        };
        if (!isUnsafe(current) && !isUnsafe(formationTarget)) continue;
        const source = isUnsafe(formationTarget) ? formationTarget : current;
        const sourceDistance = distanceFromCenter(source);
        const direction = sourceDistance > 0.01
          ? [(source[0] - telegraph.center[0]) / sourceDistance, (source[1] - telegraph.center[1]) / sourceDistance] as WorldPosition
          : [Math.sin(fallbackAngle), Math.cos(fallbackAngle)] as WorldPosition;
        return {
          target: clampToArena([
            telegraph.center[0] + direction[0] * innerSafeRadius,
            telegraph.center[1] + direction[1] * innerSafeRadius,
          ]),
          speed: 4.3,
        };
      }

      const clearance = telegraph.radius + 0.55;
      const currentUnsafe = distanceFromCenter(current) < clearance;
      const formationUnsafe = distanceFromCenter(formationTarget) < clearance;
      if (!currentUnsafe && !formationUnsafe) continue;
      const source = formationUnsafe ? formationTarget : current;
      return { target: clampToArena(pointOutsideCircle(source, telegraph.center, clearance, fallbackAngle)), speed: 4.1 };
    }
    return null;
  },
};

export const maintainFormationBehavior: PartyBehavior = {
  id: "maintain-formation",
  decide: ({ formationTarget, bossMotion, combatKitId }) => {
    if (!FORMATION_MODES.includes(bossMotion.mode)) return null;
    return { target: formationTarget, speed: MOVE_SPEEDS[combatKitId] };
  },
};

export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [
  breakTetherBehavior,
  stackForPounceBehavior,
  stackForPooledSoakBehavior,
  evadeChargeBehavior,
  evadeSlashLanesBehavior,
  avoidBreathBehavior,
  reactToPooledTelegraphsBehavior,
  avoidCircleHazardsBehavior,
  maintainFormationBehavior,
];

export function updatePartyPositions(
  current: Record<MemberId, WorldPosition>,
  bossMotionOrMotions: BossMotionState | readonly BossMotionState[],
  party: PartyMember[],
  time: number,
  delta: number,
  behaviors: readonly PartyBehavior[] = DEFAULT_PARTY_BEHAVIORS,
  moveSpeedMultipliers?: Partial<Record<AiMemberId, number>>,
) {
  const bossMotions = Array.isArray(bossMotionOrMotions) ? bossMotionOrMotions : [bossMotionOrMotions];
  const activeMotions = bossMotions;
  const next: Record<MemberId, WorldPosition> = {
    aelia: [current.aelia[0], current.aelia[1]],
    brann: [current.brann[0], current.brann[1]],
    nia: [current.nia[0], current.nia[1]],
    orin: [current.orin[0], current.orin[1]],
    vale: [current.vale[0], current.vale[1]],
  };
  if (!activeMotions.length) return next;
  let formationX = 0;
  let formationZ = 0;
  for (const motion of activeMotions) {
    const origin = DASH_MODES.includes(motion.mode) ? motion.chargeStart : motion.position;
    formationX += origin[0];
    formationZ += origin[1];
  }
  const formationOrigin: WorldPosition = [formationX / activeMotions.length, formationZ / activeMotions.length];
  const formation = combatFormation(formationOrigin, party);

  for (const memberId of AI_MEMBER_IDS) {
    const member = party.find((candidate) => candidate.id === memberId);
    if (!member || member.hp <= 0 || member.knockedUntil > time) continue;
    const combatKitId = combatKit(memberId, party);
    for (const behavior of behaviors) {
      let handled = false;
      for (const bossMotion of activeMotions) {
        const decision = behavior.decide({
          memberId,
          combatKitId,
          current: next[memberId],
          formationTarget: formation[memberId],
          bossMotion,
          bossMotions: activeMotions,
          partyPositions: next,
          time,
        });
        if (!decision) continue;
        next[memberId] = clampToArena(moveToward(next[memberId], decision.target, decision.speed * (moveSpeedMultipliers?.[memberId] ?? 1) * delta));
        handled = true;
        break;
      }
      if (handled) break;
    }
  }
  for (const memberId of AI_MEMBER_IDS) next[memberId] = clampToArena(next[memberId]);
  next.aelia = clampToArena(next.aelia);
  return next;
}
