#!/usr/bin/env python3
"""
dp_frag.py  --  C28x data-page fragmentation analyzer
======================================================

Usage (recommended -- map file is generated by every build):
    python dp_frag.py yourfile.map [--section .ebss] [--html report.html]

Usage (ofd2000 -- section labels only, sizes still from addr-delta):
    ofd2000 --obj_display=none,symbols yourfile.out > sym.txt
    python dp_frag.py --ofd sym.txt [--section .ebss] [--html report.html]

Note on sizes
-------------
The TI COFF format does not store per-symbol sizes in the linker .map file or
the COFF symbol table.  Sizes are inferred from address deltas: sort symbols
within the section by address, size(sym[i]) = addr(sym[i+1]) - addr(sym[i]).
The last symbol uses (section_end - addr).  This is correct for fragmentation
accounting -- any word between the end of one symbol and the start of the next
IS wasted DP space, because the linker cannot fill those gaps post-placement.

C28x DP page = 64 words (0x40).  The linker will not place a symbol such that
it crosses a DP boundary, so any tail space at the end of a page that is too
small to hold the next symbol becomes a permanent hole.
"""

import re
import sys
import argparse
from collections import defaultdict
from pathlib import Path

DP_SIZE = 64  # words per data page on C28x


# ---------------------------------------------------------------------------
# Parsing -- .map file (primary)
# ---------------------------------------------------------------------------

def _parse_section_ranges(text):
    """
    Return dict {section_name: (start_addr, end_addr)} from the
    SECTION ALLOCATION MAP block.

    Handles both single-line format:
        .ebss      1    00008e00    00000020     UNINITIALIZED
    and two-line format (some custom sections):
        codestart
        *          0    00000000    00000002
    """
    ranges = {}
    in_map = False
    pending_name = None

    for line in text.splitlines():
        if 'SECTION ALLOCATION MAP' in line:
            in_map = True
            continue
        if not in_map:
            continue
        # Two-line format: asterisk continuation
        if pending_name:
            m = re.match(r'^\*\s+\d+\s+([0-9a-fA-F]+)\s+([0-9a-fA-F]+)', line)
            if m:
                start = int(m.group(1), 16)
                length = int(m.group(2), 16)
                ranges[pending_name] = (start, start + length)
            pending_name = None
            continue
        # Single-line format: section name + page + origin + length on same line
        m = re.match(r'^(\.?\w+)\s+(\d+)\s+([0-9a-fA-F]+)\s+([0-9a-fA-F]+)', line)
        if m:
            name = m.group(1)
            start = int(m.group(3), 16)
            length = int(m.group(4), 16)
            ranges[name] = (start, start + length)
            continue
        # Two-line format: just a section name on its own line
        m = re.match(r'^([A-Za-z_]\w*)\s*$', line)
        if m:
            candidate = m.group(1)
            # Skip header words
            if candidate not in ('GLOBAL', 'LOCAL', 'output', 'section',
                                 'attributes', 'MEMORY', 'PAGE'):
                pending_name = candidate
    return ranges


def _parse_global_symbols_by_address(text):
    """
    Return list of (addr, name) from the
    'GLOBAL SYMBOLS: SORTED BY Symbol Address' block.
    """
    symbols = []
    in_block = False

    for line in text.splitlines():
        if 'SORTED BY Symbol Address' in line:
            in_block = True
            continue
        if not in_block:
            continue
        m = re.match(r'^([0-9a-fA-F]{8})\s+(\S+)\s*$', line)
        if m:
            symbols.append((int(m.group(1), 16), m.group(2)))
    return symbols


