// D3 geo + canvas/SVG hybrid viz
// Canvas for edges (perf), SVG for nodes (interaction)
class NetworkViz {
    constructor(containerId) {
        this.container = document.getElementById(containerId);
        this.canvas = document.getElementById('edge-canvas');
        this.svg = d3.select('#node-svg');
        this.ctx = this.canvas.getContext('2d');

        this.projection = d3.geoAlbersUsa();
        this.currentTransform = d3.zoomIdentity;

        this.riskColor = {
            low: '#2ecc71',
            medium: '#f39c12',
            high: '#e74c3c',
            severe: '#8e44ad',
        };

        this.nodeScale = d3.scaleSqrt().range([3, 18]);
        this.edgeOpacity = d3.scaleLog().clamp(true).range([0.08, 0.5]);

        this.data = null;
        this.projectedNodes = new Map();
        this.selectedAirport = null;
        this.activeLayer = null;
        this.statesGeo = null;  // US states geojson for background map

        this._lastHoveredEdge = null;

        // Nodes/edges temporarily added when an airport is selected
        this._supplementalNodes = new Set();
        this._supplementalEdges = [];

        this._setupDimensions();
        this._setupZoom();
        this._setupEdgeHitDetection();
        this._loadStates();
    }

    _setupDimensions() {
        const rect = this.container.getBoundingClientRect();
        this.width = rect.width;
        this.height = rect.height;

        const dpr = window.devicePixelRatio || 1;
        this.canvas.width = this.width * dpr;
        this.canvas.height = this.height * dpr;
        this.canvas.style.width = this.width + 'px';
        this.canvas.style.height = this.height + 'px';
        this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

        this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`);

        this.projection
            .translate([this.width / 2, this.height / 2])
            .scale(this.width * 1.1);
    }

    _setupZoom() {
        const zoom = d3.zoom()
            .scaleExtent([0.5, 8])
            .on('zoom', (event) => {
                this.currentTransform = event.transform;
                this._renderEdges();
                this.svg.select('.nodes-group')
                    .attr('transform', event.transform.toString());
            });

        this.svg.call(zoom);
    }

    _setupEdgeHitDetection() {
        // Throttle: only run hit-test once per animation frame
        let pending = false;

        this.svg.on('mousemove.edgehit', (event) => {
            if (!this.activeLayer || !this.activeLayer.edgeClickEnabled) {
                if (this._lastHoveredEdge) {
                    this._lastHoveredEdge = null;
                    this._renderEdges();
                }
                return;
            }
            // Skip if we're over an airport node
            if (event.target.classList && event.target.classList.contains('airport-node')) {
                if (this._lastHoveredEdge) {
                    this._lastHoveredEdge = null;
                    this._hideTooltip();
                    this._renderEdges();
                }
                return;
            }

            if (pending) return;
            pending = true;

            requestAnimationFrame(() => {
                pending = false;
                this._updateEdgeHover(event);
            });
        });
    }

    _updateEdgeHover(event) {
        if (!this.activeLayer || !this.activeLayer.edgeClickEnabled) return;

        const edge = this._findNearestEdge(event.clientX, event.clientY);

        // Hysteresis: if we already have a hovered edge, don't drop it
        // unless cursor moved well outside its hit zone (use larger threshold)
        if (this._lastHoveredEdge && !edge) {
            const dist = this._edgeScreenDist(event.clientX, event.clientY, this._lastHoveredEdge);
            if (dist !== null && dist < 25) return;  // sticky — keep current hover
        }

        if (edge === this._lastHoveredEdge) return;

        this._lastHoveredEdge = edge;
        if (edge) {
            this.activeLayer.onEdgeHover(edge);
            this._showEdgeTooltip(event, edge);
            this.svg.node().style.cursor = 'pointer';
        } else {
            this._hideTooltip();
            this.svg.node().style.cursor = '';
        }

        // Only force re-render if the animation loop isn't already running
        if (!this.activeLayer.animFrameId) {
            this._renderEdges();
        }
    }

    // Screen-space distance from cursor to an edge (for hysteresis check)
    _edgeScreenDist(clientX, clientY, edge) {
        const src = this.projectedNodes.get(edge.source);
        const tgt = this.projectedNodes.get(edge.target);
        if (!src || !tgt) return null;

        const rect = this.container.getBoundingClientRect();
        const sx = clientX - rect.left;
        const sy = clientY - rect.top;
        const t = this.currentTransform;

        // Project edge endpoints to screen space
        const ax = src.x * t.k + t.x;
        const ay = src.y * t.k + t.y;
        const bx = tgt.x * t.k + t.x;
        const by = tgt.y * t.k + t.y;

        return this._pointToSegDist(sx, sy, ax, ay, bx, by);
    }

    // Point-to-line-segment distance (projection space coords)
    _pointToSegDist(px, py, ax, ay, bx, by) {
        const dx = bx - ax;
        const dy = by - ay;
        const lenSq = dx * dx + dy * dy;
        if (lenSq === 0) return Math.hypot(px - ax, py - ay);

        let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
        t = Math.max(0, Math.min(1, t));

        const cx = ax + t * dx;
        const cy = ay + t * dy;
        return Math.hypot(px - cx, py - cy);
    }

    _findNearestEdge(clientX, clientY) {
        if (!this.data || !this.data.edges) return null;

        const rect = this.container.getBoundingClientRect();
        const sx = clientX - rect.left;
        const sy = clientY - rect.top;

        // Invert zoom to get projection-space coords
        const t = this.currentTransform;
        const px = (sx - t.x) / t.k;
        const py = (sy - t.y) / t.k;

        const threshold = 12 / t.k;  // 12 screen pixels — generous for clickability
        let bestEdge = null;
        let bestDist = threshold;

        for (const edge of this.data.edges) {
            // Let the layer filter which edges are interactive
            if (this.activeLayer && !this.activeLayer.isEdgeInteractive(edge)) continue;

            const src = this.projectedNodes.get(edge.source);
            const tgt = this.projectedNodes.get(edge.target);
            if (!src || !tgt) continue;

            const dist = this._pointToSegDist(px, py, src.x, src.y, tgt.x, tgt.y);
            if (dist < bestDist) {
                bestDist = dist;
                bestEdge = edge;
            }
        }
        return bestEdge;
    }

    _showEdgeTooltip(event, edge) {
        const tooltip = document.getElementById('tooltip');
        const flights = (edge.flight_count || 0).toLocaleString();
        const hint = (this.activeLayer && this.activeLayer.edgeClickEnabled)
            ? '<br><span style="color:var(--text-muted);font-size:0.7rem">Click to explore chain</span>'
            : '';
        tooltip.innerHTML = `
            <strong>${edge.source} → ${edge.target}</strong><br>
            ${flights} flights${hint}
        `;
        tooltip.style.display = 'block';

        // Anchor at edge midpoint (stable, doesn't follow cursor)
        const src = this.projectedNodes.get(edge.source);
        const tgt = this.projectedNodes.get(edge.target);
        const t = this.currentTransform;
        const rect = this.container.getBoundingClientRect();

        let x, y;
        if (src && tgt) {
            const mx = (src.x + tgt.x) / 2;
            const my = (src.y + tgt.y) / 2;
            x = mx * t.k + t.x;
            y = my * t.k + t.y;
        } else {
            x = event.clientX - rect.left;
            y = event.clientY - rect.top;
        }

        const tipW = tooltip.offsetWidth;
        if (x + tipW + 20 > rect.width) {
            tooltip.style.left = (x - tipW - 12) + 'px';
        } else {
            tooltip.style.left = (x + 12) + 'px';
        }
        tooltip.style.top = (y - 28) + 'px';
    }

    async _loadStates() {
        try {
            // US Atlas TopoJSON from cdn
            const us = await d3.json('https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json');
            this.statesGeo = topojson.feature(us, us.objects.states);
            // also grab state borders for a crisper look
            this.statesMesh = topojson.mesh(us, us.objects.states, (a, b) => a !== b);
            this.render();
        } catch (err) {
            console.warn('Could not load US map data:', err);
        }
    }

    _renderStates(ctx, t) {
        if (!this.statesGeo) return;

        const path = d3.geoPath(this.projection).context(ctx);

        // Near-white fill so the map is subtle
        ctx.fillStyle = '#f3f4f6';
        ctx.beginPath();
        path(this.statesGeo);
        ctx.fill();

        // Bold state borders for contrast
        if (this.statesMesh) {
            ctx.strokeStyle = '#94a3b8';
            ctx.lineWidth = 1.0 / t.k;
            ctx.beginPath();
            path(this.statesMesh);
            ctx.stroke();
        }

        // Outer outline — darker
        ctx.strokeStyle = '#475569';
        ctx.lineWidth = 1.8 / t.k;
        ctx.beginPath();
        path(this.statesGeo);
        ctx.stroke();
    }

    showLoading() {
        const overlay = document.getElementById('loading-overlay');
        if (overlay) overlay.classList.remove('hidden');
    }

    hideLoading() {
        const overlay = document.getElementById('loading-overlay');
        if (overlay) overlay.classList.add('hidden');
    }

    async loadData(params = {}) {
        this.showLoading();
        try {
            this.data = await API.getNetwork(params);
            // Re-read container dimensions here — by the time data arrives,
            // CSS grid layout has settled in all browsers (Chrome can return 0
            // from getBoundingClientRect if called synchronously at construction)
            this._setupDimensions();
            this._projectNodes();
            this._updateScales();
            this.render();
        } catch (err) {
            console.error('Failed to load network data:', err);
        } finally {
            this.hideLoading();
        }
    }

    _projectNodes() {
        this.projectedNodes.clear();
        if (!this.data || !this.data.nodes) return;

        for (const node of this.data.nodes) {
            const projected = this.projection([node.lon, node.lat]);
            if (projected) {
                this.projectedNodes.set(node.id, {
                    ...node,
                    x: projected[0],
                    y: projected[1],
                });
            }
        }
    }

    _updateScales() {
        if (!this.data || !this.data.nodes.length) return;

        const flightExtent = d3.extent(this.data.nodes, d => d.total_flights);
        this.nodeScale.domain(flightExtent);

        if (this.data.edges.length) {
            const edgeExtent = d3.extent(this.data.edges, d => d.flight_count);
            this.edgeOpacity.domain([Math.max(1, edgeExtent[0] || 1), edgeExtent[1] || 1]);
        }
    }

    render() {
        this._renderEdges();
        this._renderNodes();
    }

    // Returns which AlbersUSA sub-projection will handle this coordinate.
    // D3 picks the sub-projection by testing input lon/lat against each
    // region's bounding box. Edges crossing region boundaries draw as long
    // diagonal lines across the map and need to be suppressed.
    _projectionRegion(lon, lat) {
        if (Math.abs(lon + 159.5) < 22 && Math.abs(lat - 63) < 8.5) return 'alaska';
        if (Math.abs(lon + 157.5) < 11 && Math.abs(lat - 20.5) < 5)  return 'hawaii';
        return 'lower48';
    }

    // Returns adjusted endpoints clipped to each node's circumference, so edge
    // lines visually connect at circle boundaries rather than center-to-center.
    // Center-to-center works fine for large hubs (edge hidden inside the circle)
    // but for small airports the tiny circle barely covers anything and the line
    // appears to float near rather than touch the node.
    // Returns null when nodes overlap or are degenerate (skip the edge).
    _edgeEndpoints(source, target, zoomK) {
        const dx = target.x - source.x;
        const dy = target.y - source.y;
        const dist = Math.sqrt(dx * dx + dy * dy);
        if (dist < 0.5) return null;

        const nx = dx / dist;
        const ny = dy / dist;
        // nodeScale returns CSS px; scale to canvas drawing units
        const sr = this.nodeScale(source.total_flights || 0) / zoomK;
        const tr = this.nodeScale(target.total_flights || 0) / zoomK;
        if (sr + tr >= dist) return null;  // nodes overlap, nothing to draw

        return [
            source.x + nx * sr,
            source.y + ny * sr,
            target.x - nx * tr,
            target.y - ny * tr,
        ];
    }

    _renderEdges(highlightCode = null) {
        const ctx = this.ctx;
        const t = this.currentTransform;

        ctx.clearRect(0, 0, this.width, this.height);

        // Draw US map under everything
        ctx.save();
        ctx.translate(t.x, t.y);
        ctx.scale(t.k, t.k);
        this._renderStates(ctx, t);
        ctx.restore();

        if (!this.data || !this.data.edges) return;

        ctx.save();
        ctx.translate(t.x, t.y);
        ctx.scale(t.k, t.k);

        const highlight = highlightCode || this.selectedAirport;

        // No layer active + no selection = skip edges, too noisy
        for (const edge of (this.activeLayer || highlight ? this.data.edges : [])) {
            const source = this.projectedNodes.get(edge.source);
            const target = this.projectedNodes.get(edge.target);
            if (!source || !target) continue;

            // Skip edges that cross between AlbersUSA sub-projections. SE Alaska
            // panhandle airports (JNU, SIT, KTN, etc.) fall outside the Alaska
            // bbox and project through the lower-48 Albers. An edge between one
            // of those and an airport in the Alaska inset (ANC, FAI, YAK) draws
            // a long spurious line across the map.
            if (this._projectionRegion(source.lon, source.lat) !== this._projectionRegion(target.lon, target.lat)) continue;

            const isConnected = highlight
                ? (edge.source === highlight || edge.target === highlight)
                : true;

            const ep = this._edgeEndpoints(source, target, t.k);
            if (!ep) continue;

            ctx.beginPath();
            ctx.moveTo(ep[0], ep[1]);
            ctx.lineTo(ep[2], ep[3]);
            const layerColor = this.activeLayer ? this.activeLayer.getEdgeColor(edge) : null;
            ctx.strokeStyle = layerColor || this.riskColor[edge.delay_risk] || '#555';

            if (highlight) {
                ctx.globalAlpha = isConnected ? 0.7 : 0.03;
                ctx.lineWidth = isConnected ? 1.5 / t.k : 0.3 / t.k;
            } else {
                ctx.globalAlpha = this.edgeOpacity(edge.flight_count || 1);
                ctx.lineWidth = 0.5 / t.k;
            }

            ctx.stroke();
        }

        // Highlight hovered edge (when edge clicking is enabled)
        if (this._lastHoveredEdge && this.activeLayer && this.activeLayer.edgeClickEnabled) {
            const he = this._lastHoveredEdge;
            const hs = this.projectedNodes.get(he.source);
            const ht = this.projectedNodes.get(he.target);
            if (hs && ht) {
                const hep = this._edgeEndpoints(hs, ht, t.k);
                if (hep) {
                    ctx.beginPath();
                    ctx.moveTo(hep[0], hep[1]);
                    ctx.lineTo(hep[2], hep[3]);
                    ctx.strokeStyle = '#f1c40f';
                    ctx.globalAlpha = 0.8;
                    ctx.lineWidth = 2.5 / t.k;
                    ctx.stroke();
                }
            }
        }

        // Let active layer draw on top (chain paths, etc)
        if (this.activeLayer) {
            this.activeLayer.renderOverlay(ctx, t);
        }

        ctx.restore();
    }

    _renderNodes() {
        if (!this.data) return;

        let group = this.svg.select('.nodes-group');
        if (group.empty()) {
            group = this.svg.append('g').attr('class', 'nodes-group');
        }

        const validNodes = Array.from(this.projectedNodes.values());

        const circles = group.selectAll('circle')
            .data(validNodes, d => d.id);

        circles.exit().remove();

        const self = this;

        circles.enter()
            .append('circle')
            .attr('class', 'airport-node')
            .merge(circles)
            .attr('cx', d => d.x)
            .attr('cy', d => d.y)
            .attr('r', d => this.nodeScale(d.total_flights))
            .attr('fill', d => this._nodeColor(d))
            .attr('stroke', '#cbd5e1')
            .attr('stroke-width', 0.5)
            .on('mouseenter', function(event, d) { self._showTooltip(event, d); })
            .on('mouseleave', function() { self._hideTooltip(); })
            .on('click', function(event, d) { self._onAirportClick(d); });

        // Apply highlight state
        if (this.selectedAirport) {
            this._applyHighlight(this.selectedAirport);
        }
    }

    _nodeColor(d) {
        if (this.activeLayer) {
            const c = this.activeLayer.getNodeColor(d);
            if (c) return c;
        }
        if (d.pct_delayed == null) return '#555';
        if (d.pct_delayed < 0.15) return this.riskColor.low;
        if (d.pct_delayed < 0.25) return this.riskColor.medium;
        if (d.pct_delayed < 0.40) return this.riskColor.high;
        return this.riskColor.severe;
    }

    _showTooltip(event, d) {
        const tooltip = document.getElementById('tooltip');
        const delay = d.avg_delay != null ? d.avg_delay.toFixed(1) + ' min' : 'N/A';
        tooltip.innerHTML = `
            <strong>${d.id}</strong> — ${d.name || ''}<br>
            Flights: ${(d.total_flights || 0).toLocaleString()}<br>
            Avg delay: ${delay}
        `;
        tooltip.style.display = 'block';
        // Position relative to viz container (tooltip's offset parent)
        const rect = this.container.getBoundingClientRect();
        const x = event.clientX - rect.left;
        const y = event.clientY - rect.top;
        const tipW = tooltip.offsetWidth;
        if (x + tipW + 20 > rect.width) {
            tooltip.style.left = (x - tipW - 12) + 'px';
        } else {
            tooltip.style.left = (x + 12) + 'px';
        }
        tooltip.style.top = (y - 28) + 'px';
    }

    _hideTooltip() {
        document.getElementById('tooltip').style.display = 'none';
    }

    _onAirportClick(d) {
        // Let active layer handle first — it may suppress default behavior
        if (this.activeLayer && this.activeLayer.onAirportClick(d)) {
            return;
        }
        this._clearSupplemental();
        this.selectedAirport = d.id;
        this._applyHighlight(d.id);
        this._renderEdges(d.id);
        InfoPanel.showAirport(d.id);
        this._expandSelection(d.id);
    }

    // Fetch routes for a selected airport and pull in any destination nodes that
    // were filtered out of the main network view (e.g. small regional airports).
    async _expandSelection(code) {
        if (!this.data) return;
        const routeData = await API.getRoutes({ origin: code, min_flights: 0, limit: 50 });
        const routes = routeData.routes || [];

        const missing = routes.filter(r => !this.projectedNodes.has(r.dest));
        if (missing.length === 0) return;

        const airports = await Promise.all(
            missing.map(r => API.getAirport(r.dest).catch(() => null))
        );

        // look up the region of the selected airport so we can skip cross-region neighbors
        const originNode = this.projectedNodes.get(code);
        const originRegion = originNode
            ? this._projectionRegion(originNode.lon, originNode.lat)
            : 'lower48';

        let added = false;
        for (let i = 0; i < missing.length; i++) {
            const route = missing[i];
            const airport = airports[i];
            if (!airport || airport.lat == null || airport.lon == null) continue;

            // Don't add supplemental nodes that are in a different sub-projection
            // region — their edges would draw as long cross-map diagonals.
            if (this._projectionRegion(airport.lon, airport.lat) !== originRegion) continue;

            const projected = this.projection([airport.lon, airport.lat]);
            if (!projected) continue;  // outside AlbersUSA bounds (Hawaii, etc.)

            const node = {
                id: airport.iata,
                name: airport.name,
                lat: airport.lat,
                lon: airport.lon,
                total_flights: airport.total_flights || 0,
                avg_delay: airport.avg_arr_delay,
                pct_delayed: airport.pct_delayed,
                delay_score: airport.delay_score,
                recovery_score: airport.recovery_score,
                x: projected[0],
                y: projected[1],
            };
            this.projectedNodes.set(airport.iata, node);
            this.data.nodes.push(node);
            this._supplementalNodes.add(airport.iata);

            this.data.edges.push({
                source: code,
                target: airport.iata,
                flight_count: route.flight_count || 0,
                avg_delay: route.avg_delay,
                pct_delayed: route.pct_delayed,
                delay_risk: route.delay_risk || 'low',
            });
            this._supplementalEdges.push({ source: code, target: airport.iata });
            added = true;
        }

        if (added && this.selectedAirport === code) {
            this._applyHighlight(code);
            this._renderEdges(code);
            this._renderNodes();
        }
    }

    _clearSupplemental() {
        if (this._supplementalNodes.size === 0) return;
        if (!this.data) { this._supplementalNodes.clear(); this._supplementalEdges = []; return; }

        this.data.nodes = this.data.nodes.filter(n => !this._supplementalNodes.has(n.id));
        const suppSet = this._supplementalEdges;
        this.data.edges = this.data.edges.filter(e =>
            !suppSet.some(s => s.source === e.source && s.target === e.target)
        );
        this._supplementalNodes.forEach(c => this.projectedNodes.delete(c));
        this._supplementalNodes.clear();
        this._supplementalEdges = [];
    }

    _applyHighlight(code) {
        const srcNode = this.projectedNodes.get(code);
        const srcRegion = srcNode ? this._projectionRegion(srcNode.lon, srcNode.lat) : 'lower48';

        this.svg.selectAll('.airport-node')
            .classed('dimmed', d => {
                if (d.id === code) return false;
                // Airports in a different AlbersUSA sub-projection are never
                // reachable via a rendered edge — always dim them.
                if (this._projectionRegion(d.lon, d.lat) !== srcRegion) return true;
                return !this.data.edges.some(
                    e => (e.source === code && e.target === d.id) ||
                         (e.target === code && e.source === d.id)
                );
            })
            .classed('highlighted', d => d.id === code);
    }

    clearSelection() {
        this._clearSupplemental();
        this.selectedAirport = null;
        this._lastHoveredEdge = null;
        this.svg.node().style.cursor = '';
        this.svg.selectAll('.airport-node')
            .classed('dimmed', false)
            .classed('highlighted', false);
        // Clear frozen propagation dots if any
        this.svg.selectAll('.flight-dot').remove();
        if (this.activeLayer) this.activeLayer.stopAnimation();
        // Full render — need to reset node fills too (chain layer dims them)
        this.render();
    }

    resize() {
        this._setupDimensions();
        this._projectNodes();
        this.render();
    }
}
