import DataModel from "@common/abstract/data.mjs";
import * as fields from "@common/data/fields.mjs";
import Token from "../placeables/token.mjs";
import PointSourcePolygon from "../geometry/shapes/source-polygon.mjs";

/**
 * @import {CanvasVisibilityTest, CanvasVisibilityTestConfiguration, ElevatedPoint} from "@client/_types.mjs";
 * @import {TokenDetectionMode} from "@common/documents/_types.mjs";
 * @import PointVisionSource from "../sources/point-vision-source.mjs";
 * @import Level from "@client/documents/level.mjs";
 * @import {PointSourcePolygonConfig} from "../geometry/_types.mjs";
 */

/**
 * A Detection Mode which can be associated with any kind of sense/vision/perception.
 * A token could have multiple detection modes.
 */
export default class DetectionMode extends DataModel {

  /** @override */
  static defineSchema() {
    return {
      id: new fields.StringField({blank: false}),
      label: new fields.StringField({blank: false}),
      tokenConfig: new fields.BooleanField({initial: true}),       // If this DM is available in Token Config UI
      walls: new fields.BooleanField({initial: true}),             // If this DM is constrained by walls and surfaces
      angle: new fields.BooleanField({initial: true}),             // If this DM is constrained by the vision angle
      type: new fields.NumberField({
        initial: this.DETECTION_TYPES.SIGHT,
        choices: Object.values(this.DETECTION_TYPES)
      })
    };
  }

  /* -------------------------------------------- */

  /**
   * Get the detection filter pertaining to this mode.
   * @returns {PIXI.Filter|undefined}
   */
  static getDetectionFilter() {
    return this._detectionFilter;
  }

  /**
   * An optional filter to apply on the target when it is detected with this mode.
   * @type {PIXI.Filter|undefined}
   */
  static _detectionFilter;

  /* -------------------------------------------- */

  /**
   * The types of the detection mode.
   * @type {Readonly<{SIGHT: number, SOUND: number, MOVE: number, OTHER: number}>}
   */
  static get DETECTION_TYPES() {
    return DetectionMode.#DETECTION_TYPES;
  }

