// PhishProtect Evidence: Frame Chain Builder
// Maintains a frame tree per tab, merges data from multiple sources
// Provides getChain(tabId, frameId) for full frame ancestry
// Uses chrome.storage.session to survive MV3 service worker restarts

const SESSION_KEY = 'frameChainTrees';
const SAVE_DEBOUNCE_MS = 2000;

export class FrameChainBuilder {
  constructor(headerCapture) {
    this.headerCapture = headerCapture;
    // Map<tabId, Map<frameId, FrameNode>>
    this.trees = new Map();
    this._saveTimer = null;
  }

  /**
   * Initialize listeners for frame tree population.
   * Restores persisted trees from session storage to survive SW restarts.
   */
  async initialize() {
    // Restore persisted trees before registering listeners
    await this._restoreFromSession();

    // Listen for ALL frame navigations (not just main frame)
    chrome.webNavigation.onCommitted.addListener((details) => {
      this._onFrameCommitted(details);
    });

    // Clean up on tab close
    chrome.tabs.onRemoved.addListener((tabId) => {
      this.trees.delete(tabId);
      this._scheduleSave();
    });

    console.log(`FrameChainBuilder: initialized (restored ${this.trees.size} tab trees)`);
  }

  /**
   * Handle frame navigation committed event
   */
  _onFrameCommitted(details) {
    const { tabId, frameId, parentFrameId, url } = details;
    if (tabId < 0) return;

    // Main frame navigation: clear the tree for this tab
    if (frameId === 0) {
      this.trees.set(tabId, new Map());
    }

    if (!this.trees.has(tabId)) {
      this.trees.set(tabId, new Map());
    }

    const tree = this.trees.get(tabId);
    const existing = tree.get(frameId) || {};

    tree.set(frameId, {
      ...existing,
      frameId,
      parentFrameId: parentFrameId >= 0 ? parentFrameId : null,
      url,
      origin: this._extractOrigin(url),
      committedAt: Date.now(),
      // Preserve existing merged data
      dimensions: existing.dimensions || null,
      computedStyles: existing.computedStyles || null,
      technologies: existing.technologies || null,
      children: existing.children || []
    });

    // Register as child of parent
    if (parentFrameId >= 0 && tree.has(parentFrameId)) {
      const parent = tree.get(parentFrameId);
      if (!parent.children.includes(frameId)) {
        parent.children.push(frameId);
      }
    }

    this._scheduleSave();
  }

  /**
   * Merge content script frame context report into the tree
   */
  mergeFrameContextReport(tabId, frameId, report) {
    if (!this.trees.has(tabId)) {
      this.trees.set(tabId, new Map());
    }

    const tree = this.trees.get(tabId);
    const existing = tree.get(frameId) || { frameId, children: [] };

    tree.set(frameId, {
      ...existing,
      url: report.frameUrl || existing.url,
      origin: this._extractOrigin(report.frameUrl || existing.url),
      isTopFrame: report.isTopFrame,
      documentTitle: report.documentTitle,
      viewport: report.viewport
    });

    // Merge child iframe dimensional data
    // Match children by src URL to existing frame nodes
    if (report.childIframes) {
      for (const child of report.childIframes) {
        // Find matching frameId in tree by URL
        const childFrameId = this._findFrameByUrl(tabId, child.src);
        if (childFrameId !== null) {
          const childNode = tree.get(childFrameId);
          if (childNode) {
            childNode.dimensions = child.dimensions;
            childNode.displayRatio = child.displayRatio;
            childNode.computedStyles = child.computedStyles;
          }
        }
      }
    }

    this._scheduleSave();
  }

  /**
   * Merge technology fingerprint data into a frame node
   */
  mergeTechFingerprint(tabId, frameId, techReport) {
    const tree = this.trees.get(tabId);
    if (!tree) return;

    const node = tree.get(frameId);
    if (node) {
      node.technologies = techReport;
      this._scheduleSave();
    }
  }

