import os
import re
import csv
import uuid
import time
import shutil
import zipfile
import unicodedata
from io import StringIO
from flask import Flask, request, jsonify, send_file, render_template, session, Response
from werkzeug.utils import secure_filename
import PyPDF2
import docx

app = Flask(__name__)

# Stable secret key — persist across restarts so sessions survive server reloads.
# Falls back to a random key if env var not set (fine for local use).
app.secret_key = os.environ.get('RESUMESORT_SECRET', 'resumesort-local-secret-key-change-in-prod')
app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024  # 500 MB

BASE_DIR         = os.path.dirname(os.path.abspath(__file__))
UPLOAD_FOLDER    = os.path.join(BASE_DIR, 'uploads')
PROCESSED_FOLDER = os.path.join(BASE_DIR, 'processed')
TEMP_FOLDER      = os.path.join(BASE_DIR, 'temp')

for d in [UPLOAD_FOLDER, PROCESSED_FOLDER, TEMP_FOLDER]:
    os.makedirs(d, exist_ok=True)

ALLOWED_EXTENSIONS = {'pdf', 'docx'}

# ── Session TTL cleanup ───────────────────────────────────────────────────────
# Keep in-memory sessions for max 2 hours, then evict to prevent memory leaks.
SESSION_TTL = 7200  # seconds
sessions = {}  # sid -> { records, seen_emails, seen_name_role, stats, last_active }

def _evict_old_sessions():
    now = time.time()
    stale = [sid for sid, s in sessions.items() if now - s.get('last_active', 0) > SESSION_TTL]
    for sid in stale:
        _cleanup_session_files(sid)
        del sessions[sid]

def _cleanup_session_files(sid):
    session_dir = os.path.join(PROCESSED_FOLDER, sid)
    if os.path.exists(session_dir):
        shutil.rmtree(session_dir, ignore_errors=True)
    zip_path = os.path.join(TEMP_FOLDER, f"resumes_{sid}.zip")
    if os.path.exists(zip_path):
        os.remove(zip_path)

def _fresh_session():
    return {
        'records': [], 'seen_emails': {}, 'seen_name_role': {},
        'stats': {'total': 0, 'duplicates': 0, 'errors': 0, 'processed': 0},
        'last_active': time.time()
    }

def get_session_id():
    if 'sid' not in session:
        session['sid'] = str(uuid.uuid4())
    return session['sid']

def get_session_dir(sid):
    d = os.path.join(PROCESSED_FOLDER, sid)
    os.makedirs(d, exist_ok=True)
    return d

# ── Text Extraction ───────────────────────────────────────────────────────────

def extract_text_pdf(filepath):
    try:
        text = ""
        with open(filepath, 'rb') as f:
            reader = PyPDF2.PdfReader(f)
            if reader.is_encrypted:
                try: reader.decrypt('')
                except: return None, "Encrypted PDF — cannot read"
            for page in reader.pages:
                text += (page.extract_text() or "") + "\n"
        return text.strip(), None
    except Exception as e:
        return None, f"PDF parse error: {e}"

def extract_text_docx(filepath):
    try:
        doc = docx.Document(filepath)
        parts = [p.text for p in doc.paragraphs]
        # Also grab text from tables inside the docx
        for table in doc.tables:
            for row in table.rows:
                for cell in row.cells:
                    parts.append(cell.text)
        return "\n".join(parts).strip(), None
    except Exception as e:
        return None, f"DOCX parse error: {e}"

def extract_text(filepath, ext):
    if ext == 'pdf':  return extract_text_pdf(filepath)
    if ext == 'docx': return extract_text_docx(filepath)
    return None, "Unsupported format"

# ── Patterns ──────────────────────────────────────────────────────────────────

EMAIL_RE = re.compile(r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}')

PHONE_RE = re.compile(
    r'(?:'
    r'\+?91[\s\-]?[6-9]\d{9}'                       # +91 Indian mobile
    r'|0[6-9]\d{9}'                                   # 0XXXXXXXXXX
    r'|\+?[1-9]\d{0,2}[\s\-]\d{3,5}[\s\-]\d{4,7}'   # international spaced
    r'|(?<!\d)[6-9]\d{9}(?!\d)'                       # plain 10-digit (6-9 start)
    r')'
)

