/* eslint-disable jsdoc/check-param-names */
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import Axis from './lib/axis.js';
import OSR2Model from './lib/models/osr2/osr2.js';
import SR6Model from './lib/models/sr6/sr6.js';
import SSR1Model from './lib/models/ssr1/ssr1.js';
import { forEachMesh } from './lib/util.js';

const deg2rad = Math.PI / 180;

const modelInfoByType = {
  SR6: {
    model: 'SR6',
    constructor: SR6Model,
    cameraSpherical: new THREE.Spherical(600, 40 * deg2rad, 60 * deg2rad),
    controlTarget: new THREE.Vector3(-62.330470689438, 28.868408052774832, -17.219160432091634),
    controlTargetOrigin: new THREE.Vector3(0, 0, 55),
    center: new THREE.Vector3(0, 212.4, -15.0),
    scaleFactor: { L0: 39, L1: 20, L2: 29.8, R0: 60, R1: 15, R2: 25 },
    primaryAxes: ['L0', 'L1', 'L2', 'R1', 'R2'] as const,
  },

  OSR2: {
    model: 'OSR2',
    constructor: OSR2Model,
    cameraSpherical: new THREE.Spherical(600, 40 * deg2rad, 60 * deg2rad),
    controlTarget: new THREE.Vector3(-36.81235747311321, 6.186822929643268, 15.874049352801714),
    controlTargetOrigin: new THREE.Vector3(0, 0, 65),
    center: new THREE.Vector3(0, 156.5, -5.0),
    scaleFactor: { L0: 93, L1: 0, L2: 0, R0: 60, R1: 1, R2: 1 },
    primaryAxes: ['L0', 'R1', 'R2'] as const,
  },

  SSR1: {
    model: 'SSR1',
    constructor: SSR1Model,
    cameraSpherical: new THREE.Spherical(440, 40 * deg2rad, 60 * deg2rad),
    controlTarget: new THREE.Vector3(13.385718044675617, -66.86882881039317, 111.35424951515378),
    controlTargetOrigin: new THREE.Vector3(0, 0, 111),
    center: new THREE.Vector3(0, 0, 60),
    scaleFactor: { L0: 60, L1: 0, L2: 0, R0: 60, R1: 0, R2: 0 },
    primaryAxes: ['L0'] as const,
  },
};

export type modelType = keyof typeof modelInfoByType;

const COMMAND_REGEX = /^([LRVA]\d)(\d+)$/;
const COMMAND_EXTENSION_REGEX = /^([LRVA]\d)(\d+)(I|S)(\d+)$/;
const SAVE_PREFERENCE_REGEX = /^\$(?<axis>[LRVA]\d)-(?<min>\d+)-(?<max>\d+)$/;
const CUSTOM_COMMAND_REGEX = /^#([^\s:]+)(?::(?:"([^"]*)"|([^"\s]*)))?$/;

const AXIS_NAMES: Record<axisId, string> = {
  L0: 'Up',
  L1: 'Left',
  L2: 'Forward',
  R0: 'Twist',
  R1: 'Roll',
  R2: 'Pitch',
  V0: 'Vibe1',
  V1: 'Vibe2',
  A0: 'Valve',
  A1: 'Suck',
};

export type axisId = `${'L' | 'R'}${'0' | '1' | '2'}` | `${'V' | 'A'}${'0' | '1'}`;
type modelVariant =
  | 'Twist' & { addAxis?: 'R0' }
  | 'Vibes' & { addAxis?: 'V0' | 'V1' }
  | 'Valve' & { addAxis?: 'A0' }
  | 'Suck' & { addAxis?: 'A1' };
type show<T> = T extends infer U ? U & unknown : never;

type modelAxisId<Model extends modelType, ModelVariant extends modelVariant> = {
  SR6: 'L0' | 'L1' | 'L2' | 'R1' | 'R2';
  OSR2: 'L0' | 'R1' | 'R2';
  SSR1: 'L0';
  Twist: 'R0';
  Vibes: 'V0' | 'V1';
  Valve: 'A0';
  Suck: 'A1';
}[Model | ModelVariant] & axisId;