  /**
   * Get the full frame chain from top frame to the specified frameId.
   * Returns ordered array: [topFrame, ..., parentFrame, targetFrame]
   */
  getChain(tabId, frameId) {
    const tree = this.trees.get(tabId);
    if (!tree) return [];

    const chain = [];
    let currentId = frameId;
    const visited = new Set();

    while (currentId !== null && currentId !== undefined && !visited.has(currentId)) {
      visited.add(currentId);
      const node = tree.get(currentId);
      if (!node) break;

      // Merge in header data
      const headerData = this.headerCapture
        ? this.headerCapture.getFrameHeaders(tabId, currentId)
        : null;

      chain.unshift({
        frameId: node.frameId,
        url: node.url,
        origin: node.origin,
        isTopFrame: node.frameId === 0,
        documentTitle: node.documentTitle || null,
        dimensions: node.dimensions || null,
        displayRatio: node.displayRatio || null,
        computedStyles: node.computedStyles || null,
        viewport: node.viewport || null,
        technologies: node.technologies || null,
        responseHeaders: headerData ? headerData.headers : null,
        setCookieNames: headerData ? headerData.setCookieNames : null
      });

      currentId = node.parentFrameId;
    }

    return chain;
  }

  /**
   * Get the full tree for a tab (for debugging)
   */
  getTree(tabId) {
    return this.trees.get(tabId) || new Map();
  }

  /**
   * Find a frame in the tree by its URL
   */
  _findFrameByUrl(tabId, url) {
    if (!url) return null;
    const tree = this.trees.get(tabId);
    if (!tree) return null;

    for (const [fid, node] of tree) {
      if (node.url === url) return fid;
    }
    return null;
  }

  _extractOrigin(url) {
    try {
      return new URL(url).origin;
    } catch {
      return null;
    }
  }

  // ---- Session persistence ----

  /**
   * Debounced save to session storage
   */
  _scheduleSave() {
    if (this._saveTimer) clearTimeout(this._saveTimer);
    this._saveTimer = setTimeout(() => this._saveToSession(), SAVE_DEBOUNCE_MS);
  }

  /**
   * Serialize trees to session storage.
   * Converts Map<tabId, Map<frameId, node>> to a plain object for JSON storage.
   * Caps at 50 tabs to stay within session storage limits.
   */
  async _saveToSession() {
    try {
      const serialized = {};
      let count = 0;
      for (const [tabId, tree] of this.trees) {
        if (count >= 50) break;
        const frames = {};
        for (const [frameId, node] of tree) {
          frames[frameId] = node;
        }
        serialized[tabId] = frames;
        count++;
      }
      await chrome.storage.session.set({ [SESSION_KEY]: serialized });
    } catch (err) {
      // Session storage may be unavailable or quota exceeded — non-fatal
      console.warn('FrameChainBuilder: Failed to persist trees:', err.message);
    }
  }

  /**
   * Restore trees from session storage after service worker restart
   */
  async _restoreFromSession() {
    try {
      const result = await chrome.storage.session.get(SESSION_KEY);
      const data = result[SESSION_KEY];
      if (!data || typeof data !== 'object') return;

      for (const [tabIdStr, frames] of Object.entries(data)) {
        const tabId = parseInt(tabIdStr, 10);
        if (isNaN(tabId)) continue;

        const tree = new Map();
        for (const [frameIdStr, node] of Object.entries(frames)) {
          const frameId = parseInt(frameIdStr, 10);
          if (isNaN(frameId)) continue;
          tree.set(frameId, node);
        }
        this.trees.set(tabId, tree);
      }
    } catch (err) {
      console.warn('FrameChainBuilder: Failed to restore trees:', err.message);
    }
  }

  destroy() {
    if (this._saveTimer) clearTimeout(this._saveTimer);
    this.trees.clear();
  }
}