JOB_KEYWORDS = [
    'software engineer','senior software engineer','junior software engineer',
    'lead software engineer','principal engineer','staff engineer',
    'full stack developer','frontend developer','backend developer',
    'web developer','mobile developer','ios developer','android developer',
    'data scientist','data analyst','data engineer','machine learning engineer',
    'ai engineer','ml engineer','nlp engineer',
    'devops engineer','cloud engineer','site reliability engineer','sre',
    'systems engineer','embedded engineer','firmware engineer',
    'electrical engineer','mechanical engineer','civil engineer',
    'chemical engineer','biomedical engineer','aerospace engineer',
    'network engineer','security engineer','cybersecurity analyst',
    'database administrator','dba','system administrator','it administrator',
    'project manager','product manager','program manager','scrum master',
    'business analyst','financial analyst','hr manager','hr executive',
    'marketing manager','sales manager','account manager',
    'graphic designer','ui designer','ux designer','ui/ux designer',
    'content writer','technical writer','copywriter',
    'qa engineer','test engineer','quality assurance','sdet',
    'research engineer','research scientist','professor','lecturer',
    'accountant','chartered accountant','finance manager',
    'operations manager','supply chain manager','logistics manager',
    'consultant','analyst','architect','developer','engineer','designer',
    'manager','director','executive','officer','coordinator','specialist',
    'intern','fresher','trainee','associate',
]

TITLE_LABELS = [
    'position applied','applying for','role','designation',
    'current role','current designation','job title','position',
]

SECTION_HEADERS = {
    'objective','summary','profile','career objective','experience',
    'education','skills','contact','references','projects',
    'certifications','achievements','interests','hobbies',
    'work experience','professional experience','academic background',
}

# Pre-sort once at startup for performance
_SORTED_KEYWORDS = sorted(JOB_KEYWORDS, key=len, reverse=True)

# ── Extractors ────────────────────────────────────────────────────────────────

def extract_email(text):
    m = EMAIL_RE.findall(text)
    # Filter out common false positives like image filenames
    for candidate in m:
        if not candidate.endswith(('.png', '.jpg', '.gif', '.svg', '.ico')):
            return candidate.lower()
    return None

def extract_phone(text):
    cleaned = re.sub(r'(?i)(mobile|phone|cell|contact|tel|mob|ph)[:\s\-\.#]*', ' ', text)
    matches = PHONE_RE.findall(cleaned)
    if not matches:
        return None
    raw    = matches[0]
    digits = re.sub(r'[^\d]', '', raw)
    # Format Indian numbers
    if len(digits) == 12 and digits.startswith('91'):
        return f"+91 {digits[2:7]} {digits[7:]}"
    if len(digits) == 13 and digits.startswith('091'):
        return f"+91 {digits[3:8]} {digits[8:]}"
    if len(digits) == 10 and digits[0] in '6789':
        return f"+91 {digits[:5]} {digits[5:]}"
    # Keep raw for international
    return re.sub(r'[^\d+\s\-]', '', raw).strip()

def extract_job_title(text):
    text_lower = text.lower()
    lines = text.split('\n')

    # Strategy 1: explicit label "Designation: X"
    for line in lines[:30]:
        ll = line.lower().strip()
        for label in TITLE_LABELS:
            if ll.startswith(label):
                candidate = re.sub(r'^[^:]+[:\-–]\s*', '', line, flags=re.IGNORECASE).strip()
                if 3 < len(candidate) < 80:
                    return candidate

    # Strategy 2: whole line contains a known keyword (skip section headers)
    for line in lines[:40]:
        ll = line.lower().strip()
        if ll in SECTION_HEADERS or len(ll) < 3:
            continue
        for kw in _SORTED_KEYWORDS:
            if kw in ll:
                clean = re.sub(r'[^a-zA-Z\s/\-]', '', line).strip()
                if clean and clean.lower() not in SECTION_HEADERS and len(clean) <= 80:
                    return clean[:60]

    # Strategy 3: anywhere in full text (fallback)
    for kw in _SORTED_KEYWORDS:
        if kw in text_lower:
            return kw.title()

    return "Professional"