type emulatorAxisId<Emulator extends OSREmulator<any, any>> =
  | Emulator extends OSREmulator<infer Model, infer ModelVariant> ? modelAxisId<Model, ModelVariant> : never;

/**
 * TCode Command Specification:
 *
 * Device Commands:
 * - D0: device/firmware info
 * - D1: TCode version response
 * - D2: load response for each axis
 * - DSTOP: stop moving
 *
 * User Preferences (stored for reading with D2):
 * - $<axis>-<min>-<max>: save axis range preferences
 *
 * Custom Commands:
 * - #<command_name><custom_value>?
 * - custom_value: "" | ":<value>" | ':"<quoted_value>"'
 */
export type Command<axis extends string> =
  | `${axis}${number}`
  | `${axis}${number}${'' | `${'I' | 'S'}${number}`}`
  | 'D0' | 'D1' | 'D2' | 'DSTOP'
  | `$${axis}-${number}-${number}`
  | `#${string}${'' | `:${string}`}`;

class OSREmulator<Model extends modelType = modelType, ModelVariant extends modelVariant = 'Twist'> {
  _buffer = '';

  _axisEmulator: Record<axisId, Axis> = {
    L0: new Axis('L0'), // Stroke
    L1: new Axis('L1'), // Forward
    L2: new Axis('L2'), // Left
    R0: new Axis('R0'), // Twist
    R1: new Axis('R1'), // Roll
    R2: new Axis('R2'), // Pitch

    V0: new Axis('V0'), // Vibe1
    V1: new Axis('V1'), // Vibe2
    A0: new Axis('A0'), // Valve
    A1: new Axis('A1'), // Suck
  };

  _scale: Record<axisId, number> = {
    L0: 1, // Stroke
    L1: 1, // Forward
    L2: 1, // Left
    R0: 1, // Twist
    R1: 1, // Roll
    R2: 1, // Pitch

    V0: 1, // Vibe1
    V1: 1, // Vibe2
    A0: 1, // Valve
    A1: 1, // Suck
  };

  _userPreferences: Record<axisId, { min: number; max: number }> = {
    L0: { min: 0, max: 9999 },
    L1: { min: 0, max: 9999 },
    L2: { min: 0, max: 9999 },
    R0: { min: 0, max: 9999 },
    R1: { min: 0, max: 9999 },
    R2: { min: 0, max: 9999 },

    V0: { min: 0, max: 9999 },
    V1: { min: 0, max: 9999 },
    A0: { min: 0, max: 9999 },
    A1: { min: 0, max: 9999 },
  };

  _element;
  _osrModel!: InstanceType<typeof modelInfoByType[Model]['constructor']>;
  _modelInfo: typeof modelInfoByType[Model];
  _modelType: modelType;
  _sceneHelpers: (THREE.DirectionalLightHelper | THREE.CameraHelper | THREE.PointLightHelper | THREE.AxesHelper)[] | null;
  _objectHelpers: { object: THREE.Object3D; helper: THREE.Object3D }[] | null;
  _modelVariants: ModelVariant[];
  _cameraView: 'front' | undefined;
  _center: THREE.Vector3 = new THREE.Vector3(0, 0, 0);

  _resizeObserver!: ResizeObserver;
  _boundResizeListener!: () => void;
  _animationFrameRequestId!: number;

  keyLight!: THREE.PointLight;
  fillLight!: THREE.DirectionalLight;
  backLight!: THREE.PointLight;
  ambientLight!: THREE.AmbientLight;

  camera!: THREE.PerspectiveCamera;
  renderer!: THREE.WebGLRenderer | undefined;
  scene!: THREE.Scene;
  controls!: OrbitControls;

  customCommands: Record<string, (this: OSREmulator<Model, ModelVariant>, value: string) => string | string[] | undefined> = {};

  get osrModel() {
    return this._osrModel;
  }

  get availableAxes(): emulatorAxisId<this>[] {
    const axes: string[] = modelInfoByType[this._modelType].primaryAxes.slice();
    if (this._modelVariants.includes('Twist' as any))
      axes.push('R0');
    axes.sort();
    if (this._modelVariants.includes('Vibes' as any))
      axes.push('V0', 'V1');
    if (this._modelVariants.includes('Valve' as any))
      axes.push('A0');
    if (this._modelVariants.includes('Suck' as any))
      axes.push('A1');

    return axes as emulatorAxisId<this>[];
  }