def parse_map_file(text, section_filter='.ebss'):
    """
    Parse a TI COFF linker .map file.
    Returns list of dicts {name, addr, size, section}.
    Sizes are derived from address deltas within the section.
    """
    section_ranges = _parse_section_ranges(text)

    # Determine which address ranges belong to the target section(s)
    if section_filter:
        targets = {k: v for k, v in section_ranges.items()
                   if k == section_filter}
    else:
        targets = section_ranges

    if not targets:
        available = ', '.join(sorted(section_ranges.keys()))
        raise ValueError(
            f"Section '{section_filter}' not found in map file.\n"
            f"Available sections: {available}"
        )

    all_syms_by_addr = _parse_global_symbols_by_address(text)

    symbols = []
    for sec_name, (sec_start, sec_end) in sorted(targets.items(),
                                                  key=lambda x: x[1][0]):
        if sec_end == sec_start:
            continue  # empty section
        # Filter symbols to this section's address range
        in_sec = [(addr, name) for addr, name in all_syms_by_addr
                  if sec_start <= addr < sec_end]
        # Deduplicate (same addr+name can appear in both sorted tables)
        seen = set()
        unique = []
        for addr, name in in_sec:
            key = (addr, name)
            if key not in seen:
                seen.add(key)
                unique.append((addr, name))
        unique.sort(key=lambda x: x[0])

        # Compute sizes from address deltas
        for i, (addr, name) in enumerate(unique):
            if i + 1 < len(unique):
                size = unique[i + 1][0] - addr
            else:
                size = sec_end - addr
            if size > 0:
                symbols.append({
                    'name': name,
                    'addr': addr,
                    'size': size,
                    'section': sec_name,
                })
    return symbols


# ---------------------------------------------------------------------------
# Parsing -- ofd2000 --obj_display=none,symbols (secondary)
# ---------------------------------------------------------------------------

def parse_ofd_symbols(text, section_filter='.ebss'):
    """
    Parse ofd2000 --obj_display=none,symbols output.
    Format:
      id name   value  kind  section  binding  type
      ...
      356 _CpuTimer2  0x00008e08  defined  .ebss  global  object

    Section is known per symbol, but sizes are not available.
    Sizes are derived from address deltas within each section group.
    """
    # Regex for the symbol table lines
    sym_re = re.compile(
        r'^\s*\d+\s+'          # id
        r'(\S+)\s+'            # name
        r'0x([0-9a-fA-F]+)\s+' # value
        r'\S+\s+'              # kind
        r'(\S+)\s+'            # section
        r'(\S+)\s+'            # binding
        r'(\S+)',              # type
        re.MULTILINE
    )

    # Collect global data objects in the target section
    by_section = defaultdict(list)
    for m in sym_re.finditer(text):
        name, addr_hex, section, binding, typ = m.groups()
        if typ != 'object':
            continue
        if binding != 'global':
            continue
        if section_filter and section != section_filter:
            continue
        by_section[section].append((int(addr_hex, 16), name))

    if not by_section:
        return []

    symbols = []
    for sec_name, entries in by_section.items():
        # Deduplicate and sort
        seen = set()
        unique = []
        for item in entries:
            if item not in seen:
                seen.add(item)
                unique.append(item)
        unique.sort(key=lambda x: x[0])

        # Size from delta; last symbol gets size=1 (unknown end)
        for i, (addr, name) in enumerate(unique):
            if i + 1 < len(unique):
                size = unique[i + 1][0] - addr
            else:
                size = 1  # unknown -- flag it
            symbols.append({
                'name': name,
                'addr': addr,
                'size': size,
                'section': sec_name,
            })
    return symbols


# ---------------------------------------------------------------------------
# Analysis
# ---------------------------------------------------------------------------

def assign_pages(symbols):
    pages = defaultdict(list)
    for s in symbols:
        pg = s['addr'] // DP_SIZE
        pages[pg].append(s)
    return dict(sorted(pages.items()))


def analyze_page(page_idx, syms):
    base = page_idx * DP_SIZE
    sorted_syms = sorted(syms, key=lambda s: s['addr'])
    cursor = base
    gaps = []
    used = 0

    for s in sorted_syms:
        if s['addr'] > cursor:
            gaps.append((cursor, s['addr'] - cursor))
        end = s['addr'] + s['size']
        used += s['size']
        cursor = end

    page_end = base + DP_SIZE
    tail = max(0, page_end - cursor)

    return {
        'page': page_idx,
        'base': base,
        'syms': sorted_syms,
        'used': used,
        'gaps': gaps,
        'gap_words': sum(g[1] for g in gaps),
        'tail': tail,
    }


def global_stats(page_analyses):
    total_pages = len(page_analyses)
    total_capacity = total_pages * DP_SIZE
    total_used = sum(p['used'] for p in page_analyses)
    total_gap = sum(p['gap_words'] for p in page_analyses)
    total_tail = sum(p['tail'] for p in page_analyses)
    waste_pct = total_gap / total_capacity * 100 if total_capacity else 0
    return {
        'pages': total_pages,
        'capacity': total_capacity,
        'used': total_used,
        'gap': total_gap,
        'tail': total_tail,
        'waste_pct': waste_pct,
    }