def extract_name(text):
    lines = [l.strip() for l in text.split('\n') if l.strip()]
    if not lines:
        return "Unknown"
    bad_starts = ['resume','curriculum vitae','cv','profile','name:','contact','address']
    for line in lines[:6]:
        if any(line.lower().startswith(b) for b in bad_starts):
            if ':' in line:
                part = line.split(':', 1)[1].strip()
                if looks_like_name(part):
                    return part
            continue
        if looks_like_name(line):
            return line
    # Fallback: explicit "Name: X" pattern anywhere in first 20 lines
    for line in lines[:20]:
        m = re.match(r'(?:name|candidate)[:\-–]\s*(.+)', line, re.IGNORECASE)
        if m and looks_like_name(m.group(1).strip()):
            return m.group(1).strip()
    return lines[0][:40] if lines else "Unknown"

def looks_like_name(text):
    text = text.strip()
    if not text or len(text) < 3 or len(text) > 50:
        return False
    words = text.split()
    if not (2 <= len(words) <= 5):
        return False
    if any(c.isdigit() for c in text):
        return False
    if sum(c.isalpha() or c.isspace() for c in text) / len(text) < 0.85:
        return False
    junk = ['engineer','developer','manager','analyst','objective','summary',
            'experience','education','skills','contact','address','phone',
            'email','mobile','linkedin','github','portfolio','website']
    if any(j in text.lower() for j in junk):
        return False
    return True

# ── Filename helpers ──────────────────────────────────────────────────────────

def normalize_string(s):
    s = unicodedata.normalize('NFKD', s)
    s = s.encode('ascii', 'ignore').decode('ascii')
    s = re.sub(r'[^a-zA-Z0-9\s\-]', '', s)
    return re.sub(r'[\s\-]+', '_', s.strip())

def make_filename(name, job_title, ext):
    n = normalize_string(name)   or "Unknown"
    j = normalize_string(job_title) or "Professional"
    # Cap total filename length at 100 chars (before extension)
    combined = f"{n}_{j}"
    if len(combined) > 100:
        combined = combined[:100]
    return f"{combined}.{ext}"

def unique_dest(session_dir, filename):
    """Return a collision-free destination path."""
    dest = os.path.join(session_dir, filename)
    if not os.path.exists(dest):
        return dest, filename
    base, dot_ext = os.path.splitext(filename)
    counter = 1
    while True:
        new_name = f"{base}_{counter}{dot_ext}"
        dest = os.path.join(session_dir, new_name)
        if not os.path.exists(dest):
            return dest, new_name
        counter += 1

# ── Routes ────────────────────────────────────────────────────────────────────

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/api/upload', methods=['POST'])
def upload_files():
    _evict_old_sessions()          # lazy cleanup on each upload call
    sid = get_session_id()
    session_dir = get_session_dir(sid)

    if sid not in sessions:
        sessions[sid] = _fresh_session()
    else:
        sessions[sid]['last_active'] = time.time()

    sess = sessions[sid]
    results = []

    for file in request.files.getlist('resumes'):
        if not file or not file.filename:
            continue

        filename = secure_filename(file.filename)
        if not filename:
            continue

        ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''

        if ext not in ALLOWED_EXTENSIONS:
            results.append({'file': filename, 'status': 'error', 'reason': 'Unsupported format (PDF/DOCX only)'})
            sess['stats']['errors'] += 1
            continue

        sess['stats']['total'] += 1
        tmp_path = os.path.join(TEMP_FOLDER, f"{uuid.uuid4()}.{ext}")
        file.save(tmp_path)

        try:
            text, err = extract_text(tmp_path, ext)
        except Exception as e:
            err = str(e)
            text = None

        if err or not text or len(text.strip()) < 20:
            _safe_remove(tmp_path)
            reason = err or "Empty or unreadable file"
            results.append({'file': filename, 'status': 'error', 'reason': reason})
            sess['stats']['errors'] += 1
            continue

        email     = extract_email(text)
        phone     = extract_phone(text)
        name      = extract_name(text)
        job_title = extract_job_title(text)

        # Duplicate detection
        dup_email   = email.lower() if email else None
        dup_namejob = f"{normalize_string(name).lower()}_{normalize_string(job_title).lower()}"

        is_dup, dup_reason = False, ""
        if dup_email and dup_email in sess['seen_emails']:
            is_dup, dup_reason = True, f"Same email as {sess['seen_emails'][dup_email]}"
        elif dup_namejob in sess['seen_name_role']:
            is_dup, dup_reason = True, f"Same name+role as {sess['seen_name_role'][dup_namejob]}"

        if is_dup:
            _safe_remove(tmp_path)
            results.append({'file': filename, 'status': 'duplicate', 'reason': dup_reason,
                            'name': name, 'job_title': job_title, 'email': email or '', 'phone': phone or ''})
            sess['stats']['duplicates'] += 1
            continue

        # Register as seen (store original filename for better dup messages)
        if dup_email:
            sess['seen_emails'][dup_email] = filename
        sess['seen_name_role'][dup_namejob] = filename

        # Rename & move
        new_filename = make_filename(name, job_title, ext)
        dest_path, new_filename = unique_dest(session_dir, new_filename)

        try:
            shutil.move(tmp_path, dest_path)
        except Exception as e:
            _safe_remove(tmp_path)
            results.append({'file': filename, 'status': 'error', 'reason': f"File save error: {e}"})
            sess['stats']['errors'] += 1
            continue

        record = {
            'original':  filename,
            'renamed':   new_filename,
            'name':      name,
            'job_title': job_title,
            'email':     email     or '',
            'phone':     phone     or '',
            'status':    'processed',
        }
        sess['records'].append(record)
        sess['stats']['processed'] += 1
        results.append({**record, 'file': filename})

    return jsonify({'results': results, 'stats': sess['stats']})