  /**
   * Get a map of axis name to current decimal value (between zero and one).
   */
  get axes(): Record<emulatorAxisId<this>, number> {
    return Object.fromEntries(
      this.availableAxes
        .map(axis => [axis, this._axisEmulator[axis].getPosition() / 10000]),
    ) as Record<emulatorAxisId<this>, number>;
  }

  get objectHelpers() {
    return this._objectHelpers;
  }

  get sceneHelpers() {
    return this._sceneHelpers;
  }

  get relativeAxes(): { L0: number; L1: number; L2: number; R0: number; R1: number; R2: number } {
    const currentWorldPosition = new THREE.Vector3();
    this._osrModel.objects.receiver.getWorldPosition(currentWorldPosition);

    const relativePosition = currentWorldPosition.sub(this._center);

    // Scale down by model-specific factor
    const scaleFactor = modelInfoByType[this._modelType].scaleFactor;
    const scaledPosition = relativePosition.divideScalar(scaleFactor.L0 * 2);

    // New: Compute independent roll and pitch from world up vector
    const localUp = new THREE.Vector3(0, 0, 1);
    const worldUp = localUp.clone().applyQuaternion(this._osrModel.objects.receiver.quaternion).normalize();

    // For truly independent roll and pitch, use forward vector for roll
    const localForward = new THREE.Vector3(0, 1, 0);
    const worldForward = localForward.clone().applyQuaternion(this._osrModel.objects.receiver.quaternion).normalize();

    // Independent calculations:
    // Roll: rotation of forward vector around Z-axis (looking down the stroke axis)
    const roll = Math.atan2(worldForward.x, worldForward.y) * (180 / Math.PI);

    // Pitch: angle of up vector from vertical (independent of roll)
    const pitch = Math.atan2(Math.sqrt(worldUp.x * worldUp.x + worldUp.y * worldUp.y), worldUp.z) * (180 / Math.PI);
    // Convert to signed pitch using forward vector
    const signedPitch = (worldForward.z < 0 ? pitch : -pitch);

    // New: Compute twist (R0) by decomposing the quaternion around the local -Y axis
    const q = this._osrModel.objects.modCase.quaternion.clone().normalize();
    const u = new THREE.Vector3(0, -1, 0);
    const twist_sin = q.x * u.x + q.y * u.y + q.z * u.z;
    const twist_w = q.w;
    let norm = Math.sqrt(twist_w * twist_w + twist_sin * twist_sin);
    if (norm < 0.0001) norm = 1; // Avoid division by zero
    const twist_angle = 2 * Math.atan2(twist_sin / norm, twist_w / norm) * (180 / Math.PI);

    return {
      L0: scaledPosition.z + 0.5, // L0 is stroke (Z axis in OSR2)
      // L0: scaledPosition.z, // L0 is stroke (Z axis in OSR2)
      L1: -scaledPosition.y, // L1 is forward/back
      L2: scaledPosition.x, // L2 is left/right
      R0: twist_angle, // R0 is twist in degrees (-120 to 120 roughly) - adjust sign if needed (e.g., -twist_angle)
      R1: roll, // R1 is roll - negated to match previous sign convention
      R2: -signedPitch, // R2 is pitch - now truly independent
    };
  }

  /**
   * Creates a new OSR Emulator instance
   * @param element - HTML element or selector string to attach the 3D canvas
   * @param options - Configuration options for the emulator
   */
  constructor(element: HTMLElement | string, options: {
    scale?: Record<axisId, number>;
    sceneHelpers?: (THREE.DirectionalLightHelper | THREE.CameraHelper | THREE.PointLightHelper | THREE.AxesHelper)[];
    objectHelpers?: { object: THREE.Object3D; helper: THREE.Object3D }[];
    model?: Model;
    modelVariant?: ModelVariant[];
    cameraView?: 'front' | undefined;
  }) {
    if (element) {
      if (element instanceof HTMLElement) {
        this._element = element;
      }
      else if (typeof element === 'string') {
        const el = document.querySelector(element);
        if (!el) {
          throw new Error(`Element not found: ${element}`);
        }
        this._element = el;
      }
      else {
        throw new TypeError(`Invalid element: ${element}`);
      }
    }

    this._scale = { ...this._scale, ...(options?.scale || {}) };
    this._sceneHelpers = options?.sceneHelpers ? [] : null;
    this._objectHelpers = options?.objectHelpers ? [] : null;
    this._modelType = (options?.model ?? 'OSR2').toUpperCase() as 'OSR2' | 'SR6' | 'SSR1';
    this._modelInfo = modelInfoByType[this._modelType] as any;
    this._modelVariants = options?.modelVariant ?? ['Twist'] as any;
    this._cameraView = options?.cameraView;

    this._initCanvas();
  }