# ---------------------------------------------------------------------------
# Reorder suggestions
# ---------------------------------------------------------------------------

def suggest_reorder(page_analyses):
    suggestions = []
    for pa in page_analyses:
        if pa['gap_words'] == 0:
            continue
        syms = pa['syms']
        reordered = sorted(syms, key=lambda s: s['size'], reverse=True)
        simulated_gap = 0
        cursor = pa['base']
        for s in reordered:
            align = min(8, _next_pow2(s['size']))
            aligned_start = ((cursor + align - 1) // align) * align
            simulated_gap += aligned_start - cursor
            cursor = aligned_start + s['size']
        savings = pa['gap_words'] - simulated_gap
        if savings > 0:
            suggestions.append({
                'page': pa['page'],
                'current_gap': pa['gap_words'],
                'simulated_gap': simulated_gap,
                'savings': savings,
                'order': [s['name'] for s in reordered],
            })
    return sorted(suggestions, key=lambda x: x['savings'], reverse=True)


def _next_pow2(n):
    if n <= 1:
        return 1
    p = 1
    while p < n:
        p <<= 1
    return p


# ---------------------------------------------------------------------------
# Console report
# ---------------------------------------------------------------------------

def print_report(page_analyses, stats, suggestions):
    W = 72
    print('=' * W)
    print('  C28x DP Fragmentation Report')
    print('=' * W)
    print(f"  Data pages occupied : {stats['pages']}")
    print(f"  Total capacity      : {stats['capacity']} words ({stats['capacity']*2} bytes)")
    print(f"  Used by symbols     : {stats['used']} words")
    print(f"  Alignment gaps      : {stats['gap']} words  ({stats['waste_pct']:.1f}% of capacity)")
    print(f"  Free tail space     : {stats['tail']} words")
    print()

    if stats['gap'] == 0:
        print('  No alignment gaps found -- packing looks optimal.')
        if stats['tail'] > 0:
            print(f"  {stats['tail']} words of tail space remain (partial last page).")
        return

    print('  Per-page breakdown (pages with gaps only):')
    print('  ' + '-' * 68)
    print(f"  {'DP':>4}  {'base':>8}  {'used':>5}  {'gaps':>5}  {'tail':>5}  map")
    print('  ' + '-' * 68)

    for pa in page_analyses:
        if pa['gap_words'] == 0:
            continue
        bar = _ascii_bar(pa['syms'], pa['base'], width=24)
        print(f"  DP{pa['page']:<4}  0x{pa['base']:04X}     {pa['used']:>4}w  "
              f"{pa['gap_words']:>4}w  {pa['tail']:>4}w  {bar}")
        for s in pa['syms']:
            print(f"           0x{s['addr']:04X}  {s['name']:<26} {s['size']:>3}w")
        if pa['gaps']:
            for gaddr, glen in pa['gaps']:
                print(f"           0x{gaddr:04X}  {'*** gap ***':<26} {glen:>3}w  <-- wasted")
        print()

    if suggestions:
        print('  Reorder suggestions (by estimated word savings):')
        print('  ' + '-' * 68)
        for sg in suggestions[:5]:
            print(f"  DP{sg['page']}: current gap={sg['current_gap']}w  "
                  f"after reorder~{sg['simulated_gap']}w  saves~{sg['savings']}w")
            print(f"    suggested order: {', '.join(sg['order'])}")
        print()

    print('  Note: sizes inferred from address deltas -- exact only when no')
    print('  intra-symbol padding exists. Reorder savings are estimates.')
    print('=' * W)


def _ascii_bar(syms, base, width=24):
    bar = ['.'] * width
    for s in syms:
        start = s['addr'] - base
        for i in range(s['size']):
            pos = int((start + i) / DP_SIZE * width)
            if 0 <= pos < width:
                bar[pos] = '#'
    return ''.join(bar)


# ---------------------------------------------------------------------------
# HTML report
# ---------------------------------------------------------------------------

HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>C28x DP Fragmentation Report</title>
<style>
body{font-family:system-ui,sans-serif;background:#f8f7f4;color:#2c2c2a;margin:0;padding:24px}
h1{font-size:18px;font-weight:500;margin:0 0 16px}
.stats{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px}
.stat{background:#fff;border:0.5px solid #d3d1c7;border-radius:8px;padding:10px 16px;min-width:140px}
.stat-label{font-size:11px;color:#888780}
.stat-val{font-size:22px;font-weight:500;font-family:monospace}
.page-row{display:flex;align-items:center;margin-bottom:5px;gap:0}
.page-label{font-size:11px;color:#888780;width:60px;font-family:monospace}
.bar-wrap{flex:1;height:22px;display:flex;border-radius:4px;overflow:hidden;border:0.5px solid #d3d1c7;cursor:pointer}
.seg-used{background:#378ADD;height:100%}
.seg-gap{background:repeating-linear-gradient(45deg,#EF9F27,#EF9F27 3px,#FAC775 3px,#FAC775 6px);height:100%}
.seg-free{background:#f1efe8;height:100%}
.pct{font-size:10px;color:#888780;width:36px;text-align:right;font-family:monospace;padding-left:4px}
.legend{font-size:11px;color:#888;margin-bottom:8px;display:flex;gap:14px}
.legend span{display:inline-flex;align-items:center;gap:4px}
.swatch{width:12px;height:10px;border-radius:2px;display:inline-block}
#detail{margin-top:16px;background:#fff;border:0.5px solid #d3d1c7;border-radius:8px;padding:12px;min-height:60px}
table{width:100%;border-collapse:collapse;font-size:11px;font-family:monospace}
th{text-align:left;font-size:10px;color:#888;border-bottom:0.5px solid #d3d1c7;padding:3px 6px;font-family:system-ui}
td{padding:3px 6px;border-bottom:0.5px solid #f1efe8}
.badge{font-size:9px;padding:1px 5px;border-radius:3px}
.badge-used{background:#E6F1FB;color:#185FA5}
.badge-gap{background:#FAEEDA;color:#854F0B}
h2{font-size:14px;font-weight:500;margin:0 0 10px}
.note{font-size:10px;color:#aaa;margin-top:8px}
</style>
</head>
<body>
<h1>C28x DP Fragmentation Report</h1>
<div class="stats">
  <div class="stat"><div class="stat-label">alignment waste</div>
    <div class="stat-val" style="color:#BA7517">WASTE_PCT%</div></div>
  <div class="stat"><div class="stat-label">gap words</div>
    <div class="stat-val">GAP_W w</div></div>
  <div class="stat"><div class="stat-label">used words</div>
    <div class="stat-val">USED_W w</div></div>
  <div class="stat"><div class="stat-label">data pages</div>
    <div class="stat-val">N_PAGES</div></div>
</div>
<div class="legend">
  <span><span class="swatch" style="background:#378ADD"></span>used</span>
  <span><span class="swatch" style="background:repeating-linear-gradient(45deg,#EF9F27,#EF9F27 3px,#FAC775 3px,#FAC775 6px)"></span>alignment gap</span>
  <span><span class="swatch" style="background:#f1efe8;border:0.5px solid #d3d1c7"></span>free tail</span>
</div>
<div id="pages">PAGE_BARS</div>
<div id="detail"><span style="font-size:12px;color:#888">click a page bar to inspect symbols</span></div>
<p class="note">Sizes inferred from address deltas (COFF does not store per-symbol sizes).
Any gap shown IS wasted DP space -- the linker cannot backfill these holes.</p>
<script>
const DATA = PAGE_DATA;
function showDetail(idx){
  const pa = DATA[idx];
  const base = pa.page * 64;
  let rows='', syms=[...pa.syms].sort((a,b)=>a.addr-b.addr);
  let cursor=base;
  for(const s of syms){
    if(s.addr>cursor) rows+=`<tr><td style="color:#aaa;font-style:italic">[gap]</td><td>0x${cursor.toString(16).toUpperCase().padStart(4,'0')}</td><td></td><td><span class="badge badge-gap">${s.addr-cursor}w wasted</span></td></tr>`;
    rows+=`<tr><td>${s.name}</td><td>0x${s.addr.toString(16).toUpperCase().padStart(4,'0')}</td><td>${s.size}w</td><td><span class="badge badge-used">used</span></td></tr>`;
    cursor=s.addr+s.size;
  }
  const end=base+64;
  if(cursor<end) rows+=`<tr><td style="color:#aaa;font-style:italic">[free tail]</td><td>0x${cursor.toString(16).toUpperCase().padStart(4,'0')}</td><td></td><td>${end-cursor}w free</td></tr>`;
  document.getElementById('detail').innerHTML=`<h2>DP${pa.page} &nbsp; base 0x${base.toString(16).toUpperCase().padStart(4,'0')} &nbsp; used ${pa.used}w &nbsp; gaps ${pa.gap_words}w</h2><table><thead><tr><th>symbol</th><th>addr</th><th>size (delta)</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
}
</script>
</body>
</html>
"""


def build_html(page_analyses, stats):
    bars_html = ''
    page_data_js = '['
    for idx, pa in enumerate(page_analyses):
        base = pa['base']
        syms_sorted = sorted(pa['syms'], key=lambda s: s['addr'])
        cursor = base
        segs = ''
        for s in syms_sorted:
            if s['addr'] > cursor:
                w = s['addr'] - cursor
                segs += f'<div class="seg-gap" style="width:{w/DP_SIZE*100:.2f}%" title="gap {w}w"></div>'
            segs += (f'<div class="seg-used" style="width:{s["size"]/DP_SIZE*100:.2f}%"'
                     f' title="{s["name"]} ({s["size"]}w)"></div>')
            cursor = s['addr'] + s['size']
        tail = base + DP_SIZE - cursor
        if tail > 0:
            segs += f'<div class="seg-free" style="width:{tail/DP_SIZE*100:.2f}%"></div>'

        waste_pct = round(pa['gap_words'] / DP_SIZE * 100)
        color = '#BA7517' if waste_pct > 15 else '#888'
        bars_html += (
            f'<div class="page-row">'
            f'<div class="page-label">DP{pa["page"]}</div>'
            f'<div class="bar-wrap" onclick="showDetail({idx})">{segs}</div>'
            f'<div class="pct" style="color:{color}">{waste_pct}%</div>'
            f'</div>\n'
        )
        syms_js = '[' + ','.join(
            f'{{"name":"{s["name"]}","addr":{s["addr"]},"size":{s["size"]}}}'
            for s in pa['syms']
        ) + ']'
        page_data_js += (
            f'{{"page":{pa["page"]},"used":{pa["used"]},'
            f'"gap_words":{pa["gap_words"]},"tail":{pa["tail"]},'
            f'"syms":{syms_js}}},'
        )
    page_data_js = page_data_js.rstrip(',') + ']'

    html = HTML_TEMPLATE
    html = html.replace('WASTE_PCT', f"{stats['waste_pct']:.1f}")
    html = html.replace('GAP_W', str(stats['gap']))
    html = html.replace('USED_W', str(stats['used']))
    html = html.replace('N_PAGES', str(stats['pages']))
    html = html.replace('PAGE_BARS', bars_html)
    html = html.replace('PAGE_DATA', page_data_js)
    return html


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description='C28x DP fragmentation analyzer for TI COFF builds.',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument('input', nargs='?', default='-',
                        help='Linker .map file (default) or ofd2000 output with --ofd')
    parser.add_argument('--ofd', action='store_true',
                        help='Input is ofd2000 --obj_display=none,symbols output')
    parser.add_argument('--section', default='.ebss',
                        help='Data section to analyze (default: .ebss)')
    parser.add_argument('--all-sections', action='store_true',
                        help='Analyze all data sections (map mode only)')
    parser.add_argument('--html', metavar='FILE',
                        help='Write standalone HTML report to FILE')
    args = parser.parse_args()

    if args.input == '-':
        text = sys.stdin.read()
    else:
        text = Path(args.input).read_text(errors='replace')

    section = None if args.all_sections else args.section

    if args.ofd:
        symbols = parse_ofd_symbols(text, section_filter=section)
        source = 'ofd2000'
    else:
        try:
            symbols = parse_map_file(text, section_filter=section)
        except ValueError as e:
            print(f'ERROR: {e}')
            sys.exit(1)
        source = 'map file'

    if not symbols:
        print(f'ERROR: no symbols found in {section or "any section"} via {source}.')
        if not args.ofd:
            print('       Verify the .map file is from the TI COFF linker (lnk2000).')
            print('       Try --all-sections to see all available sections.')
        sys.exit(1)

    print(f'Parsed {len(symbols)} symbols from {section or "all sections"} ({source})')

    pages = assign_pages(symbols)
    page_analyses = [analyze_page(idx, syms) for idx, syms in pages.items()]
    stats = global_stats(page_analyses)
    suggestions = suggest_reorder(page_analyses)

    print_report(page_analyses, stats, suggestions)

    if args.html:
        html = build_html(page_analyses, stats)
        Path(args.html).write_text(html, encoding='utf-8')
        print(f'HTML report written to {args.html}')


if __name__ == '__main__':
    main()
