// Copyright 2023 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import 'dart:math' as math;

import 'package:charcode/charcode.dart';
import 'package:source_span/source_span.dart';

import 'ast/sass.dart';
import 'util/character.dart';

/// A map from locations in a string generated from an [Interpolation] to the
/// original source code in the interpolation.
final class InterpolationMap {
  /// The interpolation from which this map was generated.
  final Interpolation _interpolation;

  /// Location offsets in the generated string.
  ///
  /// Each of these indicates the location in the generated string that
  /// corresponds to the end of the component at the same index of
  /// [_interpolation.contents]. Its length is always one less than
  /// [_interpolation.contents] because the last element always ends the string.
  final List<int> _targetOffsets;

  /// Creates a new interpolation map that maps the given [targetOffsets] in the
  /// generated string to the contents of the interpolation.
  ///
  /// Each target offset at index `i` corresponds to the character in the
  /// generated string after `interpolation.contents[i]`.
  InterpolationMap(
    this._interpolation,
    Iterable<int> targetOffsets,
  ) : _targetOffsets = List.unmodifiable(targetOffsets) {
    var expectedLocations = math.max(0, _interpolation.contents.length - 1);
    if (_targetOffsets.length != expectedLocations) {
      throw ArgumentError(
        "InterpolationMap must have $expectedLocations targetOffsets if the "
        "interpolation has ${_interpolation.contents.length} components.",
      );
    }
  }

  /// Maps [error]'s span in the string generated from this interpolation to its
  /// original source.
  ///
  /// Returns [error] if its span is null, or if it's already been mapped.
  FormatException mapException(SourceSpanFormatException error) {
    var target = error.span;
    if (target == null) return error;

    if (_interpolation.contents.isEmpty) {
      if (_isMapped(target)) return error;
      return SourceSpanFormatException(
        error.message,
        _interpolation.span,
        error.source,
      );
    }

    var source = mapSpan(target);
    if (identical(source, target)) return error;

    var startIndex = _indexInContents(target.start);
    var endIndex = _indexInContents(target.end);

    if (!_interpolation.contents
        .skip(startIndex)
        .take(endIndex - startIndex + 1)
        .any((content) => content is Expression)) {
      return SourceSpanFormatException(error.message, source, error.source);
    } else {
      return MultiSourceSpanFormatException(
          error.message,
          source,
          "",
          {
            target: "error in interpolated output",
          },
          error.source);
    }
  }

  /// Maps a span in the string generated from this interpolation to its
  /// original source.
  ///
  /// Returns [target] as-is if it's already been mapped.
  FileSpan mapSpan(SourceSpan target) {
    if (_isMapped(target)) return target as FileSpan;

    return switch ((
      _mapLocation(target.start),
      _mapLocation(target.end),
    )) {
      (FileSpan start, FileSpan end) => start.expand(end),
      (FileSpan start, FileLocation end) => _interpolation.span.file.span(
          _expandInterpolationSpanLeft(start.start),
          end.offset,
        ),
      (FileLocation start, FileSpan end) => _interpolation.span.file.span(
          start.offset,
          _expandInterpolationSpanRight(end.end),
        ),
      (FileLocation start, FileLocation end) => _interpolation.span.file.span(
          start.offset,
          end.offset,
        ),
      _ => throw '[BUG] Unreachable',
    };
  }

  /// Returns whether [span] has already been mapped by this mapper.
  bool _isMapped(SourceSpan span) => switch (span) {
        FileSpan(:var file) => identical(file, _interpolation.span.file),
        _ => false,
      };

  /// Maps a location in the string generated from this interpolation to its
  /// original source.
  ///
  /// If [source] points to an un-interpolated portion of the original string,
  /// this will return the corresponding [FileLocation]. If it points to text
  /// generated from interpolation, this will return the full [FileSpan] for
  /// that interpolated expression.
  Object /* FileLocation|FileSpan */ _mapLocation(SourceLocation target) {
    if (_interpolation.contents.isEmpty) return _interpolation.span;

    var index = _indexInContents(target);
    if (_interpolation.contents[index] case Expression chunk) {
      return chunk.span;
    }

    var previousLocation = index == 0
        ? _interpolation.span.start
        : _interpolation.span.file.location(
            _expandInterpolationSpanRight(
              (_interpolation.contents[index - 1] as Expression).span.end,
            ),
          );
    var offsetInString =
        target.offset - (index == 0 ? 0 : _targetOffsets[index - 1]);

    // This produces slightly incorrect mappings if there are _unnecessary_
    // escapes in the source file, but that's unlikely enough that it's probably
    // not worth doing a reparse here to fix it.
    return previousLocation.file.location(
      previousLocation.offset + offsetInString,
    );
  }

  /// Return the index in [_interpolation.contents] at which [target] points.
  int _indexInContents(SourceLocation target) {
    for (var i = 0; i < _targetOffsets.length; i++) {
      if (target.offset < _targetOffsets[i]) return i;
    }

    return _interpolation.contents.length - 1;
  }

  /// Given the start of a [FileSpan] covering an interpolated expression, returns
  /// the offset of the interpolation's opening `#`.
  ///
  /// Note that this can be tricked by a `#{` that appears within a single-line
  /// comment before the expression, but since it's only used for error
  /// reporting that's probably fine.
  int _expandInterpolationSpanLeft(FileLocation start) {
    var source = start.file.codeUnits;
    var i = start.offset - 1;
    while (i >= 0) {
      var prev = source[i--];
      if (prev == $lbrace) {
        if (source[i] == $hash) break;
      } else if (prev == $slash) {
        var second = source[i--];
        if (second == $asterisk) {
          while (true) {
            var char = source[i--];
            if (char != $asterisk) continue;

            do {
              char = source[i--];
            } while (char == $asterisk);
            if (char == $slash) break;
          }
        }
      }
    }

    return i;
  }

  /// Given the end of a [FileSpan] covering an interpolated expression, returns
  /// the offset of the interpolation's closing `}`.
  int _expandInterpolationSpanRight(FileLocation end) {
    var source = end.file.codeUnits;
    var i = end.offset;
    while (i < source.length) {
      var next = source[i++];
      if (next == $rbrace) break;
      if (next == $slash) {
        var second = source[i++];
        if (second == $slash) {
          while (!source[i++].isNewline) {}
        } else if (second == $asterisk) {
          while (true) {
            var char = source[i++];
            if (char != $asterisk) continue;

            do {
              char = source[i++];
            } while (char == $asterisk);
            if (char == $slash) break;
          }
        }
      }
    }

    return i;
  }
}