  static #DETECTION_TYPES = Object.freeze({
    SIGHT: 0,       // Sight, and anything depending on light perception
    SOUND: 1,       // What you can hear. Includes echolocation for bats per example
    MOVE: 2,        // This is mostly a sense for touch and vibration, like tremorsense, movement detection, etc.
    OTHER: 3        // Can't fit in other types (smell, life sense, trans-dimensional sense, sense of humor...)
  });

  /* -------------------------------------------- */

  /**
   * The identifier of the basic sight detection mode.
   * @type {"basicSight"}
   */
  static get BASIC_MODE_ID() {
    return "basicSight";
  }

  /* -------------------------------------------- */
  /*  Visibility Testing                          */
  /* -------------------------------------------- */

  /**
   * Test visibility of a target object or array of points for a specific vision source.
   * @param {PointVisionSource} visionSource             The vision source being tested
   * @param {TokenDetectionMode} mode                    The detection mode configuration
   * @param {CanvasVisibilityTestConfiguration} config   The visibility test configuration
   * @returns {boolean}                                  Is the test target visible?
   */
  testVisibility(visionSource, mode, {object, level, tests}) {
    if ( !mode.enabled ) return false;
    if ( !this._canDetect(visionSource, object, level) ) return false;
    return tests.some(test => this._testPoint(visionSource, mode, object, test));
  }

  /* -------------------------------------------- */

  /**
   * Can this PointVisionSource theoretically detect a certain object based on its properties?
   * This check should not consider the relative positions of either object, only their state.
   * @param {PointVisionSource} visionSource   The vision source being tested
   * @param {object|null} target               The target object being tested
   * @param {Level} level                      The level the target is in
   * @returns {boolean}                        Can the target object theoretically be detected by this vision source?
   * @protected
   */
  _canDetect(visionSource, target, level) {

    // Cannot see if blinded by darkness
    if ( this.walls && visionSource.blinded.darkness ) return false;

    const src = visionSource.object.document;
    const isSight = this.type === DetectionMode.DETECTION_TYPES.SIGHT;

    // Sight-based detection fails when blinded
    if ( isSight && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false;

    // Detection fails if burrowing unless walls are ignored
    if ( this.walls && src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    if ( target instanceof Token ) {
      const tgt = target.document;

      // Sight-based detection cannot see invisible tokens
      if ( isSight && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) ) return false;

      // Burrowing tokens cannot be detected unless walls are ignored
      if ( this.walls && tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Evaluate a single test point to confirm whether it is visible.
   * Standard detection rules require that the test point be both within LOS and within range.
   * @param {PointVisionSource} visionSource      The vision source being tested
   * @param {TokenDetectionMode} mode             The detection mode configuration
   * @param {object|null} target                  The target object being tested
   * @param {CanvasVisibilityTest} test           The test case being evaluated
   * @returns {boolean}
   * @protected
   */
  _testPoint(visionSource, mode, target, test) {
    if ( !this._testRange(visionSource, mode, target, test) ) return false;
    return this._testLOS(visionSource, mode, target, test);
  }

  /* -------------------------------------------- */

  /**
   * Test whether the line-of-sight requirement for detection is satisfied.
   * Always true if the detection mode bypasses walls, otherwise the test point must be contained by the LOS polygon.
   * The result of is cached for the vision source so that later checks for other detection modes do not repeat it.
   * @param {PointVisionSource} visionSource  The vision source being tested
   * @param {TokenDetectionMode} mode         The detection mode configuration
   * @param {object|null} target              The target object being tested
   * @param {CanvasVisibilityTest} test       The test case being evaluated
   * @returns {boolean}                       Is the LOS requirement satisfied for this test?
   * @protected
   */
  _testLOS(visionSource, mode, target, test) {

    // If not constrained by walls and surfaces, we only need to test whether the point is within the vision angle
    if ( !this.walls ) return this._testAngle(visionSource, mode, target, test);

    // If the detection is not constrained by the vision angle unlike the LOS polygon
    if ( !this.angle && (visionSource.data.angle < 360) ) {
      return !this.constructor._testCollision(visionSource, test, {...visionSource.los.config, angle: 360});
    }

    // If the detection is constrained by vision angle
    let hasLOS = test.los.get(visionSource);
    if ( hasLOS === undefined ) {
      hasLOS = !this.constructor._testCollision(visionSource, test, visionSource.los);
      test.los.set(visionSource, hasLOS);
    }
    return hasLOS;
  }

  /* -------------------------------------------- */

  /**
   * Test for LOS collision.
   * For both the segment in the viewed level and in the target level, a ray is cast with the given configuration.
   * @overload
   * @param {PointVisionSource} visionSource                                      The vision source
   * @param {Omit<CanvasVisibilityTest, "los">} test                              The test
   * @param {Partial<Omit<PointSourcePolygonConfig, "source"|"level">>} [config]  The configuration
   * @returns {boolean}  True if there's a collision or the point is not in the vision angle, otherwise false
   * @protected
   */
  /**
   * Test for LOS collision.
   * For the segment of the ray in the viewed level, `los` is tested.
   * For the segment of the ray in the target level, a ray is cast with the configuration of `los`.
   * @overload
   * @param {PointVisionSource} visionSource                  The vision source
   * @param {Pick<CanvasVisibilityTest, "point"|"los">} test  The test
   * @param {PointSourcePolygon} los                          The LOS polygon
   * @returns {boolean}  True if there's a collision or the point is not in the vision angle, otherwise false
   * @protected
   */
  /**
   * Test for LOS collision.
   * @param {PointVisionSource} visionSource
   * @param {Partial<Pick<CanvasVisibilityTest, "point"|"los">>} test
   * @param {Partial<Omit<PointSourcePolygonConfig, "source"|"level">>|PointSourcePolygon} [configOrLos]
   * @returns {boolean}
   * @protected
   */
  static _testCollision(visionSource, test, configOrLos={}) {
    const {origin, level: originLevel} = visionSource;
    const {point: destination, level: destinationLevel} = test;
    const los = configOrLos instanceof PointSourcePolygon ? configOrLos : null;
    const config = los?.config ?? configOrLos;
    const {angle=360, rotation=0, externalRadius=0} = config;
    if ( (angle < 360) && (!los || (externalRadius > 0)) ) {
      const dx = destination.x - origin.x;
      const dy = destination.y - origin.y;
      if ( (dx * dx) + (dy * dy) > (externalRadius * externalRadius) ) {
        if ( angle <= 0 ) return true;
        const aMin = rotation + 90 - (angle / 2);
        const a = Math.toDegrees(Math.atan2(dy, dx));
        if ( (((a - aMin) % 360) + 360) % 360 > angle ) return true;
      }
    }
    const t = DetectionMode.#getIntermediateTValue(visionSource, test);
    if ( los ) {
      const intermediateX = Math.mix(origin.x, destination.x, t);
      const intermediateY = Math.mix(origin.y, destination.y, t);
      if ( !los.contains(intermediateX, intermediateY) ) return true;
      const type = config.type ?? visionSource.constructor.sourceType;
      const options = {type, mode: "any", tMin: 0, tMax: t, level: originLevel};
      if ( canvas.scene.testSurfaceCollision(origin, destination, options) ) return true;
    }
    else if ( DetectionMode.#testCollision(visionSource, destination,
      {...config, tMin: 0, tMax: t, level: originLevel}) ) {
      return true;
    }
    return DetectionMode.#testCollision(visionSource, destination,
      {...config, tMin: t, tMax: 1, level: destinationLevel});
  }

  /* -------------------------------------------- */

  /**
   * Test for collision.
   * @param {PointVisionSource} visionSource    The vision source
   * @param {ElevatedPoint} destination         The destination
   * @param {Partial<Omit<PointSourcePolygonConfig, "source"> & {tMin: number; tMax: number}>} [config]
   *                                            The configuration
   * @returns {boolean}
   */
  static #testCollision(visionSource, destination, config) {
    const type = config.type ?? visionSource.constructor.sourceType;
    const origin = visionSource.origin;
    config = {priority: visionSource.priority, ...config, type, mode: "any", source: visionSource};
    const {tMin, tMax, level} = config;
    if ( canvas.scene.testSurfaceCollision(origin, destination, {type, mode: "any", tMin, tMax, level}) ) return true;
    return CONFIG.Canvas.polygonBackends[type].testCollision(origin, destination, config);
  }

  /* -------------------------------------------- */

  /**
   * Get the intermediate t-value for LOS testing.
   * @param {PointVisionSource} visionSource                  The vision source
   * @param {Pick<CanvasVisibilityTest, "point"|"los">} test  The test
   */
  static #getIntermediateTValue(visionSource, test) {
    const {origin, level: originLevel} = visionSource;
    const {point: destination, level: destinationLevel} = test;
    if ( originLevel === destinationLevel ) return 1;
    const delta = destination.elevation - origin.elevation;

    let to0;
    let to1;
    let td0;
    let td1;
    if ( delta !== 0 ) {

      // Calculate the origin level ray intersection interval
      to0 = (originLevel.elevation.bottom - origin.elevation) / delta;
      to1 = (originLevel.elevation.top - origin.elevation) / delta;
      if ( to0 > to1 ) [to0, to1] = [to1, to0];

      // Calculate the destination level ray intersection interval
      td0 = (destinationLevel.elevation.bottom - origin.elevation) / delta;
      td1 = (destinationLevel.elevation.top - origin.elevation) / delta;
      if ( td0 > td1 ) [td0, td1] = [td1, td0];
    } else {

      // Calculate the origin level ray intersection interval
      to0 = originLevel.elevation.bottom <= origin.elevation ? -Infinity : Infinity;
      to1 = originLevel.elevation.top >= origin.elevation ? Infinity : -Infinity;

      // Calculate the destination level ray intersection interval
      td0 = destinationLevel.elevation.bottom <= origin.elevation ? -Infinity : Infinity;
      td1 = destinationLevel.elevation.top >= origin.elevation ? Infinity : -Infinity;
    }

    // If the ray does not intersect the destination level, we test only in the origin level.
    if ( (td0 > 1) || (td1 < 0) ) return 1;
    // Otherwise the ray intersects the destination level.

    // If the ray does not intersect the origin level, we test only in the destination level.
    if ( (to0 > 1) || (to1 < 0) ) return 0;
    // Otherwise the ray intersects the origin level.

    // Now if the ray exits the destination level before the origin level, we test only in the origin level.
    to1 = Math.min(to1, 1);
    td1 = Math.min(td1, 1);
    if ( to1 > td1 ) return 1;
    // Otherwise the ray exits the origin level first or both at the same time.

    // Now if the ray enters the destination level before the origin level, we test only the destination level.
    to0 = Math.max(to0, 0);
    td0 = Math.max(td0, 0);
    if ( to0 > td0 ) return 0;
    // Otherwise the ray enters the origin level first or both at the same time.

    // We test in the origin level up until the point where the ray has exited the origin level and has entered
    // the destination level, and from that point onward we test in the destination level.
    return Math.max(to1, td0);
  }

  /* -------------------------------------------- */

  /**
   * Test whether the target is within the vision angle.
   * @param {PointVisionSource} visionSource  The vision source being tested
   * @param {TokenDetectionMode} mode         The detection mode configuration
   * @param {object|null} target              The target object being tested
   * @param {CanvasVisibilityTest} test       The test case being evaluated
   * @returns {boolean}                       Is the point within the vision angle?
   * @protected
   */
  _testAngle(visionSource, mode, target, test) {
    if ( !this.angle ) return true;
    const { angle, rotation, externalRadius } = visionSource.data;
    if ( angle >= 360 ) return true;
    const point = test.point;
    const dx = point.x - visionSource.x;
    const dy = point.y - visionSource.y;
    if ( (dx * dx) + (dy * dy) <= (externalRadius * externalRadius) ) return true;
    if ( angle <= 0 ) return false;
    const aMin = rotation + 90 - (angle / 2);
    const a = Math.toDegrees(Math.atan2(dy, dx));
    return (((a - aMin) % 360) + 360) % 360 <= angle;
  }

  /* -------------------------------------------- */

  /**
   * Verify that a target is in range of a source.
   * @param {PointVisionSource} visionSource      The vision source being tested
   * @param {TokenDetectionMode} mode             The detection mode configuration
   * @param {object|null} target                  The target object being tested
   * @param {CanvasVisibilityTest} test           The test case being evaluated
   * @returns {boolean}                           Is the target within range?
   * @protected
   */
  _testRange(visionSource, mode, target, test) {
    const range = mode.range;
    if ( range <= 0 ) return false;
    if ( range === Infinity ) return true;
    const point = test.point;
    const {x, y, elevation} = visionSource.data;
    if ( Math.abs(point.elevation - elevation) > range + 1e-8 ) return false;
    const radius = visionSource.object.getLightRadius(range);
    const dx = point.x - x;
    const dy = point.y - y;
    return ((dx * dx) + (dy * dy)) <= (radius * radius);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get BASIC_MODE_ID() {
    const cls = this.constructor.name;
    const message = `${cls}#BASIC_MODE_ID is deprecated. Please use ${cls}.BASIC_MODE_ID instead.`;
    foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
    return DetectionMode.BASIC_MODE_ID;
  }
}
