import { hashText, normalizeText } from "../shared/hash";
import type { NormalizedPost, SiteDefinition } from "../shared/types";

export class DefinitionEngine {
  constructor(readonly definition: SiteDefinition) {}

  discover(root: Document | Element): NormalizedPost[] {
    const posts: NormalizedPost[] = [];
    const elements = this.findPostElements(root);
    for (const element of elements) {
      const text = this.extractText(element);
      if (!text) continue;
      const id = this.resolveId(element, text);
      element.setAttribute("data-vibeguard-id", id);
      posts.push({ id, text, element, site: this.definition.id });
    }
    return posts;
  }

  observe(onPosts: (posts: NormalizedPost[]) => void): () => void {
    const observer = new MutationObserver((mutations) => {
      const candidates = new Set<Element>();
      for (const mutation of mutations) {
        const target = mutation.target.nodeType === Node.ELEMENT_NODE ? mutation.target as Element : mutation.target.parentElement;
        if (target) this.addCandidateAncestors(target, candidates);
        mutation.addedNodes.forEach((node) => {
          if (node.nodeType !== Node.ELEMENT_NODE) return;
          const element = node as Element;
          this.addCandidateAncestors(element, candidates);
          this.findPostElements(element).forEach((post) => candidates.add(post));
        });
      }
      if (candidates.size > 0) onPosts(this.normalizeElements(candidates));
    });
    observer.observe(document.body, {
      childList: true,
      characterData: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["class", "style", "hidden", "aria-expanded"]
    });
    return () => observer.disconnect();
  }

  dispose(): void {}

  private findPostElements(root: Document | Element): Element[] {
    const elements = new Set<Element>();
    const selectors = this.definition.post.rootSelectors.join(",");
    if (root instanceof Element && root.matches(selectors)) elements.add(root);
    root.querySelectorAll(selectors).forEach((element) => elements.add(element));
    return [...elements];
  }

  private normalizeElements(elements: Iterable<Element>): NormalizedPost[] {
    const posts: NormalizedPost[] = [];
    for (const element of elements) {
      const text = this.extractText(element);
      if (!text) continue;
      const id = this.resolveId(element, text);
      element.setAttribute("data-vibeguard-id", id);
      posts.push({ id, text, element, site: this.definition.id });
    }
    return posts;
  }

  private addCandidateAncestors(element: Element, candidates: Set<Element>): void {
    const selectors = this.definition.post.rootSelectors.join(",");
    let current: Element | null = element;
    while (current) {
      if (current.matches(selectors)) candidates.add(current);
      current = current.parentElement;
    }
  }

  private extractText(post: Element): string {
    for (const selector of this.definition.post.textSelectors) {
      const matches = Array.from(post.querySelectorAll(selector));
      const contents = post.matches(selector) ? [post, ...matches] : matches;
      for (const content of contents) {
        const text = visibleText(content, this.definition.post.excludedSelectors ?? []);
        if (text) return text;
      }
    }
    return "";
  }

  private resolveId(post: Element, text: string): string {
    for (const attribute of this.definition.post.idAttributes ?? []) {
      const value = post.getAttribute(attribute);
      if (value) return `${this.definition.id}:${value}`;
    }
    for (const selector of this.definition.post.permalinkSelectors ?? []) {
      const href = post.querySelector<HTMLAnchorElement>(selector)?.href;
      if (href) return `${this.definition.id}:${href}`;
    }
    return `${this.definition.id}:${location.origin}:${hashText(text)}`;
  }
}

export function selectDefinition(definitions: SiteDefinition[], currentUrl = location.href, root: Document = document): SiteDefinition | undefined {
  return definitions.find((definition) => matchesDefinition(definition, currentUrl, root));
}

/** Select a definition from the URL alone. Useful while an SPA is still hydrating. */
export function selectDefinitionForUrl(definitions: SiteDefinition[], currentUrl = location.href): SiteDefinition | undefined {
  // A broad <all_urls> definition (such as Mastodon) still needs its marker
  // selectors; otherwise it would claim every page during startup.
  return definitions.find((definition) => definition.urlPatterns.some((pattern) => pattern !== "<all_urls>") && matchesAnyUrlPattern(definition.urlPatterns, currentUrl));
}

/** Whether a definition might apply after an SPA finishes rendering its markers. */
export function hasPotentialDefinitionForUrl(definitions: SiteDefinition[], currentUrl = location.href): boolean {
  return definitions.some((definition) => matchesAnyUrlPattern(definition.urlPatterns, currentUrl));
}

function matchesDefinition(definition: SiteDefinition, currentUrl: string, root: Document): boolean {
  return matchesAnyUrlPattern(definition.urlPatterns, currentUrl) && definition.requiredSelectors.every((selector) => root.querySelector(selector) !== null);
}

export function matchesAnyUrlPattern(patterns: string[], url: string): boolean {
  return patterns.some((pattern) => matchesUrlPattern(pattern, url));
}

export function matchesUrlPattern(pattern: string, value: string): boolean {
  const url = new URL(value);
  if (pattern === "<all_urls>") return url.protocol === "http:" || url.protocol === "https:";
  const match = pattern.match(/^(\*|https?|file):\/\/(\*|\*\.[^/*]+|[^/*]+)\/(.*)$/);
  if (!match) return false;
  const [, scheme, host, path] = match;
  if (scheme !== "*" && `${scheme}:` !== url.protocol) return false;
  if (host !== "*" && !(host?.startsWith("*.") ? url.hostname === host.slice(2) || url.hostname.endsWith(`.${host.slice(2)}`) : url.hostname === host)) return false;
  return wildcardMatches(path ?? "", `${url.pathname}${url.search}`);
}

function wildcardMatches(pattern: string, value: string): boolean {
  const expression = `^${pattern.split("*").map(escapeRegex).join(".*")}$`;
  return new RegExp(expression).test(value);
}

function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }

function visibleText(element: Element, excludedSelectors: string[]): string {
  const chunks: string[] = [];
  const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
  let node: Node | null;
  while ((node = walker.nextNode())) {
    const parent = node.parentElement;
    if (!parent || excludedSelectors.some((selector) => parent.closest(selector))) continue;
    if (isVisible(parent, element)) chunks.push(node.textContent ?? "");
  }
  return normalizeText(chunks.join(" "));
}

function isVisible(element: Element, boundary: Element): boolean {
  let current: Element | null = element;
  while (current) {
    if (current.hasAttribute("hidden")) return false;
    const style = getComputedStyle(current);
    if (style.display === "none" || style.visibility === "hidden") return false;
    if (current === boundary) break;
    current = current.parentElement;
  }
  return true;
}