@app.route('/api/stats')
def get_stats():
    sid = get_session_id()
    sess = sessions.get(sid, _fresh_session())
    return jsonify({'stats': sess['stats'], 'records': sess['records']})

@app.route('/api/download/zip')
def download_zip():
    sid = get_session_id()
    session_dir = get_session_dir(sid)
    files = os.listdir(session_dir)
    if not files:
        return jsonify({'error': 'No processed files available'}), 404

    zip_path = os.path.join(TEMP_FOLDER, f"resumes_{sid}.zip")
    with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
        for fname in files:
            zf.write(os.path.join(session_dir, fname), fname)
    return send_file(zip_path, as_attachment=True, download_name='processed_resumes.zip')

@app.route('/api/download/csv')
def download_csv():
    sid = get_session_id()
    sess = sessions.get(sid, _fresh_session())
    output = StringIO()
    writer = csv.DictWriter(output,
        fieldnames=['original','renamed','name','job_title','email','phone','status'])
    writer.writeheader()
    # Export ALL records (including duplicates & errors) for full visibility
    writer.writerows(sess['records'])
    return Response(output.getvalue(), mimetype='text/csv',
        headers={'Content-Disposition': 'attachment; filename=extracted_data.csv'})

@app.route('/api/reset', methods=['POST'])
def reset_session():
    sid = get_session_id()
    _cleanup_session_files(sid)
    os.makedirs(os.path.join(PROCESSED_FOLDER, sid), exist_ok=True)
    if sid in sessions:
        del sessions[sid]
    return jsonify({'status': 'ok'})

@app.route('/api/search')
def search_records():
    sid = get_session_id()
    sess = sessions.get(sid, _fresh_session())
    q     = request.args.get('q', '').lower().strip()
    field = request.args.get('field', 'all')
    status_filter = request.args.get('status', 'all')  # new: filter by status

    records = sess['records']

    if status_filter != 'all':
        records = [r for r in records if r['status'] == status_filter]

    if q:
        def match(r):
            if field == 'name':      return q in r.get('name','').lower()
            if field == 'job_title': return q in r.get('job_title','').lower()
            if field == 'phone':     return q in r.get('phone','').lower()
            return any(q in r.get(k,'').lower() for k in ('name','job_title','email','phone'))
        records = [r for r in records if match(r)]

    return jsonify({'records': records, 'count': len(records)})

# ── Helpers ───────────────────────────────────────────────────────────────────

def _safe_remove(path):
    try:
        if path and os.path.exists(path):
            os.remove(path)
    except Exception:
        pass

# ── Startup cleanup ────────────────────────────────────────────────────────────
# Remove any stale temp files left by a previous crash
def _startup_cleanup():
    for fname in os.listdir(TEMP_FOLDER):
        fpath = os.path.join(TEMP_FOLDER, fname)
        try:
            age = time.time() - os.path.getmtime(fpath)
            if age > SESSION_TTL:
                os.remove(fpath)
        except Exception:
            pass

_startup_cleanup()

if __name__ == '__main__':
   app.run(debug=True, port=5000)
    
    