  /**
   * Execute a single TCode command and return any response
   * @param command - The TCode command to execute
   * @returns Response string if the command produces output
   */
  writeCommand(command: show<Command<emulatorAxisId<this>>>): string | undefined {
    return this._executeCommand(command).join('\n') || undefined;
  }

  /**
   * Process input string containing TCode commands (supports multiple commands and newlines)
   * @param input - Input string containing one or more TCode commands
   * @returns Combined response string if any commands produce output
   */
  write(input: string): string | undefined {
    if (typeof input !== 'string') {
      return;
    }
    const response: string[] = [];

    for (const byte of input) {
      this._buffer += byte;

      if (byte === '\n') {
        response.push(...this._executeCommand(this._buffer));
        this._buffer = '';
      }
    }

    return response.length > 0 ? `${response.join('\n')}\n` : undefined;
  }

  /**
   * Clean up resources and remove the emulator from the DOM
   */
  destroy() {
    if (this._element) {
      this._resizeObserver.unobserve(this._element);
      window.removeEventListener('resize', this._boundResizeListener);
      window.cancelAnimationFrame(this._animationFrameRequestId);
      this._element.innerHTML = '';
      this.renderer?.dispose();
      this.renderer?.forceContextLoss();
    }
  }

  _executeCommand(buffer: string): string[] {
    const trimmedBuffer = buffer.trim();
    const result: string[] = [];

    // Split commands but preserve original case for custom commands
    const commands = trimmedBuffer.split(/\s/).map(c => c.trim()).filter(c => c.length > 0);

    for (const originalCommand of commands) {
      // Only uppercase non-custom commands
      const command = originalCommand.startsWith('#') ? originalCommand : originalCommand.toUpperCase();

      if (COMMAND_REGEX.test(command)) {
        this._handleAxisCommand(command);
      }
      else if (COMMAND_EXTENSION_REGEX.test(command)) {
        this._handleAxisExtensionCommand(command);
      }
      else if (command === 'D0') {
        result.push(this._handleDeviceInfoCommand(), '');
      }
      else if (command === 'D1') {
        result.push(this._handleVersionCommand(), '');
      }
      else if (command === 'D2') {
        result.push(...this._handleLoadResponseCommand(), '');
      }
      else if (command === 'DSTOP') {
        this._handleStopCommand();
      }
      else if (command.startsWith('$') && SAVE_PREFERENCE_REGEX.test(command)) {
        this._handleSavePreferenceCommand(command);
      }
      else if (originalCommand.startsWith('#')) {
        // Use original case for custom commands
        const response = this._handleCustomCommand(originalCommand);
        if (response) {
          result.push(...response);
        }
      }
      else {
        console.error(`OSR-EMU: Unknown command: ${command}`);
      }
    }
    return result;
  }

  _resetAxisValues() {
    for (const axis of Object.keys(this._axisEmulator) as axisId[]) {
      this._axisEmulator[axis].set(5000);
      this._axisEmulator[axis]._targetTime = 0;
    }
    this._animate();
  }

  _setAxisValue(axis: axisId, value: number) {
    this._axisEmulator[axis].set(value * 10000);
    this._axisEmulator[axis]._targetTime = 0;
    this._animate();
  }

  private _handleAxisCommand(command: string): void {
    const match = COMMAND_REGEX.exec(command)!;
    const axis = match[1] as axisId;
    const value = match[2];

    if (!this.availableAxes.includes(axis as any)) {
      console.warn(`OSR-EMU: Axis ${axis} not available for model ${this._modelType}`);
      // return;
    }

    const parseValue = (value: string) => Number(value.substring(0, 4).padEnd(4, '0'));
    this._axisEmulator[axis].set(parseValue(value));
  }

  private _handleAxisExtensionCommand(command: string): void {
    const match = COMMAND_EXTENSION_REGEX.exec(command)!;
    const axis = match[1] as axisId;
    const value = match[2];
    const ext = match[3] as 'I' | 'S';
    const extValue = match[4];

    if (!this.availableAxes.includes(axis as any)) {
      console.warn(`OSR-EMU: Axis ${axis} not available for model ${this._modelType}`);
      return;
    }

    const parseValue = (value: string) => Number(value.substring(0, 4).padEnd(4, '0'));
    this._axisEmulator[axis].set(parseValue(value), ext, Number(extValue));
  }

  private _handleDeviceInfoCommand(): string {
    return `${this._modelType}-Alpha4-EMU.ino`;
  }

  private _handleVersionCommand(): string {
    return 'TCode v0.3';
  }

  private _handleLoadResponseCommand(): string[] {
    const result: string[] = [];
    for (const axis of this.availableAxes) {
      result.push(`${axis} ${
        this._userPreferences[axis].min
      } ${
        this._userPreferences[axis].max
      } ${AXIS_NAMES[axis]}`);
    }
    return result;
  }

  private _handleStopCommand(): void {
    // Stop all axis movement by setting them to their current position
    for (const axis of this.availableAxes) {
      const currentPos = this._axisEmulator[axis].getPosition();
      this._axisEmulator[axis].set(currentPos);
    }
  }

  private _handleSavePreferenceCommand(command: string): void {
    const { groups } = command.match(SAVE_PREFERENCE_REGEX)!;
    const axis = groups!.axis as axisId;
    const min = Number(groups!.min);
    const max = Number(groups!.max);

    if (min >= 0 && max <= 9999 && min <= max) {
      this._userPreferences[axis] = { min, max };
    }
    else {
      console.warn(`OSR-EMU: Invalid preference range for ${axis}: ${min}-${max}`);
    }
  }

  private _handleCustomCommand(command: string): string[] {
    const customMatch = command.match(CUSTOM_COMMAND_REGEX);

    if (!customMatch) {
      console.error(`OSR-EMU: Invalid custom command format: ${command}`);
      return [];
    }

    const commandName = customMatch[1];
    const quotedValue = customMatch[2];
    const unquotedValue = customMatch[3];
    const value = quotedValue !== undefined ? quotedValue : (unquotedValue || '');

    if (this.customCommands[commandName]) {
      const response = this.customCommands[commandName].call(this, value);
      if (response) {
        // Handle both string and string array responses
        return Array.isArray(response) ? response : [response];
      }
    }
    else {
      console.warn(`OSR-EMU: Unknown custom command: ${command}`);
    }

    return [];
  }

  _initCanvas() {
    const scene = new THREE.Scene();

    const camera = new THREE.PerspectiveCamera(50, this._computeAspectRatio(), 0.1, 1000);

    const modelInfo = modelInfoByType[this._modelType];

    const targetToUse = this._cameraView === 'front'
      ? modelInfo.controlTargetOrigin
      : modelInfo.controlTarget;

    const spherical = this._cameraView === 'front'
      ? new THREE.Spherical(modelInfo.cameraSpherical.radius, 0 * deg2rad, 90 * deg2rad)
      : modelInfo.cameraSpherical.clone();

    const position = new THREE.Vector3();
    position.setFromSpherical(spherical);
    position.add(targetToUse);

    camera.position.copy(position);
    camera.up.set(0, 0, 1);

    if (this._element) {
      const renderer = new THREE.WebGLRenderer();
      renderer.shadowMap.enabled = true;
      renderer.shadowMap.type = THREE.PCFSoftShadowMap;

      const controls = new OrbitControls(camera, renderer.domElement);
      controls.maxDistance = 700;
      controls.target.copy(targetToUse);
      controls.update();

      this.renderer = renderer;
      this.controls = controls;
    }

    this.scene = scene;
    this.camera = camera;

    this._setupLighting(scene);
    this._loadModel(scene);

    if (this._element) {
      this._element.innerHTML = '';
      this._element.appendChild(this.renderer!.domElement);
    }

    this._buffer = '';

    // Set the center position from model info
    this._center = modelInfoByType[this._modelType].center.clone();

    this._resize();
    this._animate();

    this._boundResizeListener = this._resize.bind(this);
    if (this._element) {
      this._resizeObserver = new ResizeObserver(this._boundResizeListener);
      this._resizeObserver.observe(this._element);
      window.addEventListener('resize', this._boundResizeListener);
    }
  }

  _render() {
    this.renderer?.render(this.scene, this.camera);
  }

  _animate() {
    if (this._element)
      this._animationFrameRequestId = requestAnimationFrame(this._animate.bind(this));
    this.controls?.update();
    this._updatePositions();
    this._render();
  }

  _resize() {
    const viewport = this._element?.getBoundingClientRect() ?? { width: 1, height: 1 };
    this.camera.aspect = this._computeAspectRatio();
    this.camera.updateProjectionMatrix();
    this.renderer?.setSize(viewport.width, viewport.height);
  }

  _computeAspectRatio() {
    const viewport = this._element?.getBoundingClientRect() ?? { width: 1, height: 1 };
    return viewport?.width / viewport?.height;
  }

  _setupLighting(scene: THREE.Scene) {
    const keyLight = new THREE.PointLight(0xFFFFFF);
    keyLight.intensity = 0.75;
    keyLight.position.set(200, 180, 50);
    keyLight.castShadow = true;

    const fillLight = new THREE.DirectionalLight(0xFFFFFF, 1);
    fillLight.intensity = 1;
    fillLight.position.set(-225, 225, 225);
    fillLight.castShadow = true;
    for (const side of ['left', 'right', 'bottom', 'top'] as const) {
      fillLight.shadow.camera[side] *= 50;
    }

    const backLight = new THREE.PointLight(0xFFFFFF);
    backLight.intensity = 1;
    backLight.position.set(70, -100, 200);
    backLight.castShadow = true;

    const ambientLight = new THREE.AmbientLight(0xFFFFFF, 0.625);

    this.keyLight = keyLight;
    this.fillLight = fillLight;
    this.backLight = backLight;
    this.ambientLight = ambientLight;

    if (this._modelType === 'SSR1') {
      // Slight adjustents to lighting for SSR1...
      this.keyLight.position.z = 220;
      this.backLight.position.y = -200;
    }

    scene.add(keyLight);
    scene.add(fillLight);
    scene.add(backLight);
    scene.add(ambientLight);

    if (this._sceneHelpers) {
      this._sceneHelpers = this._sceneHelpers.concat([
        new THREE.DirectionalLightHelper(fillLight, 100),
        new THREE.CameraHelper(fillLight.shadow.camera),
        new THREE.PointLightHelper(keyLight, 5),
        new THREE.PointLightHelper(backLight, 5),
        new THREE.AxesHelper(500),
      ]);

      this._sceneHelpers.forEach(h => scene.add(h));
    }
  }

  _loadModel(scene: THREE.Scene) {
    const Model = modelInfoByType[this._modelType].constructor;

    this._osrModel = new Model() as any;

    const osrGroup = new THREE.Group();

    const { objects, orientation } = this._osrModel.load();

    for (const object of Object.values(objects)) {
      forEachMesh(object, (mesh) => {
        mesh.receiveShadow = true;
        mesh.castShadow = true;
      });

      osrGroup.add(object);

      if (this._objectHelpers) {
        const helper = new THREE.AxesHelper(100);

        osrGroup.add(helper);

        this._objectHelpers.push({
          object,
          helper,
        });
      }
    }

    osrGroup.rotation.set(orientation, 0, 0);
    scene.add(osrGroup);
  }

  _updatePositions() {
    this._osrModel.preRender(this.axes as any, this._scale);
    this._render();

    // Update the position of object helpers if enabled.
    if (this._objectHelpers) {
      for (const { object, helper } of this._objectHelpers) {
        helper.position.set(object.position.x, object.position.y, object.position.z);
        helper.rotation.set(object.rotation.x, object.rotation.y, object.rotation.z);
      }
    }
  }
}

export default OSREmulator;
export { OSREmulator };
